From 464b8a3901e7cb49dbb18972b871e2e0e1903555 Mon Sep 17 00:00:00 2001 From: "Nathaniel Starkman (@nstarman)" Date: Sun, 25 Jul 2021 18:12:26 -0400 Subject: [PATCH 01/31] SCF sampling code Signed-off-by: Nathaniel Starkman (@nstarman) --- sample_scf/__init__.py | 12 + sample_scf/_typing.py | 10 + sample_scf/base.py | 169 +++++++++++ sample_scf/core.py | 129 +++++++++ sample_scf/sample_exact.py | 398 ++++++++++++++++++++++++++ sample_scf/sample_intrp.py | 562 +++++++++++++++++++++++++++++++++++++ sample_scf/utils.py | 306 ++++++++++++++++++++ setup.cfg | 1 + 8 files changed, 1587 insertions(+) create mode 100644 sample_scf/_typing.py create mode 100644 sample_scf/base.py create mode 100644 sample_scf/core.py create mode 100644 sample_scf/sample_exact.py create mode 100644 sample_scf/sample_intrp.py create mode 100644 sample_scf/utils.py diff --git a/sample_scf/__init__.py b/sample_scf/__init__.py index 222aaf5..c1de58c 100644 --- a/sample_scf/__init__.py +++ b/sample_scf/__init__.py @@ -3,3 +3,15 @@ # LOCAL from sample_scf._astropy_init import * # isort: +split # noqa: F401, F403 +from sample_scf.core import SCFSampler +from sample_scf.sample_exact import SCFSampler as SCFSamplerExact +from sample_scf.sample_intrp import SCFSampler as SCFSamplerInterp + +# from .sample_exact import SCFPhiSampler as SCFPhiSamplerExact +# from .sample_exact import SCFRSampler as SCFRSamplerExact +# from .sample_exact import SCFThetaSampler as SCFThetaSamplerExact +# from .sample_intrp import SCFPhiSampler as SCFPhiSamplerInterp +# from .sample_intrp import SCFRSampler as SCFRSamplerInterp +# from .sample_intrp import SCFThetaSampler as SCFThetaSamplerInterp + +__all__ = ["SCFSampler", "SCFSamplerExact", "SCFSamplerInterp"] diff --git a/sample_scf/_typing.py b/sample_scf/_typing.py new file mode 100644 index 0000000..1af6039 --- /dev/null +++ b/sample_scf/_typing.py @@ -0,0 +1,10 @@ +# Licensed under a 3-clause BSD style license - see LICENSE.rst +# BUILT-IN +import typing as T + +# THIRD PARTY +import numpy as np +import numpy.typing as npt + +RandomLike = T.Union[None, int, np.random.RandomState] +NDArray64 = npt.NDArray[np.float64] diff --git a/sample_scf/base.py b/sample_scf/base.py new file mode 100644 index 0000000..c1f051f --- /dev/null +++ b/sample_scf/base.py @@ -0,0 +1,169 @@ +# -*- coding: utf-8 -*- + +"""Base class for sampling from an SCF Potential. + +Description. + +""" + +############################################################################## +# IMPORTS + +from __future__ import annotations + +# BUILT-IN +import typing as T + +# THIRD PARTY +import astropy.units as u +import numpy as np +import numpy.typing as npt +from astropy.coordinates import PhysicsSphericalRepresentation +from scipy._lib._util import check_random_state +from scipy.stats import rv_continuous + +# LOCAL +from ._typing import NDArray64, RandomLike + +__all__: T.List[str] = [] + + +############################################################################## +# CODE +############################################################################## + + +class rv_continuous_modrvs(rv_continuous): + """ + Modified :class:`scipy.stats.rv_continuous` to use custom rvs methods. + Made by stripping down the original scipy implementation. + See :class:`scipy.stats.rv_continuous` for details. + """ + + def rvs( + self, + *args: T.Union[float, npt.ArrayLike], + size: T.Optional[int] = None, + random_state: RandomLike = None + ) -> NDArray64: + # extra gymnastics needed for a custom random_state + rndm: np.random.RandomState + if random_state is not None: + random_state_saved = self._random_state + rndm = check_random_state(random_state) + else: + rndm = self._random_state + + vals: NDArray64 = self._rvs(*args, size=size, random_state=rndm) + + # do not forget to restore the _random_state + if random_state is not None: + self._random_state: np.random.RandomState = random_state_saved + + return vals.squeeze() + + # /def + + +# /class + + +# ------------------------------------------------------------------- + + +class SCFSamplerBase: + """Sample SCF in spherical coordinates. + + The coordinate system is: + - r : [0, infinity) + - theta : [-pi/2, pi/2] (positive at the North pole) + - phi : [0, 2pi) + + Parameters + ---------- + pot : `galpy.potential.SCFPotential` + """ + + _rsampler: rv_continuous_modrvs + _thetasampler: rv_continuous_modrvs + _phisampler: rv_continuous_modrvs + + @property + def rsampler(self) -> rv_continuous_modrvs: + """Radial coordinate sampler.""" + return self._rsampler + + # /def + + @property + def thetasampler(self) -> rv_continuous_modrvs: + """Inclination coordinate sampler.""" + return self._thetasampler + + # /def + + @property + def phisampler(self) -> rv_continuous_modrvs: + """Azimuthal coordinate sampler.""" + return self._phisampler + + # /def + + def cdf(self, r: npt.ArrayLike, theta: npt.ArrayLike, phi: npt.ArrayLike) -> NDArray64: + """ + Cumulative Distribution Functions in r, theta(r), phi(r, theta) + + Parameters + ---------- + r : (N,) array-like ['kpc'] + theta : (N,) array-like ['angle'] + phi : (N,) array-like ['angle'] + + Returns + ------- + (N, 3) ndarray + """ + R: NDArray64 = self.rsampler.cdf(r) + Theta: NDArray64 = self.thetasampler.cdf(theta=theta, r=r) + Phi: NDArray64 = self.phisampler.cdf(phi, r=r, theta=theta) + + RTP: NDArray64 = np.c_[R, Theta, Phi] + return RTP + + # /def + + def rvs( + self, *, size: T.Optional[int] = None, random_state: RandomLike = None + ) -> PhysicsSphericalRepresentation: + """Sample random variates + + Parameters + ---------- + size : int, optional + Defining number of random variates (default is 1). + random_state : None, int, `numpy.random.Generator`, `numpy.random.RandomState`, optional + If seed is None (or np.random), the `numpy.random.RandomState` + singleton is used. If seed is an int, a new RandomState instance is + used, seeded with seed. If seed is already a Generator or + RandomState instance then that instance is used. + + Returns + ------- + `~astropy.coordinates.PhysicsSphericalRepresentation` + """ + rs = self.rsampler.rvs(size=size, random_state=random_state) + thetas = self.thetasampler.rvs(rs, size=size, random_state=random_state) + phis = self.phisampler.rvs(rs, thetas, size=size, random_state=random_state) + + crd = PhysicsSphericalRepresentation( + r=rs, theta=(np.pi / 2 - thetas) * u.rad, phi=phis * u.rad + ) + return crd + + # /def + + +# /class + +############################################################################## +# END diff --git a/sample_scf/core.py b/sample_scf/core.py new file mode 100644 index 0000000..36b835d --- /dev/null +++ b/sample_scf/core.py @@ -0,0 +1,129 @@ +# -*- coding: utf-8 -*- + +"""**DOCSTRING**. + +Description. + +""" + +############################################################################## +# IMPORTS + +from __future__ import annotations + +# BUILT-IN +import typing as T +from collections.abc import Mapping + +# THIRD PARTY +from galpy.potential import SCFPotential + +# LOCAL +from .base import SCFSamplerBase + +__all__: T.List[str] = ["SCFSampler"] + + +############################################################################## +# CODE +############################################################################## + + +# class SCFSamplerSwitch(ABCMeta): +# def __new__( +# cls: T.Type[SCFSamplerSwitch], +# name: str, +# bases: T.Tuple[type, ...], +# dct: T.Dict[str, T.Any], +# **kwds: T.Any +# ) -> SCFSamplerSwitch: +# +# method: str = dct["method"] +# +# if method == "interp": +# # LOCAL +# from sample_scf.sample_intrp import SCFSampler as interpcls +# +# bases = (interpcls,) +# +# elif method == "exact": +# # LOCAL +# from sample_scf.sample_exact import SCFSampler as exactcls +# +# bases = (exactcls,) +# elif isinstance(method, Mapping): +# pass +# else: +# raise ValueError("`method` must be {'interp', 'exact'} or mapping.") +# +# self = super().__new__(cls, name, bases, dct) +# return self +# +# # /def + + +# /class + + +class SCFSampler(SCFSamplerBase): # metaclass=SCFSamplerSwitch + """Sample SCF in spherical coordinates. + + The coordinate system is: + - r : [0, infinity) + - theta : [-pi/2, pi/2] (positive at the North pole) + - phi : [0, 2pi) + + Parameters + ---------- + pot : `galpy.potential.SCFPotential` + method : {'interp', 'exact'} or mapping[str, type] + If mapping, must have keys (r, theta, phi) + **kwargs + Passed to to the individual component sampler constructors. + """ + + # def __new__( + # cls, + # pot: SCFPotential, + # *args: T.Any, + # method: T.Union[T.Literal["interp", "exact"], T.Mapping[str, T.Callable]] = "interp", + # **kwargs: T.Any + # ) -> SCFSamplerBase: + # + # self: SCFSamplerBase + # if method == "interp": + # # LOCAL + # from sample_scf.sample_intrp import SCFSampler as interpcls + # + # self = interpcls(pot, *args, method=method, **kwargs) + # elif method == "exact": + # # LOCAL + # from sample_scf.sample_exact import SCFSampler as exactcls + # + # self = exactcls(pot, *args, method=method, **kwargs) + # elif isinstance(method, Mapping): + # self = super().__new__(cls) + # else: + # raise ValueError("`method` must be {'interp', 'exact'} or mapping.") + # + # return self + # + # # /def + + def __init__( + self, pot: SCFPotential, method: T.Literal["interp", "exact"], **kwargs: T.Any + ) -> None: + if not isinstance(method, Mapping): + raise NotImplementedError + + self._rsampler = method["r"](pot, **kwargs) + self._thetasampler = method["theta"](pot, **kwargs) + self._phisampler = method["phi"](pot, **kwargs) + + # /def + + +# /class + +############################################################################## +# END diff --git a/sample_scf/sample_exact.py b/sample_scf/sample_exact.py new file mode 100644 index 0000000..9393350 --- /dev/null +++ b/sample_scf/sample_exact.py @@ -0,0 +1,398 @@ +# -*- coding: utf-8 -*- + +"""**DOCSTRING**. + +Description. + +""" + +############################################################################## +# IMPORTS + +from __future__ import annotations + +# BUILT-IN +import typing as T + +# THIRD PARTY +import astropy.units as u +import numpy as np +import numpy.typing as npt +from galpy.potential import SCFPotential +from scipy.stats import rv_continuous + +# LOCAL +from ._typing import NDArray64, RandomLike + +# PROJECT-SPECIFIC +from .base import SCFSamplerBase, rv_continuous_modrvs +from .utils import _x_of_theta, difPls, phiRSms, thetaQls, x_of_theta + +__all__: T.List[str] = ["SCFSampler", "SCFRSampler", "SCFThetaSampler", "SCFPhiSampler"] + + +############################################################################## +# PARAMETERS + +TSCFPhi = T.TypeVar("TSCFPhi", bound="SCFPhiSampler") + + +############################################################################## +# CODE +############################################################################## + + +class SCFSampler(SCFSamplerBase): + """SCF sampler in spherical coordinates. + + The coordinate system is: + - r : [0, infinity) + - theta : [-pi/2, pi/2] (positive at the North pole) + - phi : [0, 2pi) + + Parameters + ---------- + pot : `~galpy.potential.SCFPotential` + **kw + Not used. + + """ + + def __init__(self, pot: SCFPotential, **kw: T.Any) -> None: + self._rsampler = SCFRSampler(pot) + # not fixed r, theta. slower! + self._thetasampler = SCFThetaSampler_of_r(pot, r=None) + self._phisampler = SCFPhiSampler_of_rtheta(pot, r=None, theta=None) + + # /def + + +# /class + + +# ------------------------------------------------------------------- +# radial sampler + + +class SCFRSampler(rv_continuous): + """Sample radial coordinate from an SCF potential. + + Parameters + ---------- + pot : `~galpy.potential.SCFPotential` + A potential that can be used to calculate the enclosed mass. + **kw + Not used. + """ + + def __init__(self, pot: SCFPotential, **kw: T.Any) -> None: + super().__init__(a=0, b=np.inf) # allowed range of r + self._pot = pot + + # /def + + def _cdf(self, r: npt.ArrayLike, *args: T.Any, **kw: T.Any) -> NDArray64: + mass: NDArray64 = self._pot._mass(r) + # (self._scfmass(zeta) - self._mi) / (self._mf - self._mi) + # TODO! is this normalization even necessary? + return mass + + # /def + + +# /class + +# ------------------------------------------------------------------- +# inclination sampler + +TSCFThetaSamplerBase = T.TypeVar("TSCFThetaSamplerBase", bound="SCFThetaSamplerBase") + + +class SCFThetaSamplerBase(rv_continuous_modrvs): + def __new__( + cls: T.Type[TSCFThetaSamplerBase], + pot: SCFPotential, + r: T.Optional[float] = None, + **kw: T.Any + ) -> TSCFThetaSamplerBase: + if cls is SCFThetaSampler and r is None: + cls = SCFThetaSampler_of_r + + self: TSCFThetaSamplerBase = super().__new__(cls) + return self + + # /def + + def __init__(self, pot: SCFPotential, **kw: T.Any) -> None: + super().__init__(a=-np.pi / 2, b=np.pi / 2) # allowed range of theta + + # parse from potential + self._pot = pot + # shape parameters + self._nmax, self._lmax = pot._Acos.shape[:2] + self._lrange = np.arange(0, self._lmax + 1) # lmax inclusive + + # /def + + # @functools.lru_cache() + def Qls(self, r: float) -> NDArray64: + r""" + :math:`Q_l(r) = \sum_{n=0}^{n_{\max}}A_{nl} \tilde{\rho}_{nl0}(r)` + + Parameters + ---------- + r : float ['kpc'] + + Returns + ------- + Ql : ndarray + + """ + Qls: NDArray64 = thetaQls(self._pot, r) + return Qls + + # /def + + def _ppf_to_solve(self, x: float, q: float, *args: T.Any) -> NDArray64: + ppf: NDArray64 = self._cdf(*(x,) + args) - q # FIXME? x or theta + return ppf + + # /def + + +# /class + + +class SCFThetaSampler(SCFThetaSamplerBase): + """ + Sample inclination coordinate from an SCF potential. + + Parameters + ---------- + pot : `~galpy.potential.SCFPotential` + r : float or None, optional + If passed, these are the locations at which the theta CDF will be + evaluated. If None (default), then the r coordinate must be given + to the CDF and RVS functions. + **kw: + Not used. + """ + + def __init__(self, pot: SCFPotential, r: float, **kw: T.Any) -> None: + super().__init__(pot) + # allowed range of theta + # rv_continuous_modrvs.__init__(self, a=-np.pi / 2, b=np.pi / 2) + # + # # parse from potential + # self._pot = pot + # # shape parameters + # self._nmax, self._lmax = pot._Acos.shape[:2] + # self._lrange = np.arange(0, self._lmax + 1) # lmax inclusive + + # points at which CDF is defined + self._r = r + self._Qlsatr = self.Qls(r) + + # /def + + def _cdf(self, theta: npt.ArrayLike, *args: T.Any) -> NDArray64: + """ + Cumulative distribution function of the given RV. + + Parameters + ---------- + theta : array-like ['radian'] + r : array-like [float] (optional, keyword-only) + + Returns + ------- + cdf : ndarray + Cumulative distribution function evaluated at `theta` + + """ + x = x_of_theta(theta) + Qlsatr = self._Qlsatr + + # l = 0 + term0 = (1.0 + x) / 2.0 + # l = 1+ + factor = 1.0 / (2.0 * Qlsatr[0]) + term1p = np.sum((Qlsatr[None, 1:] * difPls(x, self._lmax - 1).T).T, axis=0) + + cdf: NDArray64 = term0 + factor * term1p + return cdf + + # /def + + +# /class + + +class SCFThetaSampler_of_r(SCFThetaSamplerBase): + def _cdf(self, theta: NDArray64, *args: T.Any, r: float) -> NDArray64: + x = _x_of_theta(theta) + Qlsatr = self.Qls(r) + + # l = 0 + term0 = (1.0 + x) / 2.0 + # l = 1+ + factor = 1.0 / (2.0 * Qlsatr[0]) + term1p = np.sum((Qlsatr[None, 1:] * difPls(x, self._lmax - 1).T).T, axis=0) + + cdf: NDArray64 = term0 + factor * term1p + return cdf + + # /def + + def cdf(self, theta: npt.ArrayLike, *args: T.Any, r: float) -> NDArray64: + return self._cdf(u.Quantity(theta, u.rad).value, *args, r=r) + + # /def + + def rvs( # type: ignore + self, r: npt.ArrayLike, *, size: T.Optional[int] = None, random_state: RandomLike = None + ) -> NDArray64: + # not thread safe! + getattr(self._cdf, "__kwdefaults__", {})["r"] = r + vals = super().rvs(size=size, random_state=random_state) + getattr(self._cdf, "__kwdefaults__", {})["r"] = None + return vals + + # /def + + +# /class + + +# ------------------------------------------------------------------- +# azimuth sampler + + +class SCFPhiSamplerBase(rv_continuous_modrvs): + def __new__( + cls: T.Type[TSCFPhi], + pot: SCFPotential, + r: T.Optional[float] = None, + theta: T.Optional[float] = None, + **kw: T.Any + ) -> TSCFPhi: + if cls is SCFPhiSampler and (r is None or theta is None): + cls = SCFPhiSampler_of_rtheta + + self: TSCFPhi = super().__new__(cls) + return self + + # /def + + def __init__(self, pot: SCFPotential, **kw: T.Any) -> None: + super().__init__(a=0, b=2 * np.pi) + + self._pot = pot + # shape parameters + self._nmax, self._lmax = pot._Acos.shape[:2] + self._lrange = np.arange(0, self._lmax + 1) + + # /def + + # @functools.lru_cache() + def RSms(self, r: float, theta: float) -> T.Tuple[NDArray64, NDArray64]: + return phiRSms(self._pot, r, theta) + + # /def + + +# /class + + +class SCFPhiSampler(SCFPhiSamplerBase): + """ + + Parameters + ---------- + pot + r, theta : float, optional + + """ + + def __init__(self, pot: SCFPotential, r: float, theta: float, **kw: T.Any) -> None: + super().__init__(pot) + + self._r, self._theta = r, theta + self._Rm, self._Sm = self.RSms(float(r), float(theta)) + + # /def + + def _cdf(self, phi: NDArray64, *args: T.Any, **kw: T.Any) -> NDArray64: + Rm, Sm = self._Rm, self._Sm + + # l = 0 + term0: NDArray64 = phi / (2 * np.pi) + + # l = 1+ + factor = 1 / Rm[0] # R0 + ms = np.arange(1, Rm.shape[1]) + term1p = np.sum( + (Rm[1:] * np.sin(ms * phi) + Sm[1:] * (1 - np.cos(ms * phi))) / (2 * np.pi * ms) + ) + + cdf: NDArray64 = term0 + factor * term1p + return cdf + + def cdf(self, phi: NDArray64, *args: T.Any, **kw: T.Any) -> NDArray64: + return self._cdf(phi, *args, **kw) + + # /def + + +# /class + + +class SCFPhiSampler_of_rtheta(SCFPhiSamplerBase): + _Rm: T.Optional[NDArray64] + _Sm: T.Optional[NDArray64] + + def _cdf(self, phi: npt.ArrayLike, *args: T.Any, r: float, theta: float) -> NDArray64: + self._Rm, self._Sm = self.RSms(float(r), float(theta)) + cdf: NDArray64 = super()._cdf(phi, *args) + self._Rm, self._Sm = None, None + return cdf + + # /def + + def cdf(self, phi: npt.ArrayLike, *args: T.Any, r: float, theta: float) -> NDArray64: + phi = u.Quantity(phi, u.rad).value + cdf: NDArray64 = self._cdf(phi, *args, r=r, theta=theta) + return cdf + + # /def + + def rvs( # type: ignore + self, + r: float, + theta: float, + *, + size: T.Optional[int] = None, + random_state: RandomLike = None + ) -> NDArray64: + getattr(self._cdf, "__kwdefaults__", {})["r"] = r + getattr(self._cdf, "__kwdefaults__", {})["theta"] = theta + vals = super().rvs(size=size, random_state=random_state) + getattr(self._cdf, "__kwdefaults__", {})["r"] = None + getattr(self._cdf, "__kwdefaults__", {})["theta"] = None + return vals + + # /def + + def _ppf_to_solve(self, x: float, q: float, *args: T.Any) -> NDArray64: + # changed from .cdf() to ._cdf() to use default 'r' + r: float = getattr(self._cdf, "__kwdefaults__", {})["r"] + theta: float = getattr(self._cdf, "__kwdefaults__", {})["theta"] + return self._cdf(*(x,) + args, r=r, theta=theta) - q + + # /def + + +# /class + +############################################################################## +# END diff --git a/sample_scf/sample_intrp.py b/sample_scf/sample_intrp.py new file mode 100644 index 0000000..4de96b6 --- /dev/null +++ b/sample_scf/sample_intrp.py @@ -0,0 +1,562 @@ +# -*- coding: utf-8 -*- + +"""**DOCSTRING**. + +Description. + +""" + +############################################################################## +# IMPORTS + +from __future__ import annotations + +# BUILT-IN +import itertools +import typing as T +import warnings + +# THIRD PARTY +import astropy.units as u +import numpy as np +import numpy.typing as npt +from galpy.potential import SCFPotential +from scipy.interpolate import ( + InterpolatedUnivariateSpline, + RectBivariateSpline, + RegularGridInterpolator, + splev, + splrep, +) +from scipy.stats import rv_continuous + +# LOCAL +from ._typing import NDArray64, RandomLike +from .base import SCFSamplerBase, rv_continuous_modrvs +from .utils import ( + _phiRSms, + _x_of_theta, + difPls, + phiRSms, + r_of_zeta, + thetaQls, + x_of_theta, + zeta_of_r, +) + +__all__: T.List[str] = ["SCFSampler", "SCFRSampler", "SCFThetaSampler", "SCFPhiSampler"] + + +############################################################################## +# CODE +############################################################################## + + +class SCFSampler(SCFSamplerBase): + r"""Interpolated SCF Sampler. + + Parameters + ---------- + pot : `~galpy.potential.SCFPotential` + rtgrid : array-like[float] + The radial component of the interpolation grid. + thetagrid : array-like[float] + The inclination component of the interpolation grid. + :math:`\theta \in [-\pi/2, \pi/2]`, from the South to North pole, so + :math:`\theta = 0` is the equator. + phigrid : array-like[float] + The azimuthal component of the interpolation grid. + :math:`phi \in [0, 2\pi)`. + + **kw: + passed to :class:`~sample_scf.sample_interp.SCFRSampler`, + :class:`~sample_scf.sample_interp.SCFThetaSampler`, + :class:`~sample_scf.sample_interp.SCFPhiSampler` + + Examples + -------- + For all examples we assume the following imports + + >>> import numpy as np + >>> from galpy import potential + + For the SCF Potential we will use the simple example of a Hernquist sphere. + + >>> Acos = np.zeros((20, 24, 24)) + >>> Acos[0, 0, 0] = 1 # Hernquist potential + >>> pot = potential.SCFPotential(Acos=Acos) + + Now we make the sampler, specifying the grid from which the interpolation + will be built. + + >>> rgrid = np.geomspace(1e-1, 1e3, 100) + >>> thetagrid = np.linspace(-np.pi / 2, np.pi / 2, 30) + >>> phigrid = np.linspace(0, 2 * np.pi, 30) + + >>> sampler = SCFSampler(pot, rgrid=rgrid, thetagrid=thetagrid, phigrid=phigrid) + + Now we can evaluate the CDF + + >>> sampler.cdf(10, np.pi/3, np.pi) + array([[0.82644628, 0.9330127 , 0.5 ]]) + + And draw samples + + >>> sampler.rvs(size=5, random_state=3) + + + """ + + def __init__( + self, + pot: SCFPotential, + rgrid: NDArray64, + thetagrid: NDArray64, + phigrid: NDArray64, + **kw: T.Any, + ) -> None: + # compute the r-dependent coefficient matrix $\tilde{\rho}$ + nmax, lmax = pot._Acos.shape[:2] + rhoTilde = np.array([pot._rhoTilde(r, N=nmax, L=lmax) for r in rgrid]) # (R, N, L) + + # ---------- + # theta Qls + # radial sums over $\cos$ portion of the density function + # the $\sin$ part disappears in the integral. + Qls = np.sum(pot._Acos[None, :, :, 0] * rhoTilde, axis=1) # ({R}, L) + + # ---------- + # phi Rm, Sm + # radial and inclination sums + + Rm, Sm = _phiRSms(rhoTilde, Acos=pot._Acos, Asin=pot._Asin, r=rgrid, theta=thetagrid) + + # ---------- + # make samplers + + self._rsampler = SCFRSampler(pot, rgrid, **kw) + self._thetasampler = SCFThetaSampler(pot, rgrid, thetagrid, Qls=Qls, **kw) + self._phisampler = SCFPhiSampler(pot, rgrid, thetagrid, phigrid, RSms=(Rm, Sm), **kw) + + # /def + + +# /class + +# ------------------------------------------------------------------- +# radial sampler + + +class SCFRSampler(rv_continuous): + """Sample radial coordinate from an SCF potential. + + The potential must have a convergent mass function. + + Parameters + ---------- + pot : `~galpy.potential.SCFPotential` or ndarray + The mass enclosed in a spherical volume of radius(ii) 'rgrid', or the + a potential that can be used to calculate the enclosed mass. + rgrid : ndarray + **kw + not used + """ + + def __init__(self, pot: SCFPotential, rgrid: NDArray64, **kw: T.Any) -> None: + super().__init__(a=0, b=np.inf) # allowed range of r + + if isinstance(pot, np.ndarray): + mgrid = pot + elif isinstance(pot, SCFPotential): # todo! generalize over potential + mgrid = np.array([pot._mass(x) for x in rgrid]) # :( + # manual fixes for endpoints + ind = np.where(np.isnan(mgrid))[0] + mgrid[ind[rgrid[ind] == 0]] = 0 + mgrid[ind[rgrid[ind] == np.inf]] = 1 + else: + raise TypeError + + # work in zeta, not r, since it is more numerically stable + zeta = zeta_of_r(rgrid) + # make splines for fast calculation + self._spl_cdf = InterpolatedUnivariateSpline(zeta, mgrid, ext="raise", bbox=[-1, 1]) + self._spl_ppf = InterpolatedUnivariateSpline(mgrid, zeta, ext="raise", bbox=[0, 1]) + + # TODO! make sure + # # store endpoint values to ensure CDF normalized to [0, 1] + # self._mi = self._spl_cdf(min(zeta)) + # self._mf = self._spl_cdf(max(zeta)) + + # /def + + def _cdf(self, r: npt.ArrayLike, *args: T.Any, **kw: T.Any) -> NDArray64: + cdf: NDArray64 = self._spl_cdf(zeta_of_r(r)) + # (self._scfmass(zeta) - self._mi) / (self._mf - self._mi) + # TODO! is this normalization even necessary? + return cdf + + # /def + + def _ppf(self, q: npt.ArrayLike, *args: T.Any, **kw: T.Any) -> NDArray64: + return r_of_zeta(self._spl_ppf(q)) + + # /def + + +# /class + +# ------------------------------------------------------------------- +# inclination sampler + + +class SCFThetaSampler(rv_continuous_modrvs): + """ + Sample inclination coordinate from an SCF potential. + + Parameters + ---------- + pot : `~galpy.potential.SCFPotential` + rgrid, tgrid : ndarray + + """ + + def __init__( + self, + pot: SCFPotential, + rgrid: NDArray64, + tgrid: NDArray64, + intrp_step: float = 0.01, + **kw: T.Any, + ) -> None: + super().__init__(a=-np.pi / 2, b=np.pi / 2) # allowed range of theta + + self._theta_interpolant = np.arange(-np.pi / 2, np.pi / 2, intrp_step) + self._x_interpolant = _x_of_theta(self._theta_interpolant) + self._q_interpolant = np.linspace(0, 1, len(self._theta_interpolant)) + + # ------- + # parse from potential + + self._pot = pot + self._nmax, self._lmax = (nmax, lmax) = pot._Acos.shape[:2] + self._lrange = np.arange(0, self._lmax + 1) + + # ------- + # build CDF in shells + # TODO: clean up shape stuff + + zetas = zeta_of_r(rgrid) # (R,) + xs = _x_of_theta(tgrid) # (T,) + + if "Qls" in kw: + Qls = kw["Qls"] + else: + Qls = thetaQls(pot, rgrid) + # check it's the right shape (R, Lmax) + if Qls.shape != (len(rgrid), lmax): + raise ValueError(f"Qls must be shape ({len(rgrid)}, {lmax})") + + # l = 0 : spherical symmetry + term0 = (1.0 + xs) / 2.0 # (T,) + # l = 1+ : non-symmetry + factor = 1.0 / (2.0 * Qls[:, 0]) # (R,) + term1p = np.sum( + (Qls[None, :, 1:] * difPls(xs, lmax - 1).T[:, None, :]).T, + axis=0, + ) + + cdfs = term0[None, :] + np.nan_to_num(factor[:, None] * term1p) # (R, T) + + # ------- + # interpolate + # currently assumes a regular grid + + self._spl_cdf = RectBivariateSpline( + zetas, + xs, + cdfs, + bbox=[-1, 1, -1, 1], # [zetamin, zetamax, xmin, xmax] + kx=kw.get("kx", 3), + ky=kw.get("ky", 3), + s=kw.get("s", 0), + ) + + # ppf, one per r + # TODO! see if can use this to avoid resplining + _cdfs = self._spl_cdf(zetas, self._x_interpolant) + spls = [ # work through the rs + splrep(_cdfs[i, :], self._theta_interpolant, s=0) for i in range(_cdfs.shape[0]) + ] + ppfs = np.array([splev(self._q_interpolant, spl, ext=0) for spl in spls]) + self._spl_ppf = RectBivariateSpline( + zetas, + self._q_interpolant, + ppfs, + bbox=[-1, 1, 0, 1], # [zetamin, zetamax, xmin, xmax] + kx=kw.get("kx", 3), + ky=kw.get("ky", 3), + s=kw.get("s", 0), + ) + + # /def + + def _cdf( + self, + x: npt.ArrayLike, + *args: T.Any, + zeta: npt.ArrayLike, + grid: bool = False, + ) -> NDArray64: + cdf: NDArray64 = self._spl_cdf(zeta, x, grid=grid) + return cdf + + # /def + + def cdf(self, theta: npt.ArrayLike, r: npt.ArrayLike) -> NDArray64: + # TODO! make sure r, theta in right domain + cdf = self._cdf(x_of_theta(u.Quantity(theta, u.rad)), zeta=zeta_of_r(r)) + return cdf + + # /def + + def _ppf( + self, + q: npt.ArrayLike, + *, + r: npt.ArrayLike, + grid: bool = False, + **kw: T.Any, + ) -> NDArray64: + """Percent-point function. + + Parameters + ---------- + q : float or (N,) array-like[float] + r : float or (N,) array-like[float] + + Returns + ------- + float or (N,) array-like[float] + Same shape as 'r', 'q'. + """ + ppf: NDArray64 = self._spl_ppf(zeta_of_r(r), q, grid=grid) + return ppf + + # /def + + def _rvs( + self, + r: npt.ArrayLike, + *, + random_state: T.Union[np.random.RandomState, np.random.Generator], + size: T.Optional[int] = None, + ) -> NDArray64: + """Random variate sampling. + + Parameters + ---------- + r : float or (N,) array-like[float] + size : int (optional, keyword-only) + random_state : int or None (optional, keyword-only) + + Returns + ------- + float or array-like[float] + Shape 'size'. + """ + # Use inverse cdf algorithm for RV generation. + U = random_state.uniform(size=size) + Y = self._ppf(U, r=r, grid=False) + return Y + + # /def + + def rvs( # type: ignore + self, + r: npt.ArrayLike, + *, + size: T.Optional[int] = None, + random_state: RandomLike = None, + ) -> NDArray64: + """Random variate sampling. + + Parameters + ---------- + r : float or (N,) array-like[float] + size : int or None (optional, keyword-only) + random_state : int or None (optional, keyword-only) + + Returns + ------- + float or array-like[float] + Shape 'size'. + """ + return super().rvs(r, size=size, random_state=random_state) + + # /def + + +# ------------------------------------------------------------------- +# Azimuth sampler + + +class SCFPhiSampler(rv_continuous_modrvs): + """SCF phi sampler + + .. todo:: + + Make sure that stuff actually goes from 0 to 1. + + """ + + def __init__( + self, + pot: SCFPotential, + rgrid: NDArray64, + tgrid: NDArray64, + pgrid: NDArray64, + intrp_step: float = 0.01, + **kw: T.Any, + ) -> None: + super().__init__(a=0, b=2 * np.pi) # allowed range of r + + self._phi_interpolant = np.arange(0, 2 * np.pi, intrp_step) + self._ninterpolant = len(self._phi_interpolant) + self._q_interpolant = qarr = np.linspace(0, 1, self._ninterpolant) + + # ------- + # parse from potential + + self._pot = pot + self._nmax, self._lmax = (nmax, lmax) = pot._Acos.shape[:2] + + # ------- + # build CDF + + zetas = zeta_of_r(rgrid) # (R,) + xs = _x_of_theta(tgrid) # (T,) + + lR, lT, _ = len(rgrid), len(tgrid), len(pgrid) + + Phis = pgrid[None, None, :, None] # ({R}, {T}, P, {L}) + + if "RSms" in kw: + (Rm, Sm) = kw["RSms"] + else: + (Rm, Sm) = phiRSms(pot, rgrid, tgrid) # (R, T, L) + # check it's the right shape + if (Rm.shape != Sm.shape) or (Rm.shape != (lR, lT, lmax)): + raise ValueError(f"Rm, Sm must be shape ({lR}, {lT}, {lmax})") + + # l = 0 : spherical symmetry + term0 = pgrid[None, None, :] / (2 * np.pi) # (1, 1, P) + # l = 1+ : non-symmetry + with warnings.catch_warnings(): # ignore true_divide RuntimeWarnings + warnings.simplefilter("ignore") + factor = 1 / Rm[:, :, :1] # R0 (R, T, 1) # can be inf + + ms = np.arange(1, lmax)[None, None, None, :] # (1, 1, 1, L) + term1p = np.sum( + (Rm[:, :, None, 1:] * np.sin(ms * Phis) + Sm[:, :, None, 1:] * (1 - np.cos(ms * Phis))) + / (2 * np.pi * ms), + axis=-1, + ) + + cdfs = term0 + np.nan_to_num(factor * term1p) # (R, T, P) + # 'factor' can be inf and term1p 0 => inf * 0 = nan -> 0 + + # interpolate + # currently assumes a regular grid + self._spl_cdf = RegularGridInterpolator((zetas, xs, pgrid), cdfs) + + # ------- + # ppf + # start by supersampling + Zetas, Xs, Phis = np.meshgrid(zetas, xs, self._phi_interpolant, indexing="ij") + _cdfs = self._spl_cdf((Zetas.ravel(), Xs.ravel(), Phis.ravel())).reshape( + lR, lT, len(self._phi_interpolant) + ) + # build reverse spline + ppfs = np.empty((lR, lT, self._ninterpolant), dtype=np.float64) + for (i, j) in itertools.product(*map(range, ppfs.shape[:2])): + spl = splrep(_cdfs[i, j, :], self._phi_interpolant, s=0) + ppfs[i, j, :] = splev(qarr, spl, ext=0) + # interpolate + self._spl_ppf = RegularGridInterpolator( + (zetas, xs, self._q_interpolant), ppfs, bounds_error=False + ) + + # /def + + def _cdf( + self, + phi: npt.ArrayLike, + *args: T.Any, + zeta: npt.ArrayLike, + x: npt.ArrayLike, + ) -> NDArray64: + cdf: NDArray64 = self._spl_cdf((zeta, x, phi)) + return cdf + + # /def + + def cdf(self, phi: npt.ArrayLike, r: npt.ArrayLike, theta: npt.ArrayLike) -> NDArray64: + # TODO! make sure r, theta in right domain + cdf = self._cdf( + phi, + zeta=zeta_of_r(r), + x=x_of_theta(u.Quantity(theta, u.rad)), + ) + return cdf + + # /def + + def _ppf( + self, + q: npt.ArrayLike, + *args: T.Any, + r: npt.ArrayLike, + theta: NDArray64, + grid: bool = False, + **kw: T.Any, + ) -> NDArray64: + ppf: NDArray64 = self._spl_ppf((zeta_of_r(r), _x_of_theta(theta), q)) + return ppf + + # /def + + def _rvs( + self, + r: npt.ArrayLike, + theta: NDArray64, + *args: T.Any, + random_state: np.random.RandomState, + size: T.Optional[int] = None, + ) -> NDArray64: + # Use inverse cdf algorithm for RV generation. + U = random_state.uniform(size=size) + Y = self._ppf(U, *args, r=r, theta=theta) + return Y + + # /def + + def rvs( # type: ignore + self, + r: T.Union[float, npt.ArrayLike], + theta: T.Union[float, npt.ArrayLike], + *, + size: T.Optional[int] = None, + random_state: RandomLike = None, + ) -> NDArray64: + return super().rvs(r, theta, size=size, random_state=random_state) + + # /def + + +############################################################################## +# END diff --git a/sample_scf/utils.py b/sample_scf/utils.py new file mode 100644 index 0000000..8f64c4a --- /dev/null +++ b/sample_scf/utils.py @@ -0,0 +1,306 @@ +# -*- coding: utf-8 -*- + +"""**DOCSTRING**. + +Description. + +""" + +############################################################################## +# IMPORTS + +from __future__ import annotations + +# BUILT-IN +import typing as T +import warnings + +# THIRD PARTY +import astropy.units as u +import numpy as np +import numpy.typing as npt +from galpy.potential import SCFPotential +from numpy import ( + arange, + array, + asanyarray, + atleast_1d, + cos, + divide, + nan_to_num, + pi, + sqrt, + stack, + sum, +) +from scipy.special import legendre, lpmn + +# LOCAL +from ._typing import NDArray64, RandomLike + +__all__ = [ + "zeta_of_r", + "r_of_zeta", + "x_of_theta", + "difPls", + "thetaQls", + "phiRSms", +] + +############################################################################## +# PARAMETERS + +lpmn_vec = np.vectorize(lpmn, otypes=(object, object)) + +# # pre-compute the difPls +with warnings.catch_warnings(): + warnings.simplefilter("ignore") + + # TODO! organize & allow for lmax > 200. + lrange = arange(0, 200 + 1) + Pls = array([legendre(L) for L in lrange], dtype=object) + # l=1+. l=0 is done separately + _difPls = (Pls[2:] - Pls[:-2]) / (2 * lrange[1:-1] + 1) + + +def difPls(x: T.Union[float, NDArray64], lmax: int) -> NDArray64: + # TODO? speed up + return array([dPl(x) for dPl in _difPls[:lmax]]) + + +############################################################################## +# CODE +############################################################################## + + +def zeta_of_r(r: T.Union[u.Quantity, NDArray64]) -> NDArray64: + r""":math:`\zeta = \frac{r - 1}{r + 1}` + + Map the half-infinite domain [0, infinity) -> [-1, 1] + + Parameters + ---------- + r : quantity-like ['length'] + + Returns + ------- + zeta : ndarray + With shape (len(r),) + """ + ra: NDArray64 = r.value if isinstance(r, u.Quantity) else r + zeta: NDArray64 = nan_to_num(divide(ra - 1, ra + 1), nan=1) + return zeta + + +# /def + + +def r_of_zeta( + zeta: npt.ArrayLike, unit: T.Optional[u.UnitBase] = None +) -> T.Union[u.Quantity, NDArray64]: + r""":math:`r = \frac{1 + \zeta}{1 - \zeta}` + + Map back to the half-infinite domain [0, infinity) <- [-1, 1] + + Parameters + ---------- + zeta : array-like + unit : `astropy.units.UnitBase` or None, optional + + Returns + ------- + r: ndarray + """ + z = array(zeta, subok=True) + r = atleast_1d(divide(1 + z, 1 - z)) + r[r < 0] = 0 + + rq: T.Union[u.Quantity, NDArray64] = r * (unit or 1) + return rq + + +# /def + + +# ------------------------------------------------------------------- + + +def _x_of_theta(theta: NDArray64) -> NDArray64: + r""":math:`x = \cos{\theta}`. + + Parameters + ---------- + theta : array-like ['radian'] + + Returns + ------- + x : float or array-like + """ + x: NDArray64 = cos(pi / 2 - theta) + return x + + +# /def + + +# @u.quantity_input(theta=u.radian) +def x_of_theta(theta: u.Quantity) -> NDArray64: + r""":math:`x = \cos{\theta}`. + + Parameters + ---------- + theta : quantity-like ['radian'] + + Returns + ------- + x : float or ndarray + """ + x = _x_of_theta(theta.to_value(u.rad)) + return x + + +# /def + +# ------------------------------------------------------------------- + + +def thetaQls(pot: SCFPotential, r: T.Union[float, NDArray64]) -> NDArray64: + r""" + Radial sums for inclination weighting factors. + The weighting factors measure perturbations from spherical symmetry. + + :math:`Q_l(r) = \sum_{n=0}^{n_{\max}}A_{nl} \tilde{\rho}_{nl0}(r)` + + Parameters + ---------- + pot + r : float ['kpc'] + + Returns + ------- + Ql : ndarray + + """ + # compute the r-dependent coefficient matrix $\tilde{\rho}$ # (R, N, L) + nmax, lmax = pot._Acos.shape[:2] + rs = atleast_1d(r) # need r to be array. + rhoTilde = array([pot._rhoTilde(r, N=nmax, L=lmax) for r in rs]) + + # inclination weighting factors + Qls = sum(pot._Acos[None, :, :, 0] * rhoTilde, axis=1) # (R, N, L) + + # remove extra dimensions, e.g. scalar 'r' + Ql: NDArray64 = Qls.squeeze() + return Ql + + +# /def + +# ------------------------------------------------------------------- + + +def _phiRSms( + rhoTilde: NDArray64, + Acos: NDArray64, + Asin: NDArray64, + r: npt.ArrayLike, + theta: npt.ArrayLike, +) -> T.Tuple[NDArray64, NDArray64]: + """Radial and inclination sums for azimuthal weighting factors. + + Parameters + ---------- + rhoTilde: (R, N, L) ndarray + Acos, Asin : (N, L, L) ndarray + r, theta : float or ndarray[float] + With shapes (R,), (T,), respectively. + + Returns + ------- + Rm, Sm : (R, T, L) ndarray + Azimuthal weighting factors. + """ + # need r and theta to be arrays. Maintains units. + tgrid: NDArray64 = atleast_1d(theta) + + # transform to correct shape for vectorized computation + x = _x_of_theta(asanyarray(tgrid)) # (T,) + Xs = x[None, :, None, None, None] # ({R}, X, {N}, {L}, {L}) + + # compute the r-dependent coefficient matrix $\tilde{\rho}$ + nmax, lmax = Acos.shape[:2] + RhoT = rhoTilde[:, None, :, :, None] # (R, {X}, N, L, {L}) + + # legendre polynomials + with warnings.catch_warnings(): # there's a RuntimeWarning to ignore + warnings.simplefilter("ignore") + lps = lpmn_vec(lmax - 1, lmax - 1, x)[0] # drop deriv + + PP = np.stack(lps, axis=0).astype(float)[None, :, None, :, :] + # ({R}, X, {N}, L, L) + + # full R & S matrices + RSnlm = RhoT * sqrt(1 - Xs ** 2) * PP # (R, X, N, L, L) + + # n-sum # (R, X, L, L) + Rlm = sum(Acos[None, None, :, :, :] * RSnlm, axis=2) + Slm = sum(Asin[None, None, :, :, :] * RSnlm, axis=2) + + # m-sum # (R, X, L) + sumidx = range(Rlm.shape[2]) + Rm = stack([sum(Rlm[:, :, m:, m], axis=2) for m in sumidx], axis=2) + Sm = stack([sum(Slm[:, :, m:, m], axis=2) for m in sumidx], axis=2) + + return Rm, Sm + + +# /def + + +def phiRSms( + pot: SCFPotential, r: npt.ArrayLike, theta: npt.ArrayLike +) -> T.Tuple[NDArray64, NDArray64]: + r"""Radial and inclination sums for azimuthal weighting factors. + + .. math:: + [R/S]_{l}^{m}(r,x)= \left(\sum_{n=0}^{n_{\max}} [A/B]_{nlm} + \tilde{\rho}_{nlm}(r) \right) r \sqrt{1-x^2} + P_{l}^{m}(x) + + [R/S]^{m}(r, x) = \sum_{l=m}^{l_{\max}} [R/S]_{l}^{m}(r,x) + + Parameters + ---------- + pot : :class:`galpy.potential.SCFPotential` + Has coefficient matrices Acos and Asin with shape (N, L, L). + r : float or ndarray[float] + theta : float or ndarray[float] + + Returns + ------- + Rm, Sm : ndarray[float] + Azimuthal weighting factors. Will have shape (len(r), len(theta), L), + with :meth:`numpy.ndarray.squeeze` applied to eliminate extraneous + dimensions, e.g. scalar 'r' + """ + # need r and theta to be arrays. The extra dimensions will be 'squeeze'd. + rgrid = atleast_1d(r) + tgrid = atleast_1d(theta) + + # compute the r-dependent coefficient matrix $\tilde{\rho}$ # (R, N, L) + nmax: int + lmax: int + nmax, lmax = pot._Acos.shape[:2] + rhoTilde = array([pot._rhoTilde(r, N=nmax, L=lmax) for r in rgrid]) + + # pass to actual calculator, which takes the matrices and r, theta grids. + Rm, Sm = _phiRSms(rhoTilde, pot._Acos, pot._Asin, rgrid, tgrid) + + # remove extra dimensions from azimuthal factors + return Rm.squeeze(), Sm.squeeze() + + +# /def + +############################################################################## +# END diff --git a/setup.cfg b/setup.cfg index f58a21e..4ffd801 100644 --- a/setup.cfg +++ b/setup.cfg @@ -19,6 +19,7 @@ setup_requires = setuptools_scm install_requires = astropy extension_helpers + galpy matplotlib mypy numpy >= 1.20 From 923015e9db4d6b1d0e62e1c3cae9de4dc0921c4d Mon Sep 17 00:00:00 2001 From: "Nathaniel Starkman (@nstarman)" Date: Sun, 25 Jul 2021 18:17:40 -0400 Subject: [PATCH 02/31] prune unused imports Signed-off-by: Nathaniel Starkman (@nstarman) --- sample_scf/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sample_scf/utils.py b/sample_scf/utils.py index 8f64c4a..c86c0df 100644 --- a/sample_scf/utils.py +++ b/sample_scf/utils.py @@ -36,7 +36,7 @@ from scipy.special import legendre, lpmn # LOCAL -from ._typing import NDArray64, RandomLike +from ._typing import NDArray64 __all__ = [ "zeta_of_r", From 849a952617b3855c81accb4a972499eab26d0b04 Mon Sep 17 00:00:00 2001 From: "Nathaniel Starkman (@nstarman)" Date: Sun, 25 Jul 2021 20:25:09 -0400 Subject: [PATCH 03/31] run commit black and isort Signed-off-by: Nathaniel Starkman (@nstarman) --- docs/conf.py | 4 +++- pyproject.toml | 3 +-- sample_scf/_typing.py | 1 + sample_scf/base.py | 11 ++++++++-- sample_scf/sample_exact.py | 4 +--- sample_scf/sample_intrp.py | 41 +++++++++++++++++++++++++++++++------- sample_scf/utils.py | 7 +++++-- 7 files changed, 54 insertions(+), 17 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 33ee295..301cdd9 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -146,7 +146,9 @@ # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, author, documentclass [howto/manual]). -latex_documents = [("index", project + ".tex", project + u" Documentation", author, "manual")] +latex_documents = [ + ("index", project + ".tex", project + u" Documentation", author, "manual"), +] # -- Options for manual page output ------------------------------------------- diff --git a/pyproject.toml b/pyproject.toml index a917d4a..430661a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,8 +15,7 @@ force_grid_wrap = 0 use_parentheses = "True" ensure_newline_before_comments = "True" sections = ["FUTURE", "STDLIB", "THIRDPARTY", "FIRSTPARTY", "LOCALFOLDER"] - -known_third_party = ["astropy", "extension_helpers", "setuptools"] +known_third_party = ["astropy", "extension_helpers", "galpy", "numpy", "scipy", "setuptools"] known_localfolder = "sample_scf" import_heading_stdlib = "BUILT-IN" diff --git a/sample_scf/_typing.py b/sample_scf/_typing.py index 1af6039..bea1a38 100644 --- a/sample_scf/_typing.py +++ b/sample_scf/_typing.py @@ -1,3 +1,4 @@ +# -*- coding: utf-8 -*- # Licensed under a 3-clause BSD style license - see LICENSE.rst # BUILT-IN import typing as T diff --git a/sample_scf/base.py b/sample_scf/base.py index c1f051f..fdc0b02 100644 --- a/sample_scf/base.py +++ b/sample_scf/base.py @@ -109,7 +109,12 @@ def phisampler(self) -> rv_continuous_modrvs: # /def - def cdf(self, r: npt.ArrayLike, theta: npt.ArrayLike, phi: npt.ArrayLike) -> NDArray64: + def cdf( + self, + r: npt.ArrayLike, + theta: npt.ArrayLike, + phi: npt.ArrayLike, + ) -> NDArray64: """ Cumulative Distribution Functions in r, theta(r), phi(r, theta) @@ -156,7 +161,9 @@ def rvs( phis = self.phisampler.rvs(rs, thetas, size=size, random_state=random_state) crd = PhysicsSphericalRepresentation( - r=rs, theta=(np.pi / 2 - thetas) * u.rad, phi=phis * u.rad + r=rs, + theta=(np.pi / 2 - thetas) * u.rad, + phi=phis * u.rad, ) return crd diff --git a/sample_scf/sample_exact.py b/sample_scf/sample_exact.py index 9393350..287a92a 100644 --- a/sample_scf/sample_exact.py +++ b/sample_scf/sample_exact.py @@ -23,8 +23,6 @@ # LOCAL from ._typing import NDArray64, RandomLike - -# PROJECT-SPECIFIC from .base import SCFSamplerBase, rv_continuous_modrvs from .utils import _x_of_theta, difPls, phiRSms, thetaQls, x_of_theta @@ -332,7 +330,7 @@ def _cdf(self, phi: NDArray64, *args: T.Any, **kw: T.Any) -> NDArray64: factor = 1 / Rm[0] # R0 ms = np.arange(1, Rm.shape[1]) term1p = np.sum( - (Rm[1:] * np.sin(ms * phi) + Sm[1:] * (1 - np.cos(ms * phi))) / (2 * np.pi * ms) + (Rm[1:] * np.sin(ms * phi) + Sm[1:] * (1 - np.cos(ms * phi))) / (2 * np.pi * ms), ) cdf: NDArray64 = term0 + factor * term1p diff --git a/sample_scf/sample_intrp.py b/sample_scf/sample_intrp.py index 4de96b6..4b4cc34 100644 --- a/sample_scf/sample_intrp.py +++ b/sample_scf/sample_intrp.py @@ -122,7 +122,9 @@ def __init__( ) -> None: # compute the r-dependent coefficient matrix $\tilde{\rho}$ nmax, lmax = pot._Acos.shape[:2] - rhoTilde = np.array([pot._rhoTilde(r, N=nmax, L=lmax) for r in rgrid]) # (R, N, L) + rhoTilde = np.array( + [pot._rhoTilde(r, N=nmax, L=lmax) for r in rgrid], + ) # (R, N, L) # ---------- # theta Qls @@ -134,7 +136,13 @@ def __init__( # phi Rm, Sm # radial and inclination sums - Rm, Sm = _phiRSms(rhoTilde, Acos=pot._Acos, Asin=pot._Asin, r=rgrid, theta=thetagrid) + Rm, Sm = _phiRSms( + rhoTilde, + Acos=pot._Acos, + Asin=pot._Asin, + r=rgrid, + theta=thetagrid, + ) # ---------- # make samplers @@ -184,8 +192,18 @@ def __init__(self, pot: SCFPotential, rgrid: NDArray64, **kw: T.Any) -> None: # work in zeta, not r, since it is more numerically stable zeta = zeta_of_r(rgrid) # make splines for fast calculation - self._spl_cdf = InterpolatedUnivariateSpline(zeta, mgrid, ext="raise", bbox=[-1, 1]) - self._spl_ppf = InterpolatedUnivariateSpline(mgrid, zeta, ext="raise", bbox=[0, 1]) + self._spl_cdf = InterpolatedUnivariateSpline( + zeta, + mgrid, + ext="raise", + bbox=[-1, 1], + ) + self._spl_ppf = InterpolatedUnivariateSpline( + mgrid, + zeta, + ext="raise", + bbox=[0, 1], + ) # TODO! make sure # # store endpoint values to ensure CDF normalized to [0, 1] @@ -479,7 +497,9 @@ def __init__( # start by supersampling Zetas, Xs, Phis = np.meshgrid(zetas, xs, self._phi_interpolant, indexing="ij") _cdfs = self._spl_cdf((Zetas.ravel(), Xs.ravel(), Phis.ravel())).reshape( - lR, lT, len(self._phi_interpolant) + lR, + lT, + len(self._phi_interpolant), ) # build reverse spline ppfs = np.empty((lR, lT, self._ninterpolant), dtype=np.float64) @@ -488,7 +508,9 @@ def __init__( ppfs[i, j, :] = splev(qarr, spl, ext=0) # interpolate self._spl_ppf = RegularGridInterpolator( - (zetas, xs, self._q_interpolant), ppfs, bounds_error=False + (zetas, xs, self._q_interpolant), + ppfs, + bounds_error=False, ) # /def @@ -505,7 +527,12 @@ def _cdf( # /def - def cdf(self, phi: npt.ArrayLike, r: npt.ArrayLike, theta: npt.ArrayLike) -> NDArray64: + def cdf( + self, + phi: npt.ArrayLike, + r: npt.ArrayLike, + theta: npt.ArrayLike, + ) -> NDArray64: # TODO! make sure r, theta in right domain cdf = self._cdf( phi, diff --git a/sample_scf/utils.py b/sample_scf/utils.py index c86c0df..5ab3cc7 100644 --- a/sample_scf/utils.py +++ b/sample_scf/utils.py @@ -96,7 +96,8 @@ def zeta_of_r(r: T.Union[u.Quantity, NDArray64]) -> NDArray64: def r_of_zeta( - zeta: npt.ArrayLike, unit: T.Optional[u.UnitBase] = None + zeta: npt.ArrayLike, + unit: T.Optional[u.UnitBase] = None, ) -> T.Union[u.Quantity, NDArray64]: r""":math:`r = \frac{1 + \zeta}{1 - \zeta}` @@ -258,7 +259,9 @@ def _phiRSms( def phiRSms( - pot: SCFPotential, r: npt.ArrayLike, theta: npt.ArrayLike + pot: SCFPotential, + r: npt.ArrayLike, + theta: npt.ArrayLike, ) -> T.Tuple[NDArray64, NDArray64]: r"""Radial and inclination sums for azimuthal weighting factors. From a4cf7d3e7d9496ca96470b331bccd222bd3af2e8 Mon Sep 17 00:00:00 2001 From: "Nathaniel Starkman (@nstarman)" Date: Sun, 25 Jul 2021 23:37:12 -0400 Subject: [PATCH 04/31] start tests Signed-off-by: Nathaniel Starkman (@nstarman) --- sample_scf/__init__.py | 7 - sample_scf/core.py | 5 +- sample_scf/sample_intrp.py | 4 +- sample_scf/tests/test_utils.py | 229 +++++++++++++++++++++++++++++++++ sample_scf/utils.py | 24 ++-- 5 files changed, 251 insertions(+), 18 deletions(-) create mode 100644 sample_scf/tests/test_utils.py diff --git a/sample_scf/__init__.py b/sample_scf/__init__.py index c1de58c..2f3702f 100644 --- a/sample_scf/__init__.py +++ b/sample_scf/__init__.py @@ -7,11 +7,4 @@ from sample_scf.sample_exact import SCFSampler as SCFSamplerExact from sample_scf.sample_intrp import SCFSampler as SCFSamplerInterp -# from .sample_exact import SCFPhiSampler as SCFPhiSamplerExact -# from .sample_exact import SCFRSampler as SCFRSamplerExact -# from .sample_exact import SCFThetaSampler as SCFThetaSamplerExact -# from .sample_intrp import SCFPhiSampler as SCFPhiSamplerInterp -# from .sample_intrp import SCFRSampler as SCFRSamplerInterp -# from .sample_intrp import SCFThetaSampler as SCFThetaSamplerInterp - __all__ = ["SCFSampler", "SCFSamplerExact", "SCFSamplerInterp"] diff --git a/sample_scf/core.py b/sample_scf/core.py index 36b835d..1ae04f0 100644 --- a/sample_scf/core.py +++ b/sample_scf/core.py @@ -111,7 +111,10 @@ class SCFSampler(SCFSamplerBase): # metaclass=SCFSamplerSwitch # # /def def __init__( - self, pot: SCFPotential, method: T.Literal["interp", "exact"], **kwargs: T.Any + self, + pot: SCFPotential, + method: T.Union[T.Literal["interp", "exact"], T.Mapping], + **kwargs: T.Any ) -> None: if not isinstance(method, Mapping): raise NotImplementedError diff --git a/sample_scf/sample_intrp.py b/sample_scf/sample_intrp.py index 4b4cc34..8cc1111 100644 --- a/sample_scf/sample_intrp.py +++ b/sample_scf/sample_intrp.py @@ -272,7 +272,7 @@ def __init__( xs = _x_of_theta(tgrid) # (T,) if "Qls" in kw: - Qls = kw["Qls"] + Qls: NDArray64 = kw["Qls"] else: Qls = thetaQls(pot, rgrid) # check it's the right shape (R, Lmax) @@ -280,7 +280,7 @@ def __init__( raise ValueError(f"Qls must be shape ({len(rgrid)}, {lmax})") # l = 0 : spherical symmetry - term0 = (1.0 + xs) / 2.0 # (T,) + term0 = T.cast(npt.NDArray, 0.5 * (xs + 1)) # (T,) # l = 1+ : non-symmetry factor = 1.0 / (2.0 * Qls[:, 0]) # (R,) term1p = np.sum( diff --git a/sample_scf/tests/test_utils.py b/sample_scf/tests/test_utils.py new file mode 100644 index 0000000..f6f5265 --- /dev/null +++ b/sample_scf/tests/test_utils.py @@ -0,0 +1,229 @@ +# -*- coding: utf-8 -*- + +"""Testing :mod:`scample_scf.utils`.""" + + +############################################################################## +# IMPORTS + +# BUILT-IN +import contextlib + +# THIRD PARTY +import astropy.units as u +import numpy as np +import pytest +from galpy.potential import SCFPotential + +# LOCAL +from sample_scf.utils import _x_of_theta, r_of_zeta, thetaQls, x_of_theta, zeta_of_r + +############################################################################## +# TESTS +############################################################################## + + +class Test_zeta_of_r: + """Testing :func:`sample_scf.utils.zeta_of_r`.""" + + # =============================================================== + # Usage Tests + + @pytest.mark.parametrize( + "r, expected, warns", + [ + (0, -1.0, False), # int -> float + (1, 0.0, False), + (0.0, -1.0, False), # float -> float + (1.0, 0.0, False), + (np.inf, 1.0, RuntimeWarning), # edge case + (u.Quantity(10, u.km), 9 / 11, False), + (u.Quantity(8, u.s), 7 / 9, False), # Note the unit doesn't matter + ], + ) + def test_scalar_input(self, r, expected, warns): + """Test when input scalar.""" + with pytest.warns(warns) if warns is not False else contextlib.nullcontext(): + assert np.allclose(zeta_of_r(r), expected) + + # /def + + @pytest.mark.parametrize( + "r, expected", + [ + ([0, 1, np.inf], [-1.0, 0.0, 1.0]), + (u.Quantity([0, 1, np.inf], u.km), [-1.0, 0.0, 1.0]), + ], + ) + def test_array_input(self, r, expected): + """Test when input array.""" + with pytest.warns(RuntimeWarning): + assert np.allclose(zeta_of_r(r), expected) + + # /def + + @pytest.mark.parametrize("r", [0, 1, np.inf, [0, 1, np.inf]]) + def test_roundtrip(self, r): + """Test zeta and r round trip. Note that Quantities don't round trip.""" + assert np.allclose(r_of_zeta(zeta_of_r(r)), r) + + # /def + + +# /class + + +# ------------------------------------------------------------------- + + +class Test_r_of_zeta: + """Testing :func:`sample_scf.utils.r_of_zeta`.""" + + # =============================================================== + # Usage Tests + + @pytest.mark.parametrize( + "zeta, expected, warns", + [ + (-1.0, 0, False), # int -> float + (0.0, 1, False), + (-1.0, 0.0, False), # float -> float + (0.0, 1.0, False), + (1.0, np.inf, RuntimeWarning), # edge case + (2.0, 0, False), # out of bounds + (-2.0, 0, False), # out of bounds + ], + ) + def test_scalar_input(self, zeta, expected, warns): + """Test when input scalar.""" + with pytest.warns(warns) if warns is not False else contextlib.nullcontext(): + assert np.allclose(r_of_zeta(zeta), expected) + + # /def + + @pytest.mark.parametrize( + "zeta, expected, warns", + [ + ([-1.0, 0.0, 1.0], [0, 1, np.inf], RuntimeWarning), + ], + ) + def test_array_input(self, zeta, expected, warns): + """Test when input array.""" + with pytest.warns(warns) if warns is not False else contextlib.nullcontext(): + assert np.allclose(r_of_zeta(zeta), expected) + + # /def + + @pytest.mark.parametrize( + "zeta, expected, unit", + [ + (0, 1, None), + (0, 1 * u.pc, u.pc), + (0, 1 * u.Hz, u.Hz), + ], + ) + def test_unit_input(self, zeta, expected, unit): + """Test when input units.""" + assert np.allclose(r_of_zeta(zeta, unit=unit), expected) + + # /def + + @pytest.mark.parametrize("zeta", [-1, 0, 1, [-1, 0, 1]]) + def test_roundtrip(self, zeta): + """Test zeta and r round trip. Note that Quantities don't round trip.""" + assert np.allclose(zeta_of_r(r_of_zeta(zeta)), zeta) + + # /def + + +# /class + + +# ------------------------------------------------------------------- + + +class Test_x_of_theta: + """Test `sample_scf.utils.x_of_theta`.""" + + @pytest.mark.parametrize( + "theta, expected", + [(-np.pi / 2, -1), (0, 0), (np.pi / 2, 1), ([-np.pi / 2, 0, np.pi / 2], [-1, 0, 1])], + ) + def test__x_of_theta(self, theta, expected): + assert np.allclose(_x_of_theta(theta), expected) + + # /def + + @pytest.mark.parametrize( + "theta, expected", + [(-np.pi / 2, -1), (0, 0), (np.pi / 2, 1), ([-np.pi / 2, 0, np.pi / 2], [-1, 0, 1])], + ) + def test_x_of_theta(self, theta, expected): + assert np.allclose(x_of_theta(theta << u.rad), expected) + + # /def + + +# /class + + +# ------------------------------------------------------------------- + + +class Test_thetaQls: + """Test `sample_scf.utils.x_of_theta`.""" + + def setup_class(self): + """Set up class.""" + Acos = np.zeros((5, 6, 6)) + + Acos_hern = Acos.copy() + Acos_hern[0, 0, 0] = 1 + self.hernquist_pot = SCFPotential(Acos=Acos_hern) + + # /def + + # =============================================================== + # Usage Tests + + @pytest.mark.parametrize("r, expected", [(0, 1), (1, 0.01989437), (np.inf, 0)]) + def test_hernquist(self, r, expected): + Qls = thetaQls(self.hernquist_pot, r=r) + assert len(Qls) == 6 + assert np.isclose(Qls[0], expected) + assert np.allclose(Qls[1:], 0) + + # /def + + @pytest.mark.skip("TODO!") + def test_triaxialnfw(self): + assert False + + # /def + + +# /class + +# ------------------------------------------------------------------- + + +class Test_phiRSms: + """Test `sample_scf.utils.x_of_theta`.""" + + @pytest.mark.skip("TODO!") + def test__phiRSms(self): + assert False + + # /def + + @pytest.mark.skip("TODO!") + def test_phiRSms(self): + assert False + + # /def + + +# /class + +############################################################################## +# END diff --git a/sample_scf/utils.py b/sample_scf/utils.py index 5ab3cc7..2c142d4 100644 --- a/sample_scf/utils.py +++ b/sample_scf/utils.py @@ -56,7 +56,7 @@ with warnings.catch_warnings(): warnings.simplefilter("ignore") - # TODO! organize & allow for lmax > 200. + # TODO! allow for lmax > 200. lrange = arange(0, 200 + 1) Pls = array([legendre(L) for L in lrange], dtype=object) # l=1+. l=0 is done separately @@ -81,13 +81,14 @@ def zeta_of_r(r: T.Union[u.Quantity, NDArray64]) -> NDArray64: Parameters ---------- r : quantity-like ['length'] + 'r' must be in [0, infinity). Returns ------- zeta : ndarray With shape (len(r),) """ - ra: NDArray64 = r.value if isinstance(r, u.Quantity) else r + ra: NDArray64 = r.value if isinstance(r, u.Quantity) else np.asanyarray(r) zeta: NDArray64 = nan_to_num(divide(ra - 1, ra + 1), nan=1) return zeta @@ -110,11 +111,12 @@ def r_of_zeta( Returns ------- - r: ndarray + r: ndarray[float] or Quantity + If Quantity, has units of 'units'. """ z = array(zeta, subok=True) r = atleast_1d(divide(1 + z, 1 - z)) - r[r < 0] = 0 + r[r < 0] = 0 # correct small errors rq: T.Union[u.Quantity, NDArray64] = r * (unit or 1) return rq @@ -126,18 +128,20 @@ def r_of_zeta( # ------------------------------------------------------------------- -def _x_of_theta(theta: NDArray64) -> NDArray64: +def _x_of_theta(theta: npt.ArrayLike) -> NDArray64: r""":math:`x = \cos{\theta}`. Parameters ---------- theta : array-like ['radian'] + :math:`\theta \in [-\pi/2, \pi/2]` Returns ------- x : float or array-like + :math:`x \in [-1, 1]` """ - x: NDArray64 = cos(pi / 2 - theta) + x: NDArray64 = cos(pi / 2 - np.asanyarray(theta)) return x @@ -185,10 +189,14 @@ def thetaQls(pot: SCFPotential, r: T.Union[float, NDArray64]) -> NDArray64: # compute the r-dependent coefficient matrix $\tilde{\rho}$ # (R, N, L) nmax, lmax = pot._Acos.shape[:2] rs = atleast_1d(r) # need r to be array. - rhoTilde = array([pot._rhoTilde(r, N=nmax, L=lmax) for r in rs]) + rhoTilde = nan_to_num( + array([pot._rhoTilde(r, N=nmax, L=lmax) for r in rs]), + posinf=np.inf, + neginf=-np.inf, + ) # inclination weighting factors - Qls = sum(pot._Acos[None, :, :, 0] * rhoTilde, axis=1) # (R, N, L) + Qls = nan_to_num(sum(pot._Acos[None, :, :, 0] * rhoTilde, axis=1), nan=1) # (R, N, L) # remove extra dimensions, e.g. scalar 'r' Ql: NDArray64 = Qls.squeeze() From 3ebf477542440dbf6a5496fea484022665696b31 Mon Sep 17 00:00:00 2001 From: "Nathaniel Starkman (@nstarman)" Date: Sun, 25 Jul 2021 23:42:19 -0400 Subject: [PATCH 05/31] include pytest in known third party Signed-off-by: Nathaniel Starkman (@nstarman) --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 430661a..dc35c22 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,7 +15,7 @@ force_grid_wrap = 0 use_parentheses = "True" ensure_newline_before_comments = "True" sections = ["FUTURE", "STDLIB", "THIRDPARTY", "FIRSTPARTY", "LOCALFOLDER"] -known_third_party = ["astropy", "extension_helpers", "galpy", "numpy", "scipy", "setuptools"] +known_third_party = ["astropy", "extension_helpers", "galpy", "numpy", "pytest", "scipy", "setuptools"] known_localfolder = "sample_scf" import_heading_stdlib = "BUILT-IN" From af5f40bc9304b04499dfc103c64177f013269236 Mon Sep 17 00:00:00 2001 From: "Nathaniel Starkman (@nstarman)" Date: Mon, 26 Jul 2021 00:54:18 -0400 Subject: [PATCH 06/31] Add more documentation Signed-off-by: Nathaniel Starkman (@nstarman) --- sample_scf/base.py | 28 +++++++++++++++++++----- sample_scf/core.py | 28 ------------------------ sample_scf/sample_exact.py | 12 +---------- sample_scf/sample_intrp.py | 44 ++++++++++++++++++++++++++++++++++++-- 4 files changed, 66 insertions(+), 46 deletions(-) diff --git a/sample_scf/base.py b/sample_scf/base.py index fdc0b02..955d3ad 100644 --- a/sample_scf/base.py +++ b/sample_scf/base.py @@ -46,6 +46,24 @@ def rvs( size: T.Optional[int] = None, random_state: RandomLike = None ) -> NDArray64: + """Random variate sampler. + + Parameters + ---------- + *args + size : int or None (optional, keyword-only) + Size of random variates to generate. + random_state : int, `~numpy.random.Generator`, `~numpy.random.RandomState`, or None (optional, keyword-only) + If seed is None (or numpy.random), the `numpy.random.RandomState` + singleton is used. If seed is an int, a new RandomState instance is + used, seeded with seed. If seed is already a Generator or + RandomState instance then that instance is used. + + Returns + ------- + ndarray[float] + Shape 'size'. + """ # extra gymnastics needed for a custom random_state rndm: np.random.RandomState if random_state is not None: @@ -140,14 +158,14 @@ def cdf( def rvs( self, *, size: T.Optional[int] = None, random_state: RandomLike = None ) -> PhysicsSphericalRepresentation: - """Sample random variates + """Sample random variates. Parameters ---------- - size : int, optional - Defining number of random variates (default is 1). - random_state : None, int, `numpy.random.Generator`, `numpy.random.RandomState`, optional - If seed is None (or np.random), the `numpy.random.RandomState` + size : int or None (optional, keyword-only) + Defining number of random variates. + random_state : int, `~numpy.random.Generator`, `~numpy.random.RandomState`, or None (optional, keyword-only) + If seed is None (or numpy.random), the `numpy.random.RandomState` singleton is used. If seed is an int, a new RandomState instance is used, seeded with seed. If seed is already a Generator or RandomState instance then that instance is used. diff --git a/sample_scf/core.py b/sample_scf/core.py index 1ae04f0..0fdf1c2 100644 --- a/sample_scf/core.py +++ b/sample_scf/core.py @@ -82,34 +82,6 @@ class SCFSampler(SCFSamplerBase): # metaclass=SCFSamplerSwitch Passed to to the individual component sampler constructors. """ - # def __new__( - # cls, - # pot: SCFPotential, - # *args: T.Any, - # method: T.Union[T.Literal["interp", "exact"], T.Mapping[str, T.Callable]] = "interp", - # **kwargs: T.Any - # ) -> SCFSamplerBase: - # - # self: SCFSamplerBase - # if method == "interp": - # # LOCAL - # from sample_scf.sample_intrp import SCFSampler as interpcls - # - # self = interpcls(pot, *args, method=method, **kwargs) - # elif method == "exact": - # # LOCAL - # from sample_scf.sample_exact import SCFSampler as exactcls - # - # self = exactcls(pot, *args, method=method, **kwargs) - # elif isinstance(method, Mapping): - # self = super().__new__(cls) - # else: - # raise ValueError("`method` must be {'interp', 'exact'} or mapping.") - # - # return self - # - # # /def - def __init__( self, pot: SCFPotential, diff --git a/sample_scf/sample_exact.py b/sample_scf/sample_exact.py index 287a92a..823304b 100644 --- a/sample_scf/sample_exact.py +++ b/sample_scf/sample_exact.py @@ -33,7 +33,7 @@ # PARAMETERS TSCFPhi = T.TypeVar("TSCFPhi", bound="SCFPhiSampler") - +TSCFThetaSamplerBase = T.TypeVar("TSCFThetaSamplerBase", bound="SCFThetaSamplerBase") ############################################################################## # CODE @@ -103,8 +103,6 @@ def _cdf(self, r: npt.ArrayLike, *args: T.Any, **kw: T.Any) -> NDArray64: # ------------------------------------------------------------------- # inclination sampler -TSCFThetaSamplerBase = T.TypeVar("TSCFThetaSamplerBase", bound="SCFThetaSamplerBase") - class SCFThetaSamplerBase(rv_continuous_modrvs): def __new__( @@ -178,14 +176,6 @@ class SCFThetaSampler(SCFThetaSamplerBase): def __init__(self, pot: SCFPotential, r: float, **kw: T.Any) -> None: super().__init__(pot) - # allowed range of theta - # rv_continuous_modrvs.__init__(self, a=-np.pi / 2, b=np.pi / 2) - # - # # parse from potential - # self._pot = pot - # # shape parameters - # self._nmax, self._lmax = pot._Acos.shape[:2] - # self._lrange = np.arange(0, self._lmax + 1) # lmax inclusive # points at which CDF is defined self._r = r diff --git a/sample_scf/sample_intrp.py b/sample_scf/sample_intrp.py index 8cc1111..743214d 100644 --- a/sample_scf/sample_intrp.py +++ b/sample_scf/sample_intrp.py @@ -160,7 +160,7 @@ def __init__( # radial sampler -class SCFRSampler(rv_continuous): +class SCFRSampler(rv_continuous_modrvs): """Sample radial coordinate from an SCF potential. The potential must have a convergent mass function. @@ -336,6 +336,17 @@ def _cdf( # /def def cdf(self, theta: npt.ArrayLike, r: npt.ArrayLike) -> NDArray64: + """Cumulative Distribution Function. + + Parameters + ---------- + theta : array-like or Quantity-like + r : array-like or Quantity-like + + Returns + ------- + cdf : ndarray[float] + """ # TODO! make sure r, theta in right domain cdf = self._cdf(x_of_theta(u.Quantity(theta, u.rad)), zeta=zeta_of_r(r)) return cdf @@ -424,12 +435,21 @@ def rvs( # type: ignore class SCFPhiSampler(rv_continuous_modrvs): - """SCF phi sampler + """SCF phi sampler. .. todo:: Make sure that stuff actually goes from 0 to 1. + Parameters + ---------- + pot : `galpy.potential.SCFPotential` + rgrid : ndarray[float] + tgrid : ndarray[float] + pgrid : ndarray[float] + intrp_step : float, optional + **kw + Not used """ def __init__( @@ -580,10 +600,30 @@ def rvs( # type: ignore size: T.Optional[int] = None, random_state: RandomLike = None, ) -> NDArray64: + """Random variate sampler. + + Parameters + ---------- + r, theta : array-like[float] + size : int or None (optional, keyword-only) + Size of random variates to generate. + random_state : int, `~numpy.random.Generator`, `~numpy.random.RandomState`, or None (optional, keyword-only) + If seed is None (or numpy.random), the `numpy.random.RandomState` + singleton is used. If seed is an int, a new RandomState instance is + used, seeded with seed. If seed is already a Generator or + RandomState instance then that instance is used. + + Returns + ------- + ndarray[float] + Shape 'size'. + """ return super().rvs(r, theta, size=size, random_state=random_state) # /def +# /class + ############################################################################## # END From 913fa6d7770ca751c8d7fd4971e29d289e016ce5 Mon Sep 17 00:00:00 2001 From: "Nathaniel Starkman (@nstarman)" Date: Mon, 26 Jul 2021 00:56:33 -0400 Subject: [PATCH 07/31] outline remaining tests Signed-off-by: Nathaniel Starkman (@nstarman) --- sample_scf/tests/test_base.py | 85 +++++ sample_scf/tests/test_core.py | 34 ++ sample_scf/tests/test_sample_exact.py | 515 ++++++++++++++++++++++++++ sample_scf/tests/test_sample_intrp.py | 184 +++++++++ 4 files changed, 818 insertions(+) create mode 100644 sample_scf/tests/test_base.py create mode 100644 sample_scf/tests/test_core.py create mode 100644 sample_scf/tests/test_sample_exact.py create mode 100644 sample_scf/tests/test_sample_intrp.py diff --git a/sample_scf/tests/test_base.py b/sample_scf/tests/test_base.py new file mode 100644 index 0000000..45b2d0c --- /dev/null +++ b/sample_scf/tests/test_base.py @@ -0,0 +1,85 @@ +# -*- coding: utf-8 -*- + +"""Testing :mod:`scample_scf.base`.""" + + +############################################################################## +# IMPORTS + +# THIRD PARTY +import astropy.units as u +import numpy as np +import pytest +from galpy.potential import SCFPotential + +# LOCAL +from sample_scf import base + +############################################################################## +# TESTS +############################################################################## + + +class Test_rv_continuous_modrvs: + """Test `sample_scf.base.rv_continuous_modrvs`.""" + + @pytest.mark.skip("TODO!") + def test_rvs(self): + """Test :meth:`sample_scf.base.rv_continuous_modrvs.rvs`.""" + assert False + + # /def + + +# /class + + +# ------------------------------------------------------------------- + + +class Test_SCFSamplerBase: + """Test :class:`sample_scf.base.SCFSamplerBase`.""" + + _cls = base.SCFSamplerBase + + @pytest.mark.skip("TODO!") + def test_rsampler(self): + """Test :meth:`sample_scf.base.SCFSamplerBase.rsampler`.""" + assert False + + # /def + + @pytest.mark.skip("TODO!") + def test_thetasampler(self): + """Test :meth:`sample_scf.base.SCFSamplerBase.thetasampler`.""" + assert False + + # /def + + @pytest.mark.skip("TODO!") + def test_phisampler(self): + """Test :meth:`sample_scf.base.SCFSamplerBase.phisampler`.""" + assert False + + # /def + + @pytest.mark.skip("TODO!") + def test_cdf(self): + """Test :meth:`sample_scf.base.SCFSamplerBase.cdf`.""" + assert False + + # /def + + @pytest.mark.skip("TODO!") + def test_rvs(self): + """Test :meth:`sample_scf.base.SCFSamplerBase.rvs`.""" + assert False + + # /def + + +# /class + + +############################################################################## +# END diff --git a/sample_scf/tests/test_core.py b/sample_scf/tests/test_core.py new file mode 100644 index 0000000..f540810 --- /dev/null +++ b/sample_scf/tests/test_core.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8 -*- + +"""Testing :mod:`scample_scf.core`.""" + + +############################################################################## +# IMPORTS + +# THIRD PARTY +import astropy.units as u +import numpy as np +import pytest +from galpy.potential import SCFPotential + +# LOCAL +from .test_base import Test_SCFSamplerBase +from sample_scf import core + +############################################################################## +# TESTS +############################################################################## + + +@pytest.mark.skip("TODO!") +class Test_SCFSampler(Test_SCFSamplerBase): + """Test :class:`sample_scf.core.SCFSample`.""" + + _cls = core.SCFSampler + + +# /class + +############################################################################## +# END diff --git a/sample_scf/tests/test_sample_exact.py b/sample_scf/tests/test_sample_exact.py new file mode 100644 index 0000000..3601f05 --- /dev/null +++ b/sample_scf/tests/test_sample_exact.py @@ -0,0 +1,515 @@ +# -*- coding: utf-8 -*- + +"""Tests for :mod:`sample_scf.sample_exact`.""" + +__all__ = [ + # "Test_SCFRSampler", + # "Test_SCFThetaSampler", + # "Test_SCFThetaSampler_of_r", +] + + +############################################################################## +# IMPORTS + +# BUILT-IN +import pathlib +import time + +# THIRD PARTY +import matplotlib.pyplot as plt +import numpy as np +import pytest +from astropy.utils.misc import NumpyRNGContext +from galpy.df import isotropicHernquistdf +from galpy.potential import HernquistPotential, SCFPotential + +# LOCAL +from sample_scf.sample_exact import SCFPhiSampler, SCFRSampler, SCFSampler, SCFThetaSampler +from sample_scf.utils import x_of_theta, zeta_of_r + +############################################################################## +# PARAMETERS + +# hernpot = TriaxialHernquistPotential(b=0.8, c=1.2) +hernpot = HernquistPotential() +coeffs = np.load(pathlib.Path(__file__).parent / "scf_coeffs.npz") +Acos, Asin = coeffs["Acos"], coeffs["Asin"] + +pot = SCFPotential(Acos=Acos, Asin=Asin) +pot.turn_physical_off() + +# r sampling +r = np.unique(np.concatenate([[0], np.geomspace(1e-7, 1e3, 100), [np.inf]])) +zeta = zeta_of_r(r) +m = [pot._mass(x) for x in r] +m[0] = 0 +m[-1] = 1 + +# theta sampling +theta = np.linspace(-np.pi / 2, np.pi / 2, 30) + +# phi sampling +phi = np.linspace(0, 2 * np.pi, 30) + +############################################################################## +# CODE +############################################################################## + + +# class Test_SCFRSampler: +# def setup_class(self): +# self.sampler = SCFRSampler(m, r) +# self.theory = isotropicHernquistdf(hernpot) +# +# # /def +# +# # =============================================================== +# # Method Tests +# +# def test___init__(self): +# pass # TODO! +# # test if mgrid is SCFPotential +# +# # =============================================================== +# # Usage Tests +# +# @pytest.mark.parametrize("size", [1, 10, 100, 1000, 10000]) +# def test_cdf_time_scaling(self, size): +# """Test that the time scales as ~200 microseconds * size""" +# tic = time.perf_counter() +# self.sampler.cdf(np.linspace(0, 1e4, size)) +# toc = time.perf_counter() +# +# assert (toc - tic) < 0.0003 * size # 200 microseconds * linear scaling +# +# # /def +# +# @pytest.mark.parametrize("size", [1, 10, 100, 1000, 10000]) +# def test_rvs_time_scaling(self, size): +# """Test that the time scales as ~300 microseconds * size""" +# tic = time.perf_counter() +# self.sampler.rvs(size=size) +# toc = time.perf_counter() +# +# assert (toc - tic) < 0.0004 * size # 300 microseconds * linear scaling +# +# # /def +# +# # ---------------------------------------------------------------- +# # Image tests +# +# @pytest.mark.mpl_image_compare( +# baseline_dir="baseline_images", +# hash_library="baseline_images/path_to_file.json", +# ) +# def test_r_cdf_plot(self): +# """Compare""" +# fig = plt.figure(figsize=(10, 3)) +# +# ax = fig.add_subplot( +# 121, +# title=r"$m(\leq r) / m_{tot}$", +# xlabel="r", +# ylabel=r"$m(\leq r) / m_{tot}$", +# ) +# ax.semilogx( +# r, +# self.sampler.cdf(r), +# marker="o", +# ms=5, +# c="k", +# zorder=10, +# label="CDF", +# ) +# ax.axvline(0, c="tab:blue") +# ax.axhline(self.sampler.cdf(0), c="tab:blue", label="r=0") +# ax.axvline(1, c="tab:green") +# ax.axhline(self.sampler.cdf(1), c="tab:green", label="r=1") +# ax.axvline(1e2, c="tab:red") +# ax.axhline(self.sampler.cdf(1e2), c="tab:red", label="r=100") +# +# ax.set_xlim((1e-1, None)) +# ax.legend() +# +# ax = fig.add_subplot( +# 122, +# title=r"$m(\leq \zeta) / m_{tot}$", +# xlabel=r"$\zeta$", +# ylabel=r"$m(\leq \zeta) / m_{tot}$", +# ) +# ax.plot( +# zeta, +# self.sampler.cdf(r), +# marker="o", +# ms=4, +# c="k", +# zorder=10, +# label="CDF", +# ) +# ax.axvline(zeta_of_r(0), c="tab:blue") +# ax.axhline(self.sampler.cdf(0), c="tab:blue", label="r=0") +# ax.axvline(zeta_of_r(1), c="tab:green") +# ax.axhline(self.sampler.cdf(1), c="tab:green", label="r=1") +# ax.axvline(zeta_of_r(1e2), c="tab:red") +# ax.axhline(self.sampler.cdf(1e2), c="tab:red", label="r=100") +# +# ax.legend() +# +# fig.tight_layout() +# return fig +# +# # /def +# +# @pytest.mark.mpl_image_compare( +# baseline_dir="baseline_images", +# hash_library="baseline_images/path_to_file.json", +# ) +# def test_r_sampling_plot(self): +# """Test sampling.""" +# with NumpyRNGContext(0): # control the random numbers +# sample = self.sampler.rvs(size=1000000) +# sample = sample[sample < 1e4] +# +# theory = self.theory.sample(n=1000000).r() +# theory = theory[theory < 1e4] +# +# fig = plt.figure(figsize=(10, 3)) +# ax = fig.add_subplot(121, title="SCF vs theory sampling", xlabel="r", ylabel="frequency") +# _, bins, *_ = ax.hist(sample, bins=30, log=True, alpha=0.5, label="SCF sample") +# # Comparing to expected +# ax.hist( +# theory, +# bins=bins, +# log=True, +# alpha=0.5, +# label="Hernquist theoretical", +# ) +# ax.legend() +# fig.tight_layout() +# +# return fig +# +# # /def +# +# +# # /class +# +# +# class Test_SCFThetaSampler: +# def setup_class(self): +# self.sampler = SCFThetaSampler(pot, r=1) +# self.theory = isotropicHernquistdf(hernpot) +# +# # /def +# +# # =============================================================== +# # Method Tests +# +# def test___init__(self): +# pass # TODO! +# # test if mgrid is SCFPotential +# +# # =============================================================== +# # Usage Tests +# +# @pytest.mark.parametrize("size", [1, 10, 100, 1000, 10000]) +# def test_cdf_time_scaling(self, size): +# """Test that the time scales as ~800 microseconds * size""" +# tic = time.perf_counter() +# self.sampler.cdf(np.linspace(0, np.pi, size)) +# toc = time.perf_counter() +# +# assert (toc - tic) < 0.001 * size # 800 microseconds * linear scaling +# +# # /def +# +# @pytest.mark.parametrize("size", [1, 10, 100]) +# def test_rvs_time_scaling(self, size): +# """Test that the time scales as ~4 milliseconds * size""" +# tic = time.perf_counter() +# self.sampler.rvs(size=size) +# toc = time.perf_counter() +# +# assert (toc - tic) < 0.005 * size # linear scaling +# +# # /def +# +# # ---------------------------------------------------------------- +# # Image tests +# +# @pytest.mark.mpl_image_compare( +# baseline_dir="baseline_images", +# hash_library="baseline_images/path_to_file.json", +# ) +# def test_theta_cdf_plot(self): +# """Compare""" +# fig = plt.figure(figsize=(10, 3)) +# +# ax = fig.add_subplot( +# 121, +# title=r"$\Theta(\leq \theta; r=1)$", +# xlabel=r"$\theta$", +# ylabel=r"$\Theta(\leq \theta; r=1)$", +# ) +# ax.plot( +# theta, +# self.sampler.cdf(theta), +# marker="o", +# ms=5, +# c="k", +# zorder=10, +# label="CDF", +# ) +# ax.legend(loc="lower right") +# +# # Plotting CDF against x. +# # This should be a straight line. +# ax = fig.add_subplot( +# 122, +# title=r"$\Theta(\leq \theta; r=1)$", +# xlabel=r"$x=\cos\theta$", +# ylabel=r"$\Theta(\leq \theta; r=1)$", +# ) +# ax.plot( +# x_of_theta(theta), +# self.sampler.cdf(theta), +# marker="o", +# ms=4, +# c="k", +# zorder=10, +# label="CDF", +# ) +# ax.legend(loc="lower right") +# +# fig.tight_layout() +# return fig +# +# # /def +# +# @pytest.mark.mpl_image_compare( +# baseline_dir="baseline_images", +# hash_library="baseline_images/path_to_file.json", +# ) +# def test_theta_sampling_plot(self): +# """Test sampling.""" +# with NumpyRNGContext(0): # control the random numbers +# sample = self.sampler.rvs(size=1000) +# sample = sample[sample < 1e4] +# +# theory = self.theory.sample(n=1000).theta() - np.pi / 2 +# theory = theory[theory < 1e4] +# +# fig = plt.figure(figsize=(7, 5)) +# ax = fig.add_subplot( +# 111, +# title="SCF vs theory sampling", +# xlabel="theta", +# ylabel="frequency", +# ) +# _, bins, *_ = ax.hist(sample, bins=30, log=True, alpha=0.5, label="SCF sample") +# # Comparing to expected +# ax.hist( +# theory, +# bins=bins, +# log=True, +# alpha=0.5, +# label="Hernquist theoretical", +# ) +# ax.legend(fontsize=12) +# fig.tight_layout() +# +# return fig +# +# # /def +# +# +# # /class +# +# +# class Test_SCFThetaSampler_of_r: +# def setup_class(self): +# self.sampler = SCFThetaSampler(pot) +# self.theory = isotropicHernquistdf(hernpot) +# +# # /def +# +# # =============================================================== +# # Method Tests +# +# def test___init__(self): +# pass # TODO! +# # test if mgrid is SCFPotential +# +# # =============================================================== +# # Usage Tests +# +# @pytest.mark.parametrize("size", [1, 10, 100, 1000, 10000]) +# def test_cdf_time_scaling(self, size): +# """Test that the time scales as ~3 milliseconds * size""" +# tic = time.perf_counter() +# self.sampler.cdf(np.linspace(0, np.pi, size), r=1) +# toc = time.perf_counter() +# +# assert (toc - tic) < 0.003 * size # 3 microseconds * linear scaling +# +# # /def +# +# @pytest.mark.parametrize("size", [1, 10, 100]) +# def test_rvs_time_scaling(self, size): +# """Test that the time scales as ~4 milliseconds * size""" +# tic = time.perf_counter() +# self.sampler.rvs(size=size, r=1) +# toc = time.perf_counter() +# +# assert (toc - tic) < 0.04 * size # linear scaling +# +# # /def +# +# def test_cdf_independent_of_r(self): +# """The Hernquist potential CDF(theta) is r independent.""" +# expected = (x_of_theta(theta) + 1) / 2 +# # [-1, 1] -> [0, 1] with a +# +# # assert np.allclose(self.sampler.cdf(theta, r=0), expected) # FIXME! +# assert np.allclose(self.sampler.cdf(theta, r=1), expected) +# assert np.allclose(self.sampler.cdf(theta, r=2), expected) +# # assert np.allclose(self.sampler.cdf(theta, r=np.inf), expected) # FIXME! +# +# +# # ------------------------------------------------------------------------------ +# +# +# class Test_SCFPhiSampler: +# def setup_class(self): +# self.sampler = SCFPhiSampler(pot, r=1, theta=np.pi / 3) +# self.theory = isotropicHernquistdf(hernpot) +# +# # /def +# +# # =============================================================== +# # Method Tests +# +# def test___init__(self): +# pass # TODO! +# # test if mgrid is SCFPotential +# +# # =============================================================== +# # Usage Tests +# +# @pytest.mark.parametrize("size", [1, 10, 100, 1000, 10000]) +# def test_cdf_time_scaling(self, size): +# """Test that the time scales as ~800 microseconds * size""" +# tic = time.perf_counter() +# self.sampler.cdf(np.linspace(0, 2 * np.pi, size)) +# toc = time.perf_counter() +# +# assert (toc - tic) < 0.001 * size # 800 microseconds * linear scaling +# +# # /def +# +# @pytest.mark.parametrize("size", [1, 10, 100]) +# def test_rvs_time_scaling(self, size): +# """Test that the time scales as ~4 milliseconds * size""" +# tic = time.perf_counter() +# self.sampler.rvs(size=size) +# toc = time.perf_counter() +# +# assert (toc - tic) < 0.005 * size # linear scaling +# +# # /def +# +# # ---------------------------------------------------------------- +# # Image tests +# +# @pytest.mark.mpl_image_compare( +# baseline_dir="baseline_images", +# hash_library="baseline_images/path_to_file.json", +# ) +# def test_phi_cdf_plot(self): +# """Compare""" +# fig = plt.figure(figsize=(10, 3)) +# +# ax = fig.add_subplot( +# 121, +# title=r"$\Phi(\leq \phi; r=1)$", +# xlabel=r"$\phi$", +# ylabel=r"$\Phi(\leq \phi; r=1)$", +# ) +# ax.plot( +# phi, +# self.sampler.cdf(phi), +# marker="o", +# ms=5, +# c="k", +# zorder=10, +# label="CDF", +# ) +# ax.legend(loc="lower right") +# +# # Plotting CDF against x. +# # This should be a straight line. +# ax = fig.add_subplot( +# 122, +# title=r"$\Phi(\leq \phi; r=1)$", +# xlabel=r"$\phi/2\pi$", +# ylabel=r"$\Phi(\leq \phi; r=1)$", +# ) +# ax.plot( +# phi / (2 * np.pi), +# self.sampler.cdf(phi), +# marker="o", +# ms=4, +# c="k", +# zorder=10, +# label="CDF", +# ) +# ax.legend(loc="lower right") +# +# fig.tight_layout() +# return fig +# +# # /def +# +# @pytest.mark.mpl_image_compare( +# baseline_dir="baseline_images", +# hash_library="baseline_images/path_to_file.json", +# ) +# def test_phi_sampling_plot(self): +# """Test sampling.""" +# with NumpyRNGContext(0): # control the random numbers +# sample = self.sampler.rvs(size=1000) +# sample = sample[sample < 1e4] +# +# theory = self.theory.sample(n=1000).phi() +# theory = theory[theory < 1e4] +# +# fig = plt.figure(figsize=(7, 5)) +# ax = fig.add_subplot( +# 111, +# title="SCF vs theory sampling", +# xlabel="theta", +# ylabel="frequency", +# ) +# _, bins, *_ = ax.hist(sample, bins=30, log=True, alpha=0.5, label="SCF sample") +# # Comparing to expected +# ax.hist( +# theory, +# bins=bins, +# log=True, +# alpha=0.5, +# label="Hernquist theoretical", +# ) +# ax.legend(fontsize=12) +# fig.tight_layout() +# +# return fig +# +# # /def +# +# +# # /class + + +############################################################################## +# END diff --git a/sample_scf/tests/test_sample_intrp.py b/sample_scf/tests/test_sample_intrp.py new file mode 100644 index 0000000..08e4201 --- /dev/null +++ b/sample_scf/tests/test_sample_intrp.py @@ -0,0 +1,184 @@ +# -*- coding: utf-8 -*- + +"""Testing :mod:`scample_scf.sample_intrp`.""" + + +############################################################################## +# IMPORTS + +# THIRD PARTY +import astropy.units as u +import numpy as np +import pytest +from galpy.potential import SCFPotential + +# LOCAL +from .test_base import Test_rv_continuous_modrvs, Test_SCFSamplerBase +from sample_scf import sample_intrp + +############################################################################## +# TESTS +############################################################################## + + +@pytest.mark.skip("TODO!") +class Test_SCFSampler(Test_SCFSamplerBase): + """Test :class:`sample_scf.sample_intrp.SCFSampler`.""" + + _cls = sample_intrp.SCFSampler + + +# /class + +# ------------------------------------------------------------------- + + +class Test_SCFRSampler(Test_rv_continuous_modrvs): + """Test :class:`sample_scf.`""" + + # =============================================================== + # Method Tests + + @pytest.mark.skip("TODO!") + def test___init__(self): + """Test :meth:`sample_scf.sample_intrp.SCFRSampler._cdf`.""" + assert False + + # /def + + @pytest.mark.skip("TODO!") + def test__cdf(self): + """Test :meth:`sample_scf.sample_intrp.SCFRSampler._cdf`.""" + assert False + + # /def + + @pytest.mark.skip("TODO!") + def test__ppf(self): + """Test :meth:`sample_scf.sample_intrp.SCFRSampler._ppf`.""" + assert False + + # /def + + # =============================================================== + # Usage Tests + + +# /class + +# ------------------------------------------------------------------- + + +class Test_SCFThetaSampler(Test_rv_continuous_modrvs): + """Test :class:`sample_scf.sample_intrp.SCFThetaSampler`.""" + + # =============================================================== + # Method Tests + + @pytest.mark.skip("TODO!") + def test___init__(self): + """Test :meth:`sample_scf.sample_intrp.SCFThetaSampler._cdf`.""" + assert False + + # /def + + @pytest.mark.skip("TODO!") + def test__cdf(self): + """Test :meth:`sample_scf.sample_intrp.SCFThetaSampler._cdf`.""" + assert False + + # /def + + @pytest.mark.skip("TODO!") + def test_cdf(self): + """Test :meth:`sample_scf.sample_intrp.SCFThetaSampler.cdf`.""" + assert False + + # /def + + @pytest.mark.skip("TODO!") + def test__ppf(self): + """Test :meth:`sample_scf.sample_intrp.SCFThetaSampler._ppf`.""" + assert False + + # /def + + @pytest.mark.skip("TODO!") + def test__rvs(self): + """Test :meth:`sample_scf.sample_intrp.SCFThetaSampler._rvs`.""" + assert False + + # /def + + @pytest.mark.skip("TODO!") + def test_rvs(self): + """Test :meth:`sample_scf.sample_intrp.SCFThetaSampler.rvs`.""" + assert False + + # /def + + # =============================================================== + # Usage Tests + + +# /class + +# ------------------------------------------------------------------- + + +class Test_SCFPhiSampler(Test_rv_continuous_modrvs): + """Test :class:`sample_scf.sample_intrp.SCFPhiSampler`.""" + + # =============================================================== + # Method Tests + + @pytest.mark.skip("TODO!") + def test___init__(self): + """Test :meth:`sample_scf.sample_intrp.SCFPhiSampler._cdf`.""" + assert False + + # /def + + @pytest.mark.skip("TODO!") + def test__cdf(self): + """Test :meth:`sample_scf.sample_intrp.SCFPhiSampler._cdf`.""" + assert False + + # /def + + @pytest.mark.skip("TODO!") + def test_cdf(self): + """Test :meth:`sample_scf.sample_intrp.SCFPhiSampler.cdf`.""" + assert False + + # /def + + @pytest.mark.skip("TODO!") + def test__ppf(self): + """Test :meth:`sample_scf.sample_intrp.SCFPhiSampler._ppf`.""" + assert False + + # /def + + @pytest.mark.skip("TODO!") + def test__rvs(self): + """Test :meth:`sample_scf.sample_intrp.SCFPhiSampler._rvs`.""" + assert False + + # /def + + @pytest.mark.skip("TODO!") + def test_rvs(self): + """Test :meth:`sample_scf.sample_intrp.SCFPhiSampler.rvs`.""" + assert False + + # /def + + # =============================================================== + # Usage Tests + + +# /class + +############################################################################## +# END From 7bdf73178ff13f80e616b568e7a16a14c19068d3 Mon Sep 17 00:00:00 2001 From: "Nathaniel Starkman (@nstarman)" Date: Mon, 26 Jul 2021 01:06:59 -0400 Subject: [PATCH 08/31] prune imports Signed-off-by: Nathaniel Starkman (@nstarman) --- sample_scf/base.py | 4 ++-- sample_scf/sample_intrp.py | 3 +-- sample_scf/tests/test_base.py | 8 +++++--- sample_scf/tests/test_core.py | 3 --- sample_scf/tests/test_sample_exact.py | 17 ++++++++++------- sample_scf/tests/test_sample_intrp.py | 8 +++++--- 6 files changed, 23 insertions(+), 20 deletions(-) diff --git a/sample_scf/base.py b/sample_scf/base.py index 955d3ad..a65e73f 100644 --- a/sample_scf/base.py +++ b/sample_scf/base.py @@ -53,7 +53,7 @@ def rvs( *args size : int or None (optional, keyword-only) Size of random variates to generate. - random_state : int, `~numpy.random.Generator`, `~numpy.random.RandomState`, or None (optional, keyword-only) + random_state : int, `~numpy.random.RandomState`, or None (optional, keyword-only) If seed is None (or numpy.random), the `numpy.random.RandomState` singleton is used. If seed is an int, a new RandomState instance is used, seeded with seed. If seed is already a Generator or @@ -164,7 +164,7 @@ def rvs( ---------- size : int or None (optional, keyword-only) Defining number of random variates. - random_state : int, `~numpy.random.Generator`, `~numpy.random.RandomState`, or None (optional, keyword-only) + random_state : int, `~numpy.random.RandomState`, or None (optional, keyword-only) If seed is None (or numpy.random), the `numpy.random.RandomState` singleton is used. If seed is an int, a new RandomState instance is used, seeded with seed. If seed is already a Generator or diff --git a/sample_scf/sample_intrp.py b/sample_scf/sample_intrp.py index 743214d..0396be0 100644 --- a/sample_scf/sample_intrp.py +++ b/sample_scf/sample_intrp.py @@ -28,7 +28,6 @@ splev, splrep, ) -from scipy.stats import rv_continuous # LOCAL from ._typing import NDArray64, RandomLike @@ -607,7 +606,7 @@ def rvs( # type: ignore r, theta : array-like[float] size : int or None (optional, keyword-only) Size of random variates to generate. - random_state : int, `~numpy.random.Generator`, `~numpy.random.RandomState`, or None (optional, keyword-only) + random_state : int, `~numpy.random.RandomState`, or None (optional, keyword-only) If seed is None (or numpy.random), the `numpy.random.RandomState` singleton is used. If seed is an int, a new RandomState instance is used, seeded with seed. If seed is already a Generator or diff --git a/sample_scf/tests/test_base.py b/sample_scf/tests/test_base.py index 45b2d0c..33bd3c3 100644 --- a/sample_scf/tests/test_base.py +++ b/sample_scf/tests/test_base.py @@ -7,14 +7,16 @@ # IMPORTS # THIRD PARTY -import astropy.units as u -import numpy as np +# import astropy.units as u +# import numpy as np import pytest -from galpy.potential import SCFPotential # LOCAL from sample_scf import base +# from galpy.potential import SCFPotential + + ############################################################################## # TESTS ############################################################################## diff --git a/sample_scf/tests/test_core.py b/sample_scf/tests/test_core.py index f540810..5627f03 100644 --- a/sample_scf/tests/test_core.py +++ b/sample_scf/tests/test_core.py @@ -7,10 +7,7 @@ # IMPORTS # THIRD PARTY -import astropy.units as u -import numpy as np import pytest -from galpy.potential import SCFPotential # LOCAL from .test_base import Test_SCFSamplerBase diff --git a/sample_scf/tests/test_sample_exact.py b/sample_scf/tests/test_sample_exact.py index 3601f05..38af02e 100644 --- a/sample_scf/tests/test_sample_exact.py +++ b/sample_scf/tests/test_sample_exact.py @@ -14,19 +14,22 @@ # BUILT-IN import pathlib -import time # THIRD PARTY -import matplotlib.pyplot as plt +# import matplotlib.pyplot as plt import numpy as np -import pytest -from astropy.utils.misc import NumpyRNGContext -from galpy.df import isotropicHernquistdf + +# import pytest +# from astropy.utils.misc import NumpyRNGContext +# from galpy.df import isotropicHernquistdf from galpy.potential import HernquistPotential, SCFPotential # LOCAL -from sample_scf.sample_exact import SCFPhiSampler, SCFRSampler, SCFSampler, SCFThetaSampler -from sample_scf.utils import x_of_theta, zeta_of_r +# from sample_scf.sample_exact import SCFPhiSampler, SCFRSampler, SCFSampler, SCFThetaSampler +from sample_scf.utils import zeta_of_r # x_of_theta + +# import time + ############################################################################## # PARAMETERS diff --git a/sample_scf/tests/test_sample_intrp.py b/sample_scf/tests/test_sample_intrp.py index 08e4201..d0530a6 100644 --- a/sample_scf/tests/test_sample_intrp.py +++ b/sample_scf/tests/test_sample_intrp.py @@ -7,15 +7,17 @@ # IMPORTS # THIRD PARTY -import astropy.units as u -import numpy as np +# import astropy.units as u +# import numpy as np import pytest -from galpy.potential import SCFPotential # LOCAL from .test_base import Test_rv_continuous_modrvs, Test_SCFSamplerBase from sample_scf import sample_intrp +# from galpy.potential import SCFPotential + + ############################################################################## # TESTS ############################################################################## From 0e6f7a5f8d81974e01052efea8304e08741bcd28 Mon Sep 17 00:00:00 2001 From: "Nathaniel Starkman (@nstarman)" Date: Mon, 26 Jul 2021 01:10:06 -0400 Subject: [PATCH 09/31] add data file Signed-off-by: Nathaniel Starkman (@nstarman) --- sample_scf/tests/scf_coeffs.npz | Bin 0 -> 184822 bytes setup.cfg | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) create mode 100644 sample_scf/tests/scf_coeffs.npz diff --git a/sample_scf/tests/scf_coeffs.npz b/sample_scf/tests/scf_coeffs.npz new file mode 100644 index 0000000000000000000000000000000000000000..caf376539b3573e66c5314fa2842e44163f31b5e GIT binary patch literal 184822 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 literal 0 HcmV?d00001 diff --git a/setup.cfg b/setup.cfg index 4ffd801..4bf7c73 100644 --- a/setup.cfg +++ b/setup.cfg @@ -32,7 +32,7 @@ docs = sphinx-astropy [options.package_data] -sample_scf = data/* +sample_scf = data/*, tests/scf_coeffs.npz [tool:pytest] testpaths = "sample_scf" "docs" From 3a4b67532f6fe59cd97b7eed3295268814658728 Mon Sep 17 00:00:00 2001 From: "Nathaniel Starkman (@nstarman)" Date: Tue, 27 Jul 2021 00:04:46 -0400 Subject: [PATCH 10/31] consolidate x_of_theta via singledispatch Signed-off-by: Nathaniel Starkman (@nstarman) --- sample_scf/sample_exact.py | 4 +-- sample_scf/sample_intrp.py | 19 +++--------- sample_scf/utils.py | 62 +++++++++++++++++++++++++++++++------- 3 files changed, 58 insertions(+), 27 deletions(-) diff --git a/sample_scf/sample_exact.py b/sample_scf/sample_exact.py index 823304b..962de0e 100644 --- a/sample_scf/sample_exact.py +++ b/sample_scf/sample_exact.py @@ -24,7 +24,7 @@ # LOCAL from ._typing import NDArray64, RandomLike from .base import SCFSamplerBase, rv_continuous_modrvs -from .utils import _x_of_theta, difPls, phiRSms, thetaQls, x_of_theta +from .utils import difPls, phiRSms, thetaQls, x_of_theta __all__: T.List[str] = ["SCFSampler", "SCFRSampler", "SCFThetaSampler", "SCFPhiSampler"] @@ -218,7 +218,7 @@ def _cdf(self, theta: npt.ArrayLike, *args: T.Any) -> NDArray64: class SCFThetaSampler_of_r(SCFThetaSamplerBase): def _cdf(self, theta: NDArray64, *args: T.Any, r: float) -> NDArray64: - x = _x_of_theta(theta) + x = x_of_theta(theta) Qlsatr = self.Qls(r) # l = 0 diff --git a/sample_scf/sample_intrp.py b/sample_scf/sample_intrp.py index 0396be0..a86482a 100644 --- a/sample_scf/sample_intrp.py +++ b/sample_scf/sample_intrp.py @@ -32,16 +32,7 @@ # LOCAL from ._typing import NDArray64, RandomLike from .base import SCFSamplerBase, rv_continuous_modrvs -from .utils import ( - _phiRSms, - _x_of_theta, - difPls, - phiRSms, - r_of_zeta, - thetaQls, - x_of_theta, - zeta_of_r, -) +from .utils import _phiRSms, difPls, phiRSms, r_of_zeta, thetaQls, x_of_theta, zeta_of_r __all__: T.List[str] = ["SCFSampler", "SCFRSampler", "SCFThetaSampler", "SCFPhiSampler"] @@ -253,7 +244,7 @@ def __init__( super().__init__(a=-np.pi / 2, b=np.pi / 2) # allowed range of theta self._theta_interpolant = np.arange(-np.pi / 2, np.pi / 2, intrp_step) - self._x_interpolant = _x_of_theta(self._theta_interpolant) + self._x_interpolant = x_of_theta(self._theta_interpolant) self._q_interpolant = np.linspace(0, 1, len(self._theta_interpolant)) # ------- @@ -268,7 +259,7 @@ def __init__( # TODO: clean up shape stuff zetas = zeta_of_r(rgrid) # (R,) - xs = _x_of_theta(tgrid) # (T,) + xs = x_of_theta(tgrid) # (T,) if "Qls" in kw: Qls: NDArray64 = kw["Qls"] @@ -476,7 +467,7 @@ def __init__( # build CDF zetas = zeta_of_r(rgrid) # (R,) - xs = _x_of_theta(tgrid) # (T,) + xs = x_of_theta(tgrid) # (T,) lR, lT, _ = len(rgrid), len(tgrid), len(pgrid) @@ -571,7 +562,7 @@ def _ppf( grid: bool = False, **kw: T.Any, ) -> NDArray64: - ppf: NDArray64 = self._spl_ppf((zeta_of_r(r), _x_of_theta(theta), q)) + ppf: NDArray64 = self._spl_ppf((zeta_of_r(r), x_of_theta(theta), q)) return ppf # /def diff --git a/sample_scf/utils.py b/sample_scf/utils.py index 2c142d4..fd31f03 100644 --- a/sample_scf/utils.py +++ b/sample_scf/utils.py @@ -12,6 +12,7 @@ from __future__ import annotations # BUILT-IN +import functools import typing as T import warnings @@ -22,6 +23,7 @@ from galpy.potential import SCFPotential from numpy import ( arange, + arccos, array, asanyarray, atleast_1d, @@ -118,7 +120,9 @@ def r_of_zeta( r = atleast_1d(divide(1 + z, 1 - z)) r[r < 0] = 0 # correct small errors - rq: T.Union[u.Quantity, NDArray64] = r * (unit or 1) + rq: T.Union[NDArray64, u.Quantity] + rq = r << unit if unit is not None else r + return rq @@ -128,7 +132,8 @@ def r_of_zeta( # ------------------------------------------------------------------- -def _x_of_theta(theta: npt.ArrayLike) -> NDArray64: +@functools.singledispatch +def x_of_theta(theta: npt.ArrayLike) -> NDArray64: r""":math:`x = \cos{\theta}`. Parameters @@ -138,18 +143,15 @@ def _x_of_theta(theta: npt.ArrayLike) -> NDArray64: Returns ------- - x : float or array-like + x : ndarray[float] :math:`x \in [-1, 1]` """ x: NDArray64 = cos(pi / 2 - np.asanyarray(theta)) return x -# /def - - -# @u.quantity_input(theta=u.radian) -def x_of_theta(theta: u.Quantity) -> NDArray64: +@x_of_theta.register +def _(theta: u.Quantity) -> NDArray64: r""":math:`x = \cos{\theta}`. Parameters @@ -160,12 +162,41 @@ def x_of_theta(theta: u.Quantity) -> NDArray64: ------- x : float or ndarray """ - x = _x_of_theta(theta.to_value(u.rad)) + x = NDArray64 = cos(pi / 2 - theta.to_value(u.rad)) return x # /def + +def theta_of_x( + x: npt.ArrayLike, unit: T.Optional[u.UnitBase] = None +) -> T.Union[NDArray64, u.Quantity]: + r""":math:`\theta = \cos^{-1}{x}`. + + Parameters + ---------- + x : array-like + unit : unit-like['angular'] or None, optional + + Returns + ------- + theta : float or ndarray + """ + th: NDArray64 = pi / 2 - arccos(x) + + theta: T.Union[NDArray64, u.Quantity] + if unit is not None: + theta = u.Quantity(th, u.rad).to(unit) + else: + theta = th + + return theta + + +# /def + + # ------------------------------------------------------------------- @@ -233,7 +264,7 @@ def _phiRSms( tgrid: NDArray64 = atleast_1d(theta) # transform to correct shape for vectorized computation - x = _x_of_theta(asanyarray(tgrid)) # (T,) + x = x_of_theta(tgrid) # (T,) Xs = x[None, :, None, None, None] # ({R}, X, {N}, {L}, {L}) # compute the r-dependent coefficient matrix $\tilde{\rho}$ @@ -254,6 +285,10 @@ def _phiRSms( # n-sum # (R, X, L, L) Rlm = sum(Acos[None, None, :, :, :] * RSnlm, axis=2) Slm = sum(Asin[None, None, :, :, :] * RSnlm, axis=2) + # fix adding +/- inf -> NaN. happens when r=0. + idx = np.all(np.isnan(Rlm[:, 0, 0, :]), axis=-1) + Rlm[idx, 0, 0, :] = nan_to_num(Rlm[idx, 0, 0, :]) + Slm[idx, 0, 0, :] = nan_to_num(Slm[idx, 0, 0, :]) # m-sum # (R, X, L) sumidx = range(Rlm.shape[2]) @@ -302,7 +337,12 @@ def phiRSms( nmax: int lmax: int nmax, lmax = pot._Acos.shape[:2] - rhoTilde = array([pot._rhoTilde(r, N=nmax, L=lmax) for r in rgrid]) + rhoTilde = nan_to_num( + array([pot._rhoTilde(r, N=nmax, L=lmax) for r in rgrid]), + nan=0, + posinf=np.inf, + neginf=-np.inf, + ) # pass to actual calculator, which takes the matrices and r, theta grids. Rm, Sm = _phiRSms(rhoTilde, pot._Acos, pot._Asin, rgrid, tgrid) From 838c3d5ce3ff6fd4ad9283d72e640602f594cb41 Mon Sep 17 00:00:00 2001 From: "Nathaniel Starkman (@nstarman)" Date: Tue, 27 Jul 2021 00:05:02 -0400 Subject: [PATCH 11/31] more tests Signed-off-by: Nathaniel Starkman (@nstarman) --- sample_scf/conftest.py | 38 ++++++++- sample_scf/tests/test_utils.py | 141 ++++++++++++++++++++++++--------- sample_scf/utils.py | 3 +- 3 files changed, 142 insertions(+), 40 deletions(-) diff --git a/sample_scf/conftest.py b/sample_scf/conftest.py index 6608724..363f4da 100644 --- a/sample_scf/conftest.py +++ b/sample_scf/conftest.py @@ -14,7 +14,9 @@ import os # THIRD PARTY -from astropy.version import version as astropy_version # noqa: F401 +import numpy as np +import pytest +from galpy.potential import SCFPotential try: # THIRD PARTY @@ -24,6 +26,9 @@ except ImportError: ASTROPY_HEADER = False +# ============================================================================ +# Configuration + def pytest_configure(config): """Configure Pytest with Astropy. @@ -49,6 +54,9 @@ def pytest_configure(config): TESTED_VERSIONS[packagename] = __version__ +# /def + + # Uncomment the last two lines in this block to treat all DeprecationWarnings as # exceptions. For Astropy v2.0 or later, there are 2 additional keywords, # as follow (although default should work for most cases). @@ -61,3 +69,31 @@ def pytest_configure(config): # warnings_to_ignore_by_pyver={(MAJOR, MINOR): ['Message to ignore']} # from astropy.tests.helper import enable_deprecations_as_exceptions # noqa: F401 # enable_deprecations_as_exceptions() + + +# ============================================================================ +# Fixtures + + +@pytest.fixture(scope="session") +def hernquist_scf_potential(): + """Make a SCF of a Hernquist potential.""" + Acos = np.zeros((5, 6, 6)) + + Acos_hern = Acos.copy() + Acos_hern[0, 0, 0] = 1 + + hernpot = SCFPotential(Acos=Acos_hern) + return hernpot + + +# /def + + +@pytest.fixture(scope="session") +def nfw_scf_potential(): + """Make a SCF of a triaxial NFW potential.""" + raise NotImplementedError("TODO") + + +# /def diff --git a/sample_scf/tests/test_utils.py b/sample_scf/tests/test_utils.py index f6f5265..86e289b 100644 --- a/sample_scf/tests/test_utils.py +++ b/sample_scf/tests/test_utils.py @@ -14,9 +14,10 @@ import numpy as np import pytest from galpy.potential import SCFPotential +from numpy.testing import assert_allclose # LOCAL -from sample_scf.utils import _x_of_theta, r_of_zeta, thetaQls, x_of_theta, zeta_of_r +from sample_scf.utils import phiRSms, r_of_zeta, theta_of_x, thetaQls, x_of_theta, zeta_of_r ############################################################################## # TESTS @@ -44,7 +45,7 @@ class Test_zeta_of_r: def test_scalar_input(self, r, expected, warns): """Test when input scalar.""" with pytest.warns(warns) if warns is not False else contextlib.nullcontext(): - assert np.allclose(zeta_of_r(r), expected) + assert_allclose(zeta_of_r(r), expected) # /def @@ -58,14 +59,14 @@ def test_scalar_input(self, r, expected, warns): def test_array_input(self, r, expected): """Test when input array.""" with pytest.warns(RuntimeWarning): - assert np.allclose(zeta_of_r(r), expected) + assert_allclose(zeta_of_r(r), expected) # /def @pytest.mark.parametrize("r", [0, 1, np.inf, [0, 1, np.inf]]) def test_roundtrip(self, r): """Test zeta and r round trip. Note that Quantities don't round trip.""" - assert np.allclose(r_of_zeta(zeta_of_r(r)), r) + assert_allclose(r_of_zeta(zeta_of_r(r)), r) # /def @@ -97,7 +98,7 @@ class Test_r_of_zeta: def test_scalar_input(self, zeta, expected, warns): """Test when input scalar.""" with pytest.warns(warns) if warns is not False else contextlib.nullcontext(): - assert np.allclose(r_of_zeta(zeta), expected) + assert_allclose(r_of_zeta(zeta), expected) # /def @@ -110,7 +111,7 @@ def test_scalar_input(self, zeta, expected, warns): def test_array_input(self, zeta, expected, warns): """Test when input array.""" with pytest.warns(warns) if warns is not False else contextlib.nullcontext(): - assert np.allclose(r_of_zeta(zeta), expected) + assert_allclose(r_of_zeta(zeta), expected) # /def @@ -124,14 +125,14 @@ def test_array_input(self, zeta, expected, warns): ) def test_unit_input(self, zeta, expected, unit): """Test when input units.""" - assert np.allclose(r_of_zeta(zeta, unit=unit), expected) + assert_allclose(r_of_zeta(zeta, unit=unit), expected) # /def @pytest.mark.parametrize("zeta", [-1, 0, 1, [-1, 0, 1]]) def test_roundtrip(self, zeta): """Test zeta and r round trip. Note that Quantities don't round trip.""" - assert np.allclose(zeta_of_r(r_of_zeta(zeta)), zeta) + assert_allclose(zeta_of_r(r_of_zeta(zeta)), zeta) # /def @@ -147,56 +148,97 @@ class Test_x_of_theta: @pytest.mark.parametrize( "theta, expected", - [(-np.pi / 2, -1), (0, 0), (np.pi / 2, 1), ([-np.pi / 2, 0, np.pi / 2], [-1, 0, 1])], + [ + (-np.pi / 2, -1), + (0, 0), + (np.pi / 2, 1), + ([-np.pi / 2, 0, np.pi / 2], [-1, 0, 1]), # array + # with units + (-np.pi / 2 << u.rad, -1), + (0 << u.deg, 0), + (np.pi / 2 << u.rad, 1), + ([-np.pi / 2, 0, np.pi / 2] << u.rad, [-1, 0, 1]), # array + ], ) - def test__x_of_theta(self, theta, expected): - assert np.allclose(_x_of_theta(theta), expected) + def test_x_of_theta(self, theta, expected): + assert_allclose(x_of_theta(theta), expected, atol=1e-16) # /def - @pytest.mark.parametrize( - "theta, expected", - [(-np.pi / 2, -1), (0, 0), (np.pi / 2, 1), ([-np.pi / 2, 0, np.pi / 2], [-1, 0, 1])], - ) - def test_x_of_theta(self, theta, expected): - assert np.allclose(x_of_theta(theta << u.rad), expected) + @pytest.mark.parametrize("theta", [-np.pi / 2, 0, np.pi / 2, [-np.pi / 2, 0, np.pi / 2]]) + def test_roundtrip(self, theta): + """Test theta and x round trip. Note that Quantities don't round trip.""" + assert_allclose(theta_of_x(x_of_theta(theta << u.rad)), theta) # /def # /class - # ------------------------------------------------------------------- -class Test_thetaQls: - """Test `sample_scf.utils.x_of_theta`.""" +class Test_theta_of_x: + """Test `sample_scf.utils.theta_of_x`.""" + + @pytest.mark.parametrize( + "x, expected", + [ + (-1, -np.pi / 2), + (0, 0), + (1, np.pi / 2), + ([-1, 0, 1], [-np.pi / 2, 0, np.pi / 2]), # array + ], + ) + def test_theta_of_x(self, x, expected): + assert_allclose(theta_of_x(x), expected) + + # /def + + @pytest.mark.parametrize( + "x, expected, unit", + [ + (-1, -np.pi / 2, None), + (0, 0 * u.deg, u.deg), + (1, np.pi / 2 * u.rad, u.rad), + ], + ) + def test_unit_input(self, x, expected, unit): + """Test when input units.""" + assert_allclose(theta_of_x(x, unit=unit), expected) - def setup_class(self): - """Set up class.""" - Acos = np.zeros((5, 6, 6)) + # /def - Acos_hern = Acos.copy() - Acos_hern[0, 0, 0] = 1 - self.hernquist_pot = SCFPotential(Acos=Acos_hern) + @pytest.mark.parametrize("x", [-1, 0, 1, [-1, 0, 1]]) + def test_roundtrip(self, x): + """Test x and theta round trip. Note that Quantities don't round trip.""" + assert_allclose(x_of_theta(theta_of_x(x)), x, atol=1e-16) # /def + +# ------------------------------------------------------------------- + + +class Test_thetaQls: + """Test `sample_scf.utils.x_of_theta`.""" + # =============================================================== # Usage Tests @pytest.mark.parametrize("r, expected", [(0, 1), (1, 0.01989437), (np.inf, 0)]) - def test_hernquist(self, r, expected): - Qls = thetaQls(self.hernquist_pot, r=r) + def test_hernquist(self, hernquist_scf_potential, r, expected): + Qls = thetaQls(hernquist_scf_potential, r=r) + # shape should be L (see setup_class) assert len(Qls) == 6 + # only 1st index is non-zero assert np.isclose(Qls[0], expected) - assert np.allclose(Qls[1:], 0) + assert_allclose(Qls[1:], 0) # /def @pytest.mark.skip("TODO!") - def test_triaxialnfw(self): + def test_nfw(self, nfw_scf_potential): assert False # /def @@ -210,15 +252,38 @@ def test_triaxialnfw(self): class Test_phiRSms: """Test `sample_scf.utils.x_of_theta`.""" - @pytest.mark.skip("TODO!") - def test__phiRSms(self): - assert False - - # /def + # =============================================================== + # Tests - @pytest.mark.skip("TODO!") - def test_phiRSms(self): - assert False + # @pytest.mark.skip("TODO!") + @pytest.mark.parametrize( + "r, theta, expected", + [ + # show it doesn't depend on theta + (0, -np.pi / 2, (np.zeros(5), np.zeros(5))), + (0, 0, (np.zeros(5), np.zeros(5))), # special case when x=0 is 0 + (0, np.pi / 6, (np.zeros(5), np.zeros(5))), + (0, np.pi / 2, (np.zeros(5), np.zeros(5))), + # nor on r + (1, -np.pi / 2, (np.zeros(5), np.zeros(5))), + (10, -np.pi / 4, (np.zeros(5), np.zeros(5))), + (100, np.pi / 6, (np.zeros(5), np.zeros(5))), + (1000, np.pi / 2, (np.zeros(5), np.zeros(5))), + # Legendre[n=0, l=0, z=z] = 1 is a special case + (1, 0, (np.zeros(5), np.zeros(5))), + (10, 0, (np.zeros(5), np.zeros(5))), + (100, 0, (np.zeros(5), np.zeros(5))), + (1000, 0, (np.zeros(5), np.zeros(5))), + ], + ) + def test_phiRSms_hernquist(self, hernquist_scf_potential, r, theta, expected): + Rm, Sm = phiRSms(hernquist_scf_potential, r, theta) + assert_allclose(Rm[1:], expected[0], atol=1e-16) + assert_allclose(Sm[1:], expected[1], atol=1e-16) + + if theta == 0 and r != 0: + assert Rm[0] != 0 + assert Sm[0] == 0 # /def diff --git a/sample_scf/utils.py b/sample_scf/utils.py index fd31f03..a9a3cb9 100644 --- a/sample_scf/utils.py +++ b/sample_scf/utils.py @@ -170,7 +170,8 @@ def _(theta: u.Quantity) -> NDArray64: def theta_of_x( - x: npt.ArrayLike, unit: T.Optional[u.UnitBase] = None + x: npt.ArrayLike, + unit: T.Optional[u.UnitBase] = None, ) -> T.Union[NDArray64, u.Quantity]: r""":math:`\theta = \cos^{-1}{x}`. From d95cf47c6b7e4dce5a2e8329a6e301d81a6f987c Mon Sep 17 00:00:00 2001 From: "Nathaniel Starkman (@nstarman)" Date: Tue, 27 Jul 2021 00:09:52 -0400 Subject: [PATCH 12/31] prune imports Signed-off-by: Nathaniel Starkman (@nstarman) --- sample_scf/tests/test_utils.py | 1 - sample_scf/utils.py | 3 +-- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/sample_scf/tests/test_utils.py b/sample_scf/tests/test_utils.py index 86e289b..525bf0e 100644 --- a/sample_scf/tests/test_utils.py +++ b/sample_scf/tests/test_utils.py @@ -13,7 +13,6 @@ import astropy.units as u import numpy as np import pytest -from galpy.potential import SCFPotential from numpy.testing import assert_allclose # LOCAL diff --git a/sample_scf/utils.py b/sample_scf/utils.py index a9a3cb9..43acf38 100644 --- a/sample_scf/utils.py +++ b/sample_scf/utils.py @@ -25,7 +25,6 @@ arange, arccos, array, - asanyarray, atleast_1d, cos, divide, @@ -162,7 +161,7 @@ def _(theta: u.Quantity) -> NDArray64: ------- x : float or ndarray """ - x = NDArray64 = cos(pi / 2 - theta.to_value(u.rad)) + x: NDArray64 = cos(pi / 2 - theta.to_value(u.rad)) return x From 58cb75c8d328e138794f4bc50036a88f5562c934 Mon Sep 17 00:00:00 2001 From: "Nathaniel Starkman (@nstarman)" Date: Tue, 27 Jul 2021 00:14:20 -0400 Subject: [PATCH 13/31] isort Signed-off-by: Nathaniel Starkman (@nstarman) --- sample_scf/utils.py | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/sample_scf/utils.py b/sample_scf/utils.py index 43acf38..52cef67 100644 --- a/sample_scf/utils.py +++ b/sample_scf/utils.py @@ -21,19 +21,7 @@ import numpy as np import numpy.typing as npt from galpy.potential import SCFPotential -from numpy import ( - arange, - arccos, - array, - atleast_1d, - cos, - divide, - nan_to_num, - pi, - sqrt, - stack, - sum, -) +from numpy import arange, arccos, array, atleast_1d, cos, divide, nan_to_num, pi, sqrt, stack, sum from scipy.special import legendre, lpmn # LOCAL From 732deadbadfb014e3027c8db71b3d83cddf89f92 Mon Sep 17 00:00:00 2001 From: "Nathaniel Starkman (@nstarman)" Date: Tue, 27 Jul 2021 22:58:54 -0400 Subject: [PATCH 14/31] more tests Signed-off-by: Nathaniel Starkman (@nstarman) --- sample_scf/base.py | 11 +- sample_scf/conftest.py | 33 ++++-- sample_scf/core.py | 45 ++++++-- sample_scf/sample_exact.py | 3 +- sample_scf/sample_intrp.py | 2 +- sample_scf/tests/test_base.py | 154 +++++++++++++++++++++----- sample_scf/tests/test_sample_exact.py | 18 ++- sample_scf/tests/test_sample_intrp.py | 58 ++++++++-- 8 files changed, 268 insertions(+), 56 deletions(-) diff --git a/sample_scf/base.py b/sample_scf/base.py index a65e73f..5221be8 100644 --- a/sample_scf/base.py +++ b/sample_scf/base.py @@ -19,6 +19,7 @@ import numpy as np import numpy.typing as npt from astropy.coordinates import PhysicsSphericalRepresentation +from galpy.potential import SCFPotential from scipy._lib._util import check_random_state from scipy.stats import rv_continuous @@ -102,6 +103,14 @@ class SCFSamplerBase: pot : `galpy.potential.SCFPotential` """ + def __init__( + self, + pot: SCFPotential, + ): + self._pot = pot + + # /def + _rsampler: rv_continuous_modrvs _thetasampler: rv_continuous_modrvs _phisampler: rv_continuous_modrvs @@ -147,7 +156,7 @@ def cdf( (N, 3) ndarray """ R: NDArray64 = self.rsampler.cdf(r) - Theta: NDArray64 = self.thetasampler.cdf(theta=theta, r=r) + Theta: NDArray64 = self.thetasampler.cdf(theta, r=r) Phi: NDArray64 = self.phisampler.cdf(phi, r=r, theta=theta) RTP: NDArray64 = np.c_[R, Theta, Phi] diff --git a/sample_scf/conftest.py b/sample_scf/conftest.py index 363f4da..8a7273a 100644 --- a/sample_scf/conftest.py +++ b/sample_scf/conftest.py @@ -75,7 +75,7 @@ def pytest_configure(config): # Fixtures -@pytest.fixture(scope="session") +@pytest.fixture(autouse=True, scope="session") def hernquist_scf_potential(): """Make a SCF of a Hernquist potential.""" Acos = np.zeros((5, 6, 6)) @@ -90,10 +90,27 @@ def hernquist_scf_potential(): # /def -@pytest.fixture(scope="session") -def nfw_scf_potential(): - """Make a SCF of a triaxial NFW potential.""" - raise NotImplementedError("TODO") - - -# /def +# @pytest.fixture(autouse=True, scope="session") +# def nfw_scf_potential(): +# """Make a SCF of a triaxial NFW potential.""" +# raise NotImplementedError("TODO") +# +# +# # /def + + +@pytest.fixture( + # autouse=True, + scope="session", + params=[ + "hernquist_scf_potential", # TODO! use hernquist_scf_potential + ], +) +def potentials(request): + if request.param == "hernquist_scf_potential": + Acos = np.zeros((5, 6, 6)) + Acos_hern = Acos.copy() + Acos_hern[0, 0, 0] = 1 + potential = SCFPotential(Acos=Acos_hern) + + yield potential diff --git a/sample_scf/core.py b/sample_scf/core.py index 0fdf1c2..03e3aa6 100644 --- a/sample_scf/core.py +++ b/sample_scf/core.py @@ -19,11 +19,23 @@ from galpy.potential import SCFPotential # LOCAL -from .base import SCFSamplerBase +from .base import SCFSamplerBase, rv_continuous_modrvs +from .sample_exact import SCFSampler as SCFSamplerExact +from .sample_intrp import SCFSampler as SCFSamplerIntrp __all__: T.List[str] = ["SCFSampler"] +############################################################################## +# Parameters + + +class MethodsMapping(T.TypedDict): + r: rv_continuous_modrvs + theta: rv_continuous_modrvs + phi: rv_continuous_modrvs + + ############################################################################## # CODE ############################################################################## @@ -85,15 +97,32 @@ class SCFSampler(SCFSamplerBase): # metaclass=SCFSamplerSwitch def __init__( self, pot: SCFPotential, - method: T.Union[T.Literal["interp", "exact"], T.Mapping], + method: T.Union[T.Literal["interp", "exact"], MethodsMapping], **kwargs: T.Any ) -> None: - if not isinstance(method, Mapping): - raise NotImplementedError - - self._rsampler = method["r"](pot, **kwargs) - self._thetasampler = method["theta"](pot, **kwargs) - self._phisampler = method["phi"](pot, **kwargs) + super().__init__(pot) + + if isinstance(method, Mapping): + sampler = None + rsampler = method["r"](pot, **kwargs) + thetasampler = method["theta"](pot, **kwargs) + phisampler = method["phi"](pot, **kwargs) + else: + sampler_cls: rv_continuous_modrvs + if method == "interp": + sampler_cls = SCFSamplerIntrp + elif method == "exact": + sampler_cls = SCFSamplerExact + + sampler = sampler_cls(pot, **kwargs) + rsampler = self._sampler._rsampler + thetasampler = self._sampler._thetasampler + phisampler = self._sampler._phisampler + + self._sampler: T.Optional[SCFSamplerBase] = sampler + self._rsampler = rsampler + self._thetasampler = thetasampler + self._phisampler = phisampler # /def diff --git a/sample_scf/sample_exact.py b/sample_scf/sample_exact.py index 962de0e..5bd0e8b 100644 --- a/sample_scf/sample_exact.py +++ b/sample_scf/sample_exact.py @@ -19,7 +19,6 @@ import numpy as np import numpy.typing as npt from galpy.potential import SCFPotential -from scipy.stats import rv_continuous # LOCAL from ._typing import NDArray64, RandomLike @@ -72,7 +71,7 @@ def __init__(self, pot: SCFPotential, **kw: T.Any) -> None: # radial sampler -class SCFRSampler(rv_continuous): +class SCFRSampler(rv_continuous_modrvs): """Sample radial coordinate from an SCF potential. Parameters diff --git a/sample_scf/sample_intrp.py b/sample_scf/sample_intrp.py index a86482a..72aa056 100644 --- a/sample_scf/sample_intrp.py +++ b/sample_scf/sample_intrp.py @@ -270,7 +270,7 @@ def __init__( raise ValueError(f"Qls must be shape ({len(rgrid)}, {lmax})") # l = 0 : spherical symmetry - term0 = T.cast(npt.NDArray, 0.5 * (xs + 1)) # (T,) + term0 = T.cast(NDArray64, 0.5 * (xs + 1)) # (T,) # l = 1+ : non-symmetry factor = 1.0 / (2.0 * Qls[:, 0]) # (R,) term1p = np.sum( diff --git a/sample_scf/tests/test_base.py b/sample_scf/tests/test_base.py index 33bd3c3..04b78ed 100644 --- a/sample_scf/tests/test_base.py +++ b/sample_scf/tests/test_base.py @@ -7,28 +7,61 @@ # IMPORTS # THIRD PARTY -# import astropy.units as u -# import numpy as np +import astropy.coordinates as coord +import astropy.units as u +import numpy as np import pytest +from numpy.testing import assert_allclose # LOCAL from sample_scf import base -# from galpy.potential import SCFPotential - - ############################################################################## # TESTS ############################################################################## -class Test_rv_continuous_modrvs: +class testrvsampler(base.rv_continuous_modrvs): + def _cdf(self, x, *args, **kwargs): + return x + + # /def + + cdf = _cdf + + def _rvs(self, *args, size=None, random_state=None): + if random_state is None: + random_state = np.random + + return np.atleast_1d(random_state.uniform(size=size)) + + # /def + + +# /class + + +class Test_RVContinuousModRVS: """Test `sample_scf.base.rv_continuous_modrvs`.""" - @pytest.mark.skip("TODO!") - def test_rvs(self): + def setup_class(self): + self.sampler = testrvsampler() + + # /def + + # =============================================================== + + @pytest.mark.parametrize( + "size, random, expected", + [ + (None, 0, 0.5488135039273248), + (1, 2, 0.43599490214200376), + ((3, 1), 4, (0.9670298390136767, 0.5472322491757223, 0.9726843599648843)), + ], + ) + def test_rvs(self, size, random, expected): """Test :meth:`sample_scf.base.rv_continuous_modrvs.rvs`.""" - assert False + assert_allclose(self.sampler.rvs(size=size, random_state=random), expected, atol=1e-16) # /def @@ -42,40 +75,84 @@ def test_rvs(self): class Test_SCFSamplerBase: """Test :class:`sample_scf.base.SCFSamplerBase`.""" - _cls = base.SCFSamplerBase + def setup_class(self): + self.cls = base.SCFSamplerBase + self.cls_args = () - @pytest.mark.skip("TODO!") - def test_rsampler(self): + self.expected_rvs = { + 0: dict(r=0.548813503927, theta=1.021982822867 * u.rad, phi=0.548813503927 * u.rad), + 1: dict(r=0.548813503927, theta=1.021982822867 * u.rad, phi=0.548813503927 * u.rad), + 2: dict( + r=[0.9670298390136, 0.5472322491757, 0.9726843599648, 0.7148159936743], + theta=[0.603766487781, 1.023564077619, 0.598111966830, 0.855980333120] * u.rad, + phi=[0.9670298390136, 0.547232249175, 0.9726843599648, 0.7148159936743] * u.rad, + ), + } + + # /def + + @pytest.fixture(autouse=True, scope="class") + def sampler(self, potentials): + """Set up r, theta, phi sampler.""" + sampler = self.cls(potentials, *self.cls_args) + sampler._rsampler = testrvsampler() + sampler._thetasampler = testrvsampler() + sampler._phisampler = testrvsampler() + + return sampler + + # /def + + # =============================================================== + + def test_rsampler(self, sampler): """Test :meth:`sample_scf.base.SCFSamplerBase.rsampler`.""" - assert False + assert isinstance(sampler.rsampler, base.rv_continuous_modrvs) # /def - @pytest.mark.skip("TODO!") - def test_thetasampler(self): + def test_thetasampler(self, sampler): """Test :meth:`sample_scf.base.SCFSamplerBase.thetasampler`.""" - assert False + assert isinstance(sampler.thetasampler, base.rv_continuous_modrvs) # /def - @pytest.mark.skip("TODO!") - def test_phisampler(self): + def test_phisampler(self, sampler): """Test :meth:`sample_scf.base.SCFSamplerBase.phisampler`.""" - assert False + assert isinstance(sampler.phisampler, base.rv_continuous_modrvs) # /def - @pytest.mark.skip("TODO!") - def test_cdf(self): + @pytest.mark.parametrize( + "r, theta, phi, expected", + [ + (0, 0, 0, [0, 0, 0]), + (1, 0, 0, [1, 0, 0]), + ([0, 1], [0, 0], [0, 0], [[0, 0, 0], [1, 0, 0]]), + ], + ) + def test_cdf(self, sampler, r, theta, phi, expected): """Test :meth:`sample_scf.base.SCFSamplerBase.cdf`.""" - assert False + assert np.allclose(sampler.cdf(r, theta, phi), expected, atol=1e-16) # /def - @pytest.mark.skip("TODO!") - def test_rvs(self): + @pytest.mark.parametrize( + "id, size, random", + [ + (0, None, 0), + (1, 1, 0), + (2, 4, 4), + ], + ) + def test_rvs(self, sampler, id, size, random): """Test :meth:`sample_scf.base.SCFSamplerBase.rvs`.""" - assert False + samples = sampler.rvs(size=size, random_state=random) + sce = coord.PhysicsSphericalRepresentation(**self.expected_rvs[id]) + + assert_allclose(samples.r, sce.r, atol=1e-16) + assert_allclose(samples.theta.value, sce.theta.value, atol=1e-16) + assert_allclose(samples.phi.value, sce.phi.value, atol=1e-16) # /def @@ -83,5 +160,32 @@ def test_rvs(self): # /class +class SCFSamplerTestBase(Test_SCFSamplerBase): + def setup_class(self): + + self.expected_rvs = { + 0: dict(r=0.548813503927, theta=1.021982822867 * u.rad, phi=0.548813503927 * u.rad), + 1: dict(r=0.548813503927, theta=1.021982822867 * u.rad, phi=0.548813503927 * u.rad), + 2: dict( + r=[0.9670298390136, 0.5472322491757, 0.9726843599648, 0.7148159936743], + theta=[0.603766487781, 1.023564077619, 0.598111966830, 0.855980333120] * u.rad, + phi=[0.9670298390136, 0.547232249175, 0.9726843599648, 0.7148159936743] * u.rad, + ), + } + + # /def + + @pytest.fixture(autouse=True, scope="class") + def sampler(self, potentials): + """Set up r, theta, phi sampler.""" + sampler = self.cls(potentials, *self.cls_args) + + return sampler + + # /def + + +# /class + ############################################################################## # END diff --git a/sample_scf/tests/test_sample_exact.py b/sample_scf/tests/test_sample_exact.py index 38af02e..3fbdec2 100644 --- a/sample_scf/tests/test_sample_exact.py +++ b/sample_scf/tests/test_sample_exact.py @@ -28,7 +28,8 @@ # from sample_scf.sample_exact import SCFPhiSampler, SCFRSampler, SCFSampler, SCFThetaSampler from sample_scf.utils import zeta_of_r # x_of_theta -# import time +# from .test_base import SCFSamplerTestBase +# from sample_scf import sample_exact ############################################################################## @@ -60,6 +61,21 @@ ############################################################################## +# class Test_SCFSampler(SCFSamplerTestBase): +# """Test :class:`sample_scf.sample_intrp.SCFSampler`.""" +# +# def setup_class(self): +# super().setup_class() +# +# self.cls = sample_exact.SCFSampler +# self.cls_args = () +# +# # /def +# +# +# # /class + + # class Test_SCFRSampler: # def setup_class(self): # self.sampler = SCFRSampler(m, r) diff --git a/sample_scf/tests/test_sample_intrp.py b/sample_scf/tests/test_sample_intrp.py index d0530a6..9de02e5 100644 --- a/sample_scf/tests/test_sample_intrp.py +++ b/sample_scf/tests/test_sample_intrp.py @@ -7,15 +7,22 @@ # IMPORTS # THIRD PARTY -# import astropy.units as u -# import numpy as np +import astropy.coordinates as coord +import numpy as np import pytest +from numpy.testing import assert_allclose # LOCAL -from .test_base import Test_rv_continuous_modrvs, Test_SCFSamplerBase +from .test_base import SCFSamplerTestBase +from .test_base import Test_RVContinuousModRVS as RVContinuousModRVSTest from sample_scf import sample_intrp -# from galpy.potential import SCFPotential +############################################################################## +# PARAMETERS + +rgrid = np.geomspace(1e-1, 1e3, 100) +tgrid = np.linspace(-np.pi / 2, np.pi / 2, 30) +pgrid = np.linspace(0, 2 * np.pi, 30) ############################################################################## @@ -23,11 +30,35 @@ ############################################################################## -@pytest.mark.skip("TODO!") -class Test_SCFSampler(Test_SCFSamplerBase): +class Test_SCFSampler(SCFSamplerTestBase): """Test :class:`sample_scf.sample_intrp.SCFSampler`.""" - _cls = sample_intrp.SCFSampler + def setup_class(self): + super().setup_class(self) + + self.cls = sample_intrp.SCFSampler + self.cls_args = (rgrid, tgrid, pgrid) + + # /def + + @pytest.mark.skip("TODO!") + def test_cdf(self, sampler, r, theta, phi, expected): + """Test :meth:`sample_scf.base.SCFSamplerBase.cdf`.""" + assert np.allclose(sampler.cdf(r, theta, phi), expected, atol=1e-16) + + # /def + + @pytest.mark.skip("TODO!") + def test_rvs(self, sampler, id, size, random): + """Test :meth:`sample_scf.base.SCFSamplerBase.rvs`.""" + samples = sampler.rvs(size=size, random_state=random) + sce = coord.PhysicsSphericalRepresentation(**self.expected_rvs[id]) + + assert_allclose(samples.r, sce.r, atol=1e-16) + assert_allclose(samples.theta.value, sce.theta.value, atol=1e-16) + assert_allclose(samples.phi.value, sce.phi.value, atol=1e-16) + + # /def # /class @@ -35,7 +66,7 @@ class Test_SCFSampler(Test_SCFSamplerBase): # ------------------------------------------------------------------- -class Test_SCFRSampler(Test_rv_continuous_modrvs): +class Test_SCFRSampler(RVContinuousModRVSTest): """Test :class:`sample_scf.`""" # =============================================================== @@ -62,6 +93,13 @@ def test__ppf(self): # /def + @pytest.mark.skip("TODO!") + def test_rvs(self): + """Test :meth:`sample_scf.sample_intrp.SCFRSampler.rvs`.""" + assert False + + # /def + # =============================================================== # Usage Tests @@ -71,7 +109,7 @@ def test__ppf(self): # ------------------------------------------------------------------- -class Test_SCFThetaSampler(Test_rv_continuous_modrvs): +class Test_SCFThetaSampler(RVContinuousModRVSTest): """Test :class:`sample_scf.sample_intrp.SCFThetaSampler`.""" # =============================================================== @@ -128,7 +166,7 @@ def test_rvs(self): # ------------------------------------------------------------------- -class Test_SCFPhiSampler(Test_rv_continuous_modrvs): +class Test_SCFPhiSampler(RVContinuousModRVSTest): """Test :class:`sample_scf.sample_intrp.SCFPhiSampler`.""" # =============================================================== From 08eb25c42d1a64b03dd3c0a9c3e2c988b5f2ae9b Mon Sep 17 00:00:00 2001 From: "Nathaniel Starkman (@nstarman)" Date: Thu, 29 Jul 2021 12:54:24 -0400 Subject: [PATCH 15/31] test core Signed-off-by: Nathaniel Starkman (@nstarman) --- sample_scf/tests/test_base.py | 17 +++++++++++++++-- sample_scf/tests/test_core.py | 4 ---- sample_scf/tests/test_init.py | 8 ++++++++ 3 files changed, 23 insertions(+), 6 deletions(-) diff --git a/sample_scf/tests/test_base.py b/sample_scf/tests/test_base.py index 04b78ed..89e4e3c 100644 --- a/sample_scf/tests/test_base.py +++ b/sample_scf/tests/test_base.py @@ -11,6 +11,7 @@ import astropy.units as u import numpy as np import pytest +from astropy.utils.misc import NumpyRNGContext from numpy.testing import assert_allclose # LOCAL @@ -57,11 +58,17 @@ def setup_class(self): (None, 0, 0.5488135039273248), (1, 2, 0.43599490214200376), ((3, 1), 4, (0.9670298390136767, 0.5472322491757223, 0.9726843599648843)), + ((3, 1), None, (0.9670298390136767, 0.5472322491757223, 0.9726843599648843)), ], ) def test_rvs(self, size, random, expected): - """Test :meth:`sample_scf.base.rv_continuous_modrvs.rvs`.""" - assert_allclose(self.sampler.rvs(size=size, random_state=random), expected, atol=1e-16) + """Test :meth:`sample_scf.base.rv_continuous_modrvs.rvs`. + + The ``NumpyRNGContext`` is to control the random generator used to make + the RandomState. For ``random != None``, this doesn't matter. + """ + with NumpyRNGContext(4): + assert_allclose(self.sampler.rvs(size=size, random_state=random), expected, atol=1e-16) # /def @@ -156,6 +163,12 @@ def test_rvs(self, sampler, id, size, random): # /def + # =============================================================== + # Time Scaling Tests + + # =============================================================== + # Image tests + # /class diff --git a/sample_scf/tests/test_core.py b/sample_scf/tests/test_core.py index 5627f03..8a2e67c 100644 --- a/sample_scf/tests/test_core.py +++ b/sample_scf/tests/test_core.py @@ -6,9 +6,6 @@ ############################################################################## # IMPORTS -# THIRD PARTY -import pytest - # LOCAL from .test_base import Test_SCFSamplerBase from sample_scf import core @@ -18,7 +15,6 @@ ############################################################################## -@pytest.mark.skip("TODO!") class Test_SCFSampler(Test_SCFSamplerBase): """Test :class:`sample_scf.core.SCFSample`.""" diff --git a/sample_scf/tests/test_init.py b/sample_scf/tests/test_init.py index 5e10999..3a61bc5 100644 --- a/sample_scf/tests/test_init.py +++ b/sample_scf/tests/test_init.py @@ -22,8 +22,16 @@ def test_expected_imports(): """Test can import expected modules and objects.""" # LOCAL import sample_scf + from sample_scf import core, sample_exact, sample_intrp assert inspect.ismodule(sample_scf) + assert inspect.ismodule(core) + assert inspect.ismodule(sample_exact) + assert inspect.ismodule(sample_intrp) + + assert sample_scf.SCFSampler is core.SCFSampler + assert sample_scf.SCFSamplerExact is sample_exact.SCFSampler + assert sample_scf.SCFSamplerInterp is sample_intrp.SCFSampler # /def From 34fb8fc50a8bee3b8453cc5ab812b0a7f26d6dea Mon Sep 17 00:00:00 2001 From: "Nathaniel Starkman (@nstarman)" Date: Thu, 29 Jul 2021 15:37:39 -0400 Subject: [PATCH 16/31] rename to rv_potential Signed-off-by: Nathaniel Starkman (@nstarman) --- sample_scf/base.py | 52 +++++- sample_scf/conftest.py | 3 +- sample_scf/core.py | 10 +- sample_scf/sample_exact.py | 45 ++--- sample_scf/sample_intrp.py | 103 ++++------- sample_scf/tests/test_base.py | 80 ++++++-- sample_scf/tests/test_sample_exact.py | 3 + sample_scf/tests/test_sample_intrp.py | 251 +++++++++++++++++++++++--- 8 files changed, 402 insertions(+), 145 deletions(-) diff --git a/sample_scf/base.py b/sample_scf/base.py index 5221be8..f872158 100644 --- a/sample_scf/base.py +++ b/sample_scf/base.py @@ -34,18 +34,54 @@ ############################################################################## -class rv_continuous_modrvs(rv_continuous): +class rv_potential(rv_continuous): """ Modified :class:`scipy.stats.rv_continuous` to use custom rvs methods. Made by stripping down the original scipy implementation. See :class:`scipy.stats.rv_continuous` for details. """ + def __init__( + self, + potential: SCFPotential, + momtype: int = 1, + a: float = None, + b: float = None, + xtol: float = 1e-14, + badvalue: T.Optional[float] = None, + name: str = None, + longname: str = None, + shapes: tuple = None, + extradoc: str = None, + seed: int = None, + ): + super().__init__( + momtype=momtype, + a=a, + b=b, + xtol=xtol, + badvalue=badvalue, + name=name, + longname=longname, + shapes=shapes, + extradoc=extradoc, + seed=seed, + ) + + if not isinstance(potential, SCFPotential): + raise TypeError( + f"potential must be , not {type(potential)}", + ) + self._potential: SCFPotential = potential + self._nmax, self._lmax = potential._Acos.shape[:2] + + # /def + def rvs( self, *args: T.Union[float, npt.ArrayLike], size: T.Optional[int] = None, - random_state: RandomLike = None + random_state: RandomLike = None, ) -> NDArray64: """Random variate sampler. @@ -111,26 +147,26 @@ def __init__( # /def - _rsampler: rv_continuous_modrvs - _thetasampler: rv_continuous_modrvs - _phisampler: rv_continuous_modrvs + _rsampler: rv_potential + _thetasampler: rv_potential + _phisampler: rv_potential @property - def rsampler(self) -> rv_continuous_modrvs: + def rsampler(self) -> rv_potential: """Radial coordinate sampler.""" return self._rsampler # /def @property - def thetasampler(self) -> rv_continuous_modrvs: + def thetasampler(self) -> rv_potential: """Inclination coordinate sampler.""" return self._thetasampler # /def @property - def phisampler(self) -> rv_continuous_modrvs: + def phisampler(self) -> rv_potential: """Azimuthal coordinate sampler.""" return self._phisampler diff --git a/sample_scf/conftest.py b/sample_scf/conftest.py index 8a7273a..2f3540b 100644 --- a/sample_scf/conftest.py +++ b/sample_scf/conftest.py @@ -104,10 +104,11 @@ def hernquist_scf_potential(): scope="session", params=[ "hernquist_scf_potential", # TODO! use hernquist_scf_potential + "other_hernquist_scf_potential", ], ) def potentials(request): - if request.param == "hernquist_scf_potential": + if request.param in ("hernquist_scf_potential", "other_hernquist_scf_potential"): Acos = np.zeros((5, 6, 6)) Acos_hern = Acos.copy() Acos_hern[0, 0, 0] = 1 diff --git a/sample_scf/core.py b/sample_scf/core.py index 03e3aa6..b6a1b5c 100644 --- a/sample_scf/core.py +++ b/sample_scf/core.py @@ -19,7 +19,7 @@ from galpy.potential import SCFPotential # LOCAL -from .base import SCFSamplerBase, rv_continuous_modrvs +from .base import SCFSamplerBase, rv_potential from .sample_exact import SCFSampler as SCFSamplerExact from .sample_intrp import SCFSampler as SCFSamplerIntrp @@ -31,9 +31,9 @@ class MethodsMapping(T.TypedDict): - r: rv_continuous_modrvs - theta: rv_continuous_modrvs - phi: rv_continuous_modrvs + r: rv_potential + theta: rv_potential + phi: rv_potential ############################################################################## @@ -108,7 +108,7 @@ def __init__( thetasampler = method["theta"](pot, **kwargs) phisampler = method["phi"](pot, **kwargs) else: - sampler_cls: rv_continuous_modrvs + sampler_cls: rv_potential if method == "interp": sampler_cls = SCFSamplerIntrp elif method == "exact": diff --git a/sample_scf/sample_exact.py b/sample_scf/sample_exact.py index 5bd0e8b..3897452 100644 --- a/sample_scf/sample_exact.py +++ b/sample_scf/sample_exact.py @@ -22,7 +22,7 @@ # LOCAL from ._typing import NDArray64, RandomLike -from .base import SCFSamplerBase, rv_continuous_modrvs +from .base import SCFSamplerBase, rv_potential from .utils import difPls, phiRSms, thetaQls, x_of_theta __all__: T.List[str] = ["SCFSampler", "SCFRSampler", "SCFThetaSampler", "SCFPhiSampler"] @@ -71,7 +71,7 @@ def __init__(self, pot: SCFPotential, **kw: T.Any) -> None: # radial sampler -class SCFRSampler(rv_continuous_modrvs): +class SCFRSampler(rv_potential): """Sample radial coordinate from an SCF potential. Parameters @@ -82,9 +82,9 @@ class SCFRSampler(rv_continuous_modrvs): Not used. """ - def __init__(self, pot: SCFPotential, **kw: T.Any) -> None: - super().__init__(a=0, b=np.inf) # allowed range of r - self._pot = pot + def __init__(self, potential: SCFPotential, **kw: T.Any) -> None: + kw["a"], kw["b"] = 0, np.inf # allowed range of r + super().__init__(potential, **kw) # /def @@ -103,10 +103,10 @@ def _cdf(self, r: npt.ArrayLike, *args: T.Any, **kw: T.Any) -> NDArray64: # inclination sampler -class SCFThetaSamplerBase(rv_continuous_modrvs): +class SCFThetaSamplerBase(rv_potential): def __new__( cls: T.Type[TSCFThetaSamplerBase], - pot: SCFPotential, + potential: SCFPotential, r: T.Optional[float] = None, **kw: T.Any ) -> TSCFThetaSamplerBase: @@ -118,13 +118,9 @@ def __new__( # /def - def __init__(self, pot: SCFPotential, **kw: T.Any) -> None: - super().__init__(a=-np.pi / 2, b=np.pi / 2) # allowed range of theta - - # parse from potential - self._pot = pot - # shape parameters - self._nmax, self._lmax = pot._Acos.shape[:2] + def __init__(self, potential: SCFPotential, **kw: T.Any) -> None: + kw["a"], kw["b"] = -np.pi / 2, np.pi / 2 # allowed range of theta + super().__init__(potential, **kw) self._lrange = np.arange(0, self._lmax + 1) # lmax inclusive # /def @@ -173,8 +169,8 @@ class SCFThetaSampler(SCFThetaSamplerBase): Not used. """ - def __init__(self, pot: SCFPotential, r: float, **kw: T.Any) -> None: - super().__init__(pot) + def __init__(self, potential: SCFPotential, r: float, **kw: T.Any) -> None: + super().__init__(potential) # points at which CDF is defined self._r = r @@ -255,10 +251,10 @@ def rvs( # type: ignore # azimuth sampler -class SCFPhiSamplerBase(rv_continuous_modrvs): +class SCFPhiSamplerBase(rv_potential): def __new__( cls: T.Type[TSCFPhi], - pot: SCFPotential, + potential: SCFPotential, r: T.Optional[float] = None, theta: T.Optional[float] = None, **kw: T.Any @@ -271,12 +267,9 @@ def __new__( # /def - def __init__(self, pot: SCFPotential, **kw: T.Any) -> None: - super().__init__(a=0, b=2 * np.pi) - - self._pot = pot - # shape parameters - self._nmax, self._lmax = pot._Acos.shape[:2] + def __init__(self, potential: SCFPotential, **kw: T.Any) -> None: + kw["a"], kw["b"] = 0, 2 * np.pi + super().__init__(potential, **kw) self._lrange = np.arange(0, self._lmax + 1) # /def @@ -301,8 +294,8 @@ class SCFPhiSampler(SCFPhiSamplerBase): """ - def __init__(self, pot: SCFPotential, r: float, theta: float, **kw: T.Any) -> None: - super().__init__(pot) + def __init__(self, potential: SCFPotential, r: float, theta: float, **kw: T.Any) -> None: + super().__init__(potential, **kw) self._r, self._theta = r, theta self._Rm, self._Sm = self.RSms(float(r), float(theta)) diff --git a/sample_scf/sample_intrp.py b/sample_scf/sample_intrp.py index 72aa056..66e8166 100644 --- a/sample_scf/sample_intrp.py +++ b/sample_scf/sample_intrp.py @@ -31,7 +31,7 @@ # LOCAL from ._typing import NDArray64, RandomLike -from .base import SCFSamplerBase, rv_continuous_modrvs +from .base import SCFSamplerBase, rv_potential from .utils import _phiRSms, difPls, phiRSms, r_of_zeta, thetaQls, x_of_theta, zeta_of_r __all__: T.List[str] = ["SCFSampler", "SCFRSampler", "SCFThetaSampler", "SCFPhiSampler"] @@ -150,34 +150,29 @@ def __init__( # radial sampler -class SCFRSampler(rv_continuous_modrvs): +class SCFRSampler(rv_potential): """Sample radial coordinate from an SCF potential. The potential must have a convergent mass function. Parameters ---------- - pot : `~galpy.potential.SCFPotential` or ndarray - The mass enclosed in a spherical volume of radius(ii) 'rgrid', or the - a potential that can be used to calculate the enclosed mass. + potential : `galpy.potential.SCFPotential` rgrid : ndarray **kw - not used + Passed to `scipy.stats.rv_continuous` + "a", "b" are set to [0, inf] """ - def __init__(self, pot: SCFPotential, rgrid: NDArray64, **kw: T.Any) -> None: - super().__init__(a=0, b=np.inf) # allowed range of r + def __init__(self, potential: SCFPotential, rgrid: NDArray64, **kw: T.Any) -> None: + kw["a"], kw["b"] = 0, np.inf # allowed range of r + super().__init__(potential, **kw) - if isinstance(pot, np.ndarray): - mgrid = pot - elif isinstance(pot, SCFPotential): # todo! generalize over potential - mgrid = np.array([pot._mass(x) for x in rgrid]) # :( - # manual fixes for endpoints - ind = np.where(np.isnan(mgrid))[0] - mgrid[ind[rgrid[ind] == 0]] = 0 - mgrid[ind[rgrid[ind] == np.inf]] = 1 - else: - raise TypeError + mgrid = np.array([potential._mass(x) for x in rgrid]) # :( + # manual fixes for endpoints + ind = np.where(np.isnan(mgrid))[0] + mgrid[ind[rgrid[ind] == 0]] = 0 + mgrid[ind[rgrid[ind] == np.inf]] = 1 # work in zeta, not r, since it is more numerically stable zeta = zeta_of_r(rgrid) @@ -222,7 +217,7 @@ def _ppf(self, q: npt.ArrayLike, *args: T.Any, **kw: T.Any) -> NDArray64: # inclination sampler -class SCFThetaSampler(rv_continuous_modrvs): +class SCFThetaSampler(rv_potential): """ Sample inclination coordinate from an SCF potential. @@ -230,28 +225,27 @@ class SCFThetaSampler(rv_continuous_modrvs): ---------- pot : `~galpy.potential.SCFPotential` rgrid, tgrid : ndarray - + **kw + Passed to `scipy.stats.rv_continuous` + "a", "b" are set to [-pi/2, pi/2] """ def __init__( self, - pot: SCFPotential, + potential: SCFPotential, rgrid: NDArray64, tgrid: NDArray64, intrp_step: float = 0.01, **kw: T.Any, ) -> None: - super().__init__(a=-np.pi / 2, b=np.pi / 2) # allowed range of theta + kw["a"], kw["b"] = -np.pi / 2, np.pi / 2 + Qls: NDArray64 = kw.pop("Qls", None) + super().__init__(potential, **kw) # allowed range of theta self._theta_interpolant = np.arange(-np.pi / 2, np.pi / 2, intrp_step) self._x_interpolant = x_of_theta(self._theta_interpolant) self._q_interpolant = np.linspace(0, 1, len(self._theta_interpolant)) - # ------- - # parse from potential - - self._pot = pot - self._nmax, self._lmax = (nmax, lmax) = pot._Acos.shape[:2] self._lrange = np.arange(0, self._lmax + 1) # ------- @@ -261,20 +255,17 @@ def __init__( zetas = zeta_of_r(rgrid) # (R,) xs = x_of_theta(tgrid) # (T,) - if "Qls" in kw: - Qls: NDArray64 = kw["Qls"] - else: - Qls = thetaQls(pot, rgrid) + Qls = Qls if Qls is not None else thetaQls(potential, rgrid) # check it's the right shape (R, Lmax) - if Qls.shape != (len(rgrid), lmax): - raise ValueError(f"Qls must be shape ({len(rgrid)}, {lmax})") + if Qls.shape != (len(rgrid), self._lmax): + raise ValueError(f"Qls must be shape ({len(rgrid)}, {self._lmax})") # l = 0 : spherical symmetry term0 = T.cast(NDArray64, 0.5 * (xs + 1)) # (T,) # l = 1+ : non-symmetry factor = 1.0 / (2.0 * Qls[:, 0]) # (R,) term1p = np.sum( - (Qls[None, :, 1:] * difPls(xs, lmax - 1).T[:, None, :]).T, + (Qls[None, :, 1:] * difPls(xs, self._lmax - 1).T[:, None, :]).T, axis=0, ) @@ -313,14 +304,8 @@ def __init__( # /def - def _cdf( - self, - x: npt.ArrayLike, - *args: T.Any, - zeta: npt.ArrayLike, - grid: bool = False, - ) -> NDArray64: - cdf: NDArray64 = self._spl_cdf(zeta, x, grid=grid) + def _cdf(self, x: npt.ArrayLike, *args: T.Any, zeta: npt.ArrayLike, **kw: T.Any) -> NDArray64: + cdf: NDArray64 = self._spl_cdf(zeta, x, grid=False) return cdf # /def @@ -348,7 +333,6 @@ def _ppf( q: npt.ArrayLike, *, r: npt.ArrayLike, - grid: bool = False, **kw: T.Any, ) -> NDArray64: """Percent-point function. @@ -363,7 +347,7 @@ def _ppf( float or (N,) array-like[float] Same shape as 'r', 'q'. """ - ppf: NDArray64 = self._spl_ppf(zeta_of_r(r), q, grid=grid) + ppf: NDArray64 = self._spl_ppf(zeta_of_r(r), q, grid=False) return ppf # /def @@ -424,7 +408,7 @@ def rvs( # type: ignore # Azimuth sampler -class SCFPhiSampler(rv_continuous_modrvs): +class SCFPhiSampler(rv_potential): """SCF phi sampler. .. todo:: @@ -433,36 +417,33 @@ class SCFPhiSampler(rv_continuous_modrvs): Parameters ---------- - pot : `galpy.potential.SCFPotential` + potential : `galpy.potential.SCFPotential` rgrid : ndarray[float] tgrid : ndarray[float] pgrid : ndarray[float] intrp_step : float, optional **kw - Not used + Passed to `scipy.stats.rv_continuous` + "a", "b" are set to [0, 2 pi] """ def __init__( self, - pot: SCFPotential, + potential: SCFPotential, rgrid: NDArray64, tgrid: NDArray64, pgrid: NDArray64, intrp_step: float = 0.01, **kw: T.Any, ) -> None: - super().__init__(a=0, b=2 * np.pi) # allowed range of r + kw["a"], kw["b"] = 0, 2 * np.pi + (Rm, Sm) = kw.pop("RSms", (None, None)) + super().__init__(potential, **kw) # allowed range of r self._phi_interpolant = np.arange(0, 2 * np.pi, intrp_step) self._ninterpolant = len(self._phi_interpolant) self._q_interpolant = qarr = np.linspace(0, 1, self._ninterpolant) - # ------- - # parse from potential - - self._pot = pot - self._nmax, self._lmax = (nmax, lmax) = pot._Acos.shape[:2] - # ------- # build CDF @@ -473,13 +454,10 @@ def __init__( Phis = pgrid[None, None, :, None] # ({R}, {T}, P, {L}) - if "RSms" in kw: - (Rm, Sm) = kw["RSms"] - else: - (Rm, Sm) = phiRSms(pot, rgrid, tgrid) # (R, T, L) + Rm, Sm = (Rm, Sm) if Rm is not None else phiRSms(potential, rgrid, tgrid) # (R, T, L) # check it's the right shape - if (Rm.shape != Sm.shape) or (Rm.shape != (lR, lT, lmax)): - raise ValueError(f"Rm, Sm must be shape ({lR}, {lT}, {lmax})") + if (Rm.shape != Sm.shape) or (Rm.shape != (lR, lT, self._lmax)): + raise ValueError(f"Rm, Sm must be shape ({lR}, {lT}, {self._lmax})") # l = 0 : spherical symmetry term0 = pgrid[None, None, :] / (2 * np.pi) # (1, 1, P) @@ -488,7 +466,7 @@ def __init__( warnings.simplefilter("ignore") factor = 1 / Rm[:, :, :1] # R0 (R, T, 1) # can be inf - ms = np.arange(1, lmax)[None, None, None, :] # (1, 1, 1, L) + ms = np.arange(1, self._lmax)[None, None, None, :] # (1, 1, 1, L) term1p = np.sum( (Rm[:, :, None, 1:] * np.sin(ms * Phis) + Sm[:, :, None, 1:] * (1 - np.cos(ms * Phis))) / (2 * np.pi * ms), @@ -559,7 +537,6 @@ def _ppf( *args: T.Any, r: npt.ArrayLike, theta: NDArray64, - grid: bool = False, **kw: T.Any, ) -> NDArray64: ppf: NDArray64 = self._spl_ppf((zeta_of_r(r), x_of_theta(theta), q)) diff --git a/sample_scf/tests/test_base.py b/sample_scf/tests/test_base.py index 89e4e3c..8a5d031 100644 --- a/sample_scf/tests/test_base.py +++ b/sample_scf/tests/test_base.py @@ -6,6 +6,9 @@ ############################################################################## # IMPORTS +# BUILT-IN +import time + # THIRD PARTY import astropy.coordinates as coord import astropy.units as u @@ -22,7 +25,9 @@ ############################################################################## -class testrvsampler(base.rv_continuous_modrvs): +class rvtestsampler(base.rv_potential): + """A sampler for testing the modified ``rv_continuous`` base class.""" + def _cdf(self, x, *args, **kwargs): return x @@ -42,15 +47,29 @@ def _rvs(self, *args, size=None, random_state=None): # /class -class Test_RVContinuousModRVS: - """Test `sample_scf.base.rv_continuous_modrvs`.""" +class Test_RVPotential: + """Test `sample_scf.base.rv_potential`.""" def setup_class(self): - self.sampler = testrvsampler() + self.cls = rvtestsampler + self.cls_args = () + + self.cdf_time_scale = 0 + self.rvs_time_scale = 0 + + # /def + + @pytest.fixture(autouse=True, scope="class") + def sampler(self, potentials): + """Set up r, theta, or phi sampler.""" + sampler = self.cls(potentials, *self.cls_args) + + return sampler # /def # =============================================================== + # Method Tests @pytest.mark.parametrize( "size, random, expected", @@ -61,22 +80,53 @@ def setup_class(self): ((3, 1), None, (0.9670298390136767, 0.5472322491757223, 0.9726843599648843)), ], ) - def test_rvs(self, size, random, expected): - """Test :meth:`sample_scf.base.rv_continuous_modrvs.rvs`. + def test_rvs(self, sampler, size, random, expected): + """Test :meth:`sample_scf.base.rv_potential.rvs`. The ``NumpyRNGContext`` is to control the random generator used to make the RandomState. For ``random != None``, this doesn't matter. """ with NumpyRNGContext(4): - assert_allclose(self.sampler.rvs(size=size, random_state=random), expected, atol=1e-16) + assert_allclose(sampler.rvs(size=size, random_state=random), expected, atol=1e-16) + + # /def + + # =============================================================== + # Time Scaling Tests + + # TODO! generalize for subclasses + @pytest.mark.parametrize("size", [1, 10, 100, 1000, 10000]) + def test_cdf_time_scaling(self, sampler, size): + """Test that the time scales as X * size""" + x = np.linspace(0, 1e4, size) + tic = time.perf_counter() + sampler.cdf(x) + toc = time.perf_counter() + + assert (toc - tic) < self.cdf_time_scale * size # linear scaling + + # /def + + # TODO! generalize for subclasses + @pytest.mark.parametrize("size", [1, 10, 100, 1000, 10000]) + def test_rvs_time_scaling(self, sampler, size): + """Test that the time scales as X * size""" + tic = time.perf_counter() + sampler.rvs(size=size) + toc = time.perf_counter() + + assert (toc - tic) < self.rvs_time_scale * size # linear scaling # /def + # =============================================================== + # Image tests + # /class -# ------------------------------------------------------------------- +############################################################################## class Test_SCFSamplerBase: @@ -100,11 +150,11 @@ def setup_class(self): @pytest.fixture(autouse=True, scope="class") def sampler(self, potentials): - """Set up r, theta, phi sampler.""" + """Set up r, theta, & phi sampler.""" sampler = self.cls(potentials, *self.cls_args) - sampler._rsampler = testrvsampler() - sampler._thetasampler = testrvsampler() - sampler._phisampler = testrvsampler() + sampler._rsampler = rvtestsampler() + sampler._thetasampler = rvtestsampler() + sampler._phisampler = rvtestsampler() return sampler @@ -114,19 +164,19 @@ def sampler(self, potentials): def test_rsampler(self, sampler): """Test :meth:`sample_scf.base.SCFSamplerBase.rsampler`.""" - assert isinstance(sampler.rsampler, base.rv_continuous_modrvs) + assert isinstance(sampler.rsampler, base.rv_potential) # /def def test_thetasampler(self, sampler): """Test :meth:`sample_scf.base.SCFSamplerBase.thetasampler`.""" - assert isinstance(sampler.thetasampler, base.rv_continuous_modrvs) + assert isinstance(sampler.thetasampler, base.rv_potential) # /def def test_phisampler(self, sampler): """Test :meth:`sample_scf.base.SCFSamplerBase.phisampler`.""" - assert isinstance(sampler.phisampler, base.rv_continuous_modrvs) + assert isinstance(sampler.phisampler, base.rv_potential) # /def diff --git a/sample_scf/tests/test_sample_exact.py b/sample_scf/tests/test_sample_exact.py index 3fbdec2..624a8e3 100644 --- a/sample_scf/tests/test_sample_exact.py +++ b/sample_scf/tests/test_sample_exact.py @@ -76,6 +76,9 @@ # # /class +# ============================================================================ + + # class Test_SCFRSampler: # def setup_class(self): # self.sampler = SCFRSampler(m, r) diff --git a/sample_scf/tests/test_sample_intrp.py b/sample_scf/tests/test_sample_intrp.py index 9de02e5..15bca13 100644 --- a/sample_scf/tests/test_sample_intrp.py +++ b/sample_scf/tests/test_sample_intrp.py @@ -6,6 +6,9 @@ ############################################################################## # IMPORTS +# BUILT-IN +import time + # THIRD PARTY import astropy.coordinates as coord import numpy as np @@ -14,13 +17,14 @@ # LOCAL from .test_base import SCFSamplerTestBase -from .test_base import Test_RVContinuousModRVS as RVContinuousModRVSTest +from .test_base import Test_RVPotential as RVPotentialTest from sample_scf import sample_intrp +from sample_scf.utils import r_of_zeta, zeta_of_r ############################################################################## # PARAMETERS -rgrid = np.geomspace(1e-1, 1e3, 100) +rgrid = np.concatenate(([0], np.geomspace(1e-1, 1e3, 100))) tgrid = np.linspace(-np.pi / 2, np.pi / 2, 30) pgrid = np.linspace(0, 2 * np.pi, 30) @@ -63,40 +67,141 @@ def test_rvs(self, sampler, id, size, random): # /class -# ------------------------------------------------------------------- +############################################################################## + + +class InterpRVPotentialTest(RVPotentialTest): + def test___init__(self, sampler): + """Test initialization.""" + potential = sampler._potential + + assert hasattr(sampler, "_spl_cdf") + assert hasattr(sampler, "_spl_ppf") + + # good + newsampler = self.cls(potential, *self.cls_args) + + cdfk = sampler._spl_cdf.get_knots() + ncdfk = newsampler._spl_cdf.get_knots() + if isinstance(cdfk, np.ndarray): # 1D splines + assert_allclose(ncdfk, cdfk, atol=1e-16) + else: # 2D and 3D splines + for k, nk in zip(cdfk, ncdfk): + assert_allclose(k, nk, atol=1e-16) + + ppfk = sampler._spl_ppf.get_knots() + nppfk = newsampler._spl_ppf.get_knots() + if isinstance(ppfk, np.ndarray): # 1D splines + assert_allclose(nppfk, ppfk, atol=1e-16) + else: # 2D and 3D splines + for k, nk in zip(ppfk, nppfk): + assert_allclose(k, nk, atol=1e-16) + + # bad + with pytest.raises(TypeError, match="SCFPotential"): + self.cls(None, *self.cls_args) + + # /def + + +# /class + +# ---------------------------------------------------------------------------- -class Test_SCFRSampler(RVContinuousModRVSTest): + +class Test_SCFRSampler(InterpRVPotentialTest): """Test :class:`sample_scf.`""" + def setup_class(self): + self.cls = sample_intrp.SCFRSampler + self.cls_args = (rgrid,) + + self.cdf_time_scale = 6e-4 # milliseconds + self.rvs_time_scale = 2e-4 # milliseconds + + # /def + # =============================================================== # Method Tests - @pytest.mark.skip("TODO!") - def test___init__(self): + def test___init__(self, sampler): + """Test initialization.""" + super().test___init__(sampler) + + # TODO! test mgrid endpoints, cdf, and ppf + + # /def + + # TODO! use hypothesis + @pytest.mark.parametrize("r", np.random.default_rng(0).uniform(0, 1e4, 10)) + def test__cdf(self, sampler, r): """Test :meth:`sample_scf.sample_intrp.SCFRSampler._cdf`.""" - assert False + # expected + assert_allclose(sampler._cdf(r), sampler._spl_cdf(zeta_of_r(r))) + + # args and kwargs don't matter + assert_allclose(sampler._cdf(r), sampler._cdf(r, 10, test=14)) # /def - @pytest.mark.skip("TODO!") - def test__cdf(self): + def test__cdf_edge(self, sampler): """Test :meth:`sample_scf.sample_intrp.SCFRSampler._cdf`.""" - assert False + assert np.isclose(sampler._cdf(0), 0.0, 1e-20) + assert np.isclose(sampler._cdf(np.inf), 1.0, 1e-20) # /def - @pytest.mark.skip("TODO!") - def test__ppf(self): + # TODO! use hypothesis + @pytest.mark.parametrize("q", np.random.default_rng(0).uniform(0, 1, 10)) + def test__ppf(self, sampler, q): """Test :meth:`sample_scf.sample_intrp.SCFRSampler._ppf`.""" - assert False + # expected + assert_allclose(sampler._ppf(q), r_of_zeta(sampler._spl_ppf(q))) + + # args and kwargs don't matter + assert_allclose(sampler._ppf(q), sampler._ppf(q, 10, test=14)) # /def - @pytest.mark.skip("TODO!") - def test_rvs(self): + @pytest.mark.parametrize( + "size, random, expected", + [ + (None, 0, 2.85831468), + (1, 2, 1.94376617), + ((3, 1), 4, (59.15672032, 2.842481, 71.71466506)), + ((3, 1), None, (59.15672032, 2.842481, 71.71466506)), + ], + ) + def test_rvs(self, sampler, size, random, expected): """Test :meth:`sample_scf.sample_intrp.SCFRSampler.rvs`.""" - assert False + super().test_rvs(sampler, size, random, expected) + + # /def + + # =============================================================== + # Time Scaling Tests + + @pytest.mark.parametrize("size", [1, 10, 100, 1000, 10000]) + def test_cdf_time_scaling(self, sampler, size): + """Test that the time scales as X * size""" + x = np.linspace(0, 1e4, size) + tic = time.perf_counter() + sampler.cdf(x) + toc = time.perf_counter() + + assert (toc - tic) < self.cdf_time_scale * size # linear scaling + + # /def + + @pytest.mark.parametrize("size", [1, 10, 100, 1000, 10000]) + def test_rvs_time_scaling(self, sampler, size): + """Test that the time scales as X * size""" + tic = time.perf_counter() + sampler.rvs(size=size) + toc = time.perf_counter() + + assert (toc - tic) < self.rvs_time_scale * size # linear scaling # /def @@ -106,26 +211,57 @@ def test_rvs(self): # /class -# ------------------------------------------------------------------- +# ---------------------------------------------------------------------------- -class Test_SCFThetaSampler(RVContinuousModRVSTest): +class Test_SCFThetaSampler(InterpRVPotentialTest): """Test :class:`sample_scf.sample_intrp.SCFThetaSampler`.""" + def setup_class(self): + self.cls = sample_intrp.SCFThetaSampler + self.cls_args = (rgrid, tgrid) + + self.cdf_time_scale = 3e-4 + self.rvs_time_scale = 6e-4 + + # /def + # =============================================================== # Method Tests - @pytest.mark.skip("TODO!") - def test___init__(self): - """Test :meth:`sample_scf.sample_intrp.SCFThetaSampler._cdf`.""" - assert False + def test___init__(self, sampler): + """Test initialization.""" + super().test___init__(sampler) + + # TODO! test mgrid endpoints, cdf, and ppf # /def - @pytest.mark.skip("TODO!") - def test__cdf(self): + # TODO! use hypothesis + @pytest.mark.parametrize( + "x, zeta", + [ + *zip( + np.random.default_rng(0).uniform(-1, 1, 10), + np.random.default_rng(1).uniform(-1, 1, 10), + ), + ], + ) + def test__cdf(self, sampler, x, zeta): """Test :meth:`sample_scf.sample_intrp.SCFThetaSampler._cdf`.""" - assert False + # expected + assert_allclose(sampler._cdf(x, zeta=zeta), sampler._spl_cdf(zeta, x, grid=False)) + + # args and kwargs don't matter + assert_allclose(sampler._cdf(x, zeta=zeta), sampler._cdf(x, 10, zeta=zeta, test=14)) + + # /def + + @pytest.mark.parametrize("zeta", np.random.default_rng(0).uniform(-1, 1, 10)) + def test__cdf_edge(self, sampler, zeta): + """Test :meth:`sample_scf.sample_intrp.SCFRSampler._cdf`.""" + assert np.isclose(sampler._cdf(-1, zeta=zeta), 0.0, atol=1e-16) + assert np.isclose(sampler._cdf(1, zeta=zeta), 1.0, atol=1e-16) # /def @@ -157,18 +293,53 @@ def test_rvs(self): # /def + # =============================================================== + # Time Scaling Tests + + @pytest.mark.parametrize("size", [1, 10, 100, 1000, 10000]) + def test_cdf_time_scaling(self, sampler, size): + """Test that the time scales as X * size""" + x = np.linspace(-np.pi / 2, np.pi / 2, size) + tic = time.perf_counter() + sampler.cdf(x, r=10) + toc = time.perf_counter() + + assert (toc - tic) < self.cdf_time_scale * size # linear scaling + + # /def + + @pytest.mark.parametrize("size", [1, 10, 100, 1000, 10000]) + def test_rvs_time_scaling(self, sampler, size): + """Test that the time scales as X * size""" + tic = time.perf_counter() + sampler.rvs(size=size, r=10) + toc = time.perf_counter() + + assert (toc - tic) < self.rvs_time_scale * size # linear scaling + + # /def + # =============================================================== # Usage Tests # /class -# ------------------------------------------------------------------- +# ---------------------------------------------------------------------------- -class Test_SCFPhiSampler(RVContinuousModRVSTest): +class Test_SCFPhiSampler(InterpRVPotentialTest): """Test :class:`sample_scf.sample_intrp.SCFPhiSampler`.""" + def setup_class(self): + self.cls = sample_intrp.SCFPhiSampler + self.cls_args = (rgrid, tgrid, pgrid) + + self.cdf_time_scale = 12e-4 + self.rvs_time_scale = 10e-4 + + # /def + # =============================================================== # Method Tests @@ -214,6 +385,32 @@ def test_rvs(self): # /def + # =============================================================== + # Time Scaling Tests + + @pytest.mark.parametrize("size", [1, 10, 100, 1000, 10000]) + def test_cdf_time_scaling(self, sampler, size): + """Test that the time scales as X * size""" + x = np.linspace(0, 2 * np.pi, size) + tic = time.perf_counter() + sampler.cdf(x, r=10, theta=np.pi / 6) + toc = time.perf_counter() + + assert (toc - tic) < self.cdf_time_scale * size # linear scaling + + # /def + + @pytest.mark.parametrize("size", [1, 10, 100, 1000, 10000]) + def test_rvs_time_scaling(self, sampler, size): + """Test that the time scales as X * size""" + tic = time.perf_counter() + sampler.rvs(size=size, r=10, theta=np.pi / 6) + toc = time.perf_counter() + + assert (toc - tic) < self.rvs_time_scale * size # linear scaling + + # /def + # =============================================================== # Usage Tests From 84961a949c631bf06c247234d8d7dc3be8099b59 Mon Sep 17 00:00:00 2001 From: "Nathaniel Starkman (@nstarman)" Date: Thu, 29 Jul 2021 15:47:16 -0400 Subject: [PATCH 17/31] fixes Signed-off-by: Nathaniel Starkman (@nstarman) --- sample_scf/tests/test_base.py | 10 +++++----- sample_scf/tests/test_sample_intrp.py | 20 ++++++++++++++++---- 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/sample_scf/tests/test_base.py b/sample_scf/tests/test_base.py index 8a5d031..7ca0589 100644 --- a/sample_scf/tests/test_base.py +++ b/sample_scf/tests/test_base.py @@ -54,8 +54,8 @@ def setup_class(self): self.cls = rvtestsampler self.cls_args = () - self.cdf_time_scale = 0 - self.rvs_time_scale = 0 + self.cdf_time_scale = 3e-6 + self.rvs_time_scale = 1e-4 # /def @@ -152,9 +152,9 @@ def setup_class(self): def sampler(self, potentials): """Set up r, theta, & phi sampler.""" sampler = self.cls(potentials, *self.cls_args) - sampler._rsampler = rvtestsampler() - sampler._thetasampler = rvtestsampler() - sampler._phisampler = rvtestsampler() + sampler._rsampler = rvtestsampler(potentials) + sampler._thetasampler = rvtestsampler(potentials) + sampler._phisampler = rvtestsampler(potentials) return sampler diff --git a/sample_scf/tests/test_sample_intrp.py b/sample_scf/tests/test_sample_intrp.py index 15bca13..f3fcdbb 100644 --- a/sample_scf/tests/test_sample_intrp.py +++ b/sample_scf/tests/test_sample_intrp.py @@ -10,6 +10,7 @@ import time # THIRD PARTY +import astropy.units as u import astropy.coordinates as coord import numpy as np import pytest @@ -19,7 +20,7 @@ from .test_base import SCFSamplerTestBase from .test_base import Test_RVPotential as RVPotentialTest from sample_scf import sample_intrp -from sample_scf.utils import r_of_zeta, zeta_of_r +from sample_scf.utils import r_of_zeta, zeta_of_r, x_of_theta ############################################################################## # PARAMETERS @@ -265,10 +266,21 @@ def test__cdf_edge(self, sampler, zeta): # /def - @pytest.mark.skip("TODO!") - def test_cdf(self): + @pytest.mark.parametrize( + "theta, r", + [ + *zip( + np.random.default_rng(0).uniform(-np.pi / 2, np.pi / 2, 10), + np.random.default_rng(1).uniform(0, 1e4, 10), + ), + ], + ) + def test_cdf(self, sampler, theta, r): """Test :meth:`sample_scf.sample_intrp.SCFThetaSampler.cdf`.""" - assert False + assert_allclose( + sampler.cdf(theta, r), + sampler._spl_cdf(zeta_of_r(r), x_of_theta(u.Quantity(theta, u.rad)), grid=False), + ) # /def From e7f3b26998a6becd8ca06e619b3247d6f4229db4 Mon Sep 17 00:00:00 2001 From: "Nathaniel Starkman (@nstarman)" Date: Thu, 29 Jul 2021 16:12:23 -0400 Subject: [PATCH 18/31] more tests Signed-off-by: Nathaniel Starkman (@nstarman) --- sample_scf/core.py | 6 +++--- sample_scf/tests/test_base.py | 3 ++- sample_scf/tests/test_core.py | 27 ++++++++++++++++++++++++--- sample_scf/tests/test_sample_intrp.py | 5 +++-- 4 files changed, 32 insertions(+), 9 deletions(-) diff --git a/sample_scf/core.py b/sample_scf/core.py index b6a1b5c..d9a4e13 100644 --- a/sample_scf/core.py +++ b/sample_scf/core.py @@ -115,9 +115,9 @@ def __init__( sampler_cls = SCFSamplerExact sampler = sampler_cls(pot, **kwargs) - rsampler = self._sampler._rsampler - thetasampler = self._sampler._thetasampler - phisampler = self._sampler._phisampler + rsampler = sampler.rsampler + thetasampler = sampler.thetasampler + phisampler = sampler.phisampler self._sampler: T.Optional[SCFSamplerBase] = sampler self._rsampler = rsampler diff --git a/sample_scf/tests/test_base.py b/sample_scf/tests/test_base.py index 7ca0589..d60214c 100644 --- a/sample_scf/tests/test_base.py +++ b/sample_scf/tests/test_base.py @@ -135,6 +135,7 @@ class Test_SCFSamplerBase: def setup_class(self): self.cls = base.SCFSamplerBase self.cls_args = () + self.cls_kwargs = {} self.expected_rvs = { 0: dict(r=0.548813503927, theta=1.021982822867 * u.rad, phi=0.548813503927 * u.rad), @@ -151,7 +152,7 @@ def setup_class(self): @pytest.fixture(autouse=True, scope="class") def sampler(self, potentials): """Set up r, theta, & phi sampler.""" - sampler = self.cls(potentials, *self.cls_args) + sampler = self.cls(potentials, *self.cls_args, **self.cls_kwargs) sampler._rsampler = rvtestsampler(potentials) sampler._thetasampler = rvtestsampler(potentials) sampler._phisampler = rvtestsampler(potentials) diff --git a/sample_scf/tests/test_core.py b/sample_scf/tests/test_core.py index 8a2e67c..3cb7a7f 100644 --- a/sample_scf/tests/test_core.py +++ b/sample_scf/tests/test_core.py @@ -6,8 +6,12 @@ ############################################################################## # IMPORTS +# THIRD PARTY +import astropy.units as u + # LOCAL -from .test_base import Test_SCFSamplerBase +from .test_base import Test_SCFSamplerBase as SCFSamplerBaseTests +from .test_sample_intrp import pgrid, rgrid, tgrid from sample_scf import core ############################################################################## @@ -15,10 +19,27 @@ ############################################################################## -class Test_SCFSampler(Test_SCFSamplerBase): +class Test_SCFSampler(SCFSamplerBaseTests): """Test :class:`sample_scf.core.SCFSample`.""" - _cls = core.SCFSampler + def setup_class(self): + super().setup_class(self) + + self.cls = core.SCFSampler + self.cls_args = ("interp",) # TODO! iterate over this + self.cls_kwargs = dict(rgrid=rgrid, thetagrid=tgrid, phigrid=pgrid) + + self.expected_rvs = { + 0: dict(r=0.548813503927, theta=1.021982822867 * u.rad, phi=0.548813503927 * u.rad), + 1: dict(r=0.548813503927, theta=1.021982822867 * u.rad, phi=0.548813503927 * u.rad), + 2: dict( + r=[0.9670298390136, 0.5472322491757, 0.9726843599648, 0.7148159936743], + theta=[0.603766487781, 1.023564077619, 0.598111966830, 0.855980333120] * u.rad, + phi=[0.9670298390136, 0.547232249175, 0.9726843599648, 0.7148159936743] * u.rad, + ), + } + + # /def # /class diff --git a/sample_scf/tests/test_sample_intrp.py b/sample_scf/tests/test_sample_intrp.py index f3fcdbb..9c116a2 100644 --- a/sample_scf/tests/test_sample_intrp.py +++ b/sample_scf/tests/test_sample_intrp.py @@ -10,8 +10,8 @@ import time # THIRD PARTY -import astropy.units as u import astropy.coordinates as coord +import astropy.units as u import numpy as np import pytest from numpy.testing import assert_allclose @@ -20,7 +20,7 @@ from .test_base import SCFSamplerTestBase from .test_base import Test_RVPotential as RVPotentialTest from sample_scf import sample_intrp -from sample_scf.utils import r_of_zeta, zeta_of_r, x_of_theta +from sample_scf.utils import r_of_zeta, x_of_theta, zeta_of_r ############################################################################## # PARAMETERS @@ -43,6 +43,7 @@ def setup_class(self): self.cls = sample_intrp.SCFSampler self.cls_args = (rgrid, tgrid, pgrid) + self.cls_kwargs = {} # /def From 17f010df4c2bb29e03d28d6b4befc1cd4d7ea600 Mon Sep 17 00:00:00 2001 From: "Nathaniel Starkman (@nstarman)" Date: Thu, 29 Jul 2021 16:15:19 -0400 Subject: [PATCH 19/31] loosen time for phi rvs Signed-off-by: Nathaniel Starkman (@nstarman) --- sample_scf/tests/test_sample_intrp.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sample_scf/tests/test_sample_intrp.py b/sample_scf/tests/test_sample_intrp.py index 9c116a2..e1714b7 100644 --- a/sample_scf/tests/test_sample_intrp.py +++ b/sample_scf/tests/test_sample_intrp.py @@ -349,7 +349,7 @@ def setup_class(self): self.cls_args = (rgrid, tgrid, pgrid) self.cdf_time_scale = 12e-4 - self.rvs_time_scale = 10e-4 + self.rvs_time_scale = 12e-4 # /def From fe392757af9aea318d8e2a1ab05c025e2c8292b8 Mon Sep 17 00:00:00 2001 From: "Nathaniel Starkman (@nstarman)" Date: Thu, 29 Jul 2021 16:40:48 -0400 Subject: [PATCH 20/31] shape mismatch tests Signed-off-by: Nathaniel Starkman (@nstarman) --- sample_scf/tests/test_sample_intrp.py | 54 +++++++++++++++++---------- 1 file changed, 35 insertions(+), 19 deletions(-) diff --git a/sample_scf/tests/test_sample_intrp.py b/sample_scf/tests/test_sample_intrp.py index e1714b7..25dd4c5 100644 --- a/sample_scf/tests/test_sample_intrp.py +++ b/sample_scf/tests/test_sample_intrp.py @@ -20,7 +20,7 @@ from .test_base import SCFSamplerTestBase from .test_base import Test_RVPotential as RVPotentialTest from sample_scf import sample_intrp -from sample_scf.utils import r_of_zeta, x_of_theta, zeta_of_r +from sample_scf.utils import phiRSms, r_of_zeta, thetaQls, x_of_theta, zeta_of_r ############################################################################## # PARAMETERS @@ -39,33 +39,42 @@ class Test_SCFSampler(SCFSamplerTestBase): """Test :class:`sample_scf.sample_intrp.SCFSampler`.""" def setup_class(self): - super().setup_class(self) self.cls = sample_intrp.SCFSampler self.cls_args = (rgrid, tgrid, pgrid) self.cls_kwargs = {} + self.expected_rvs = { + 0: dict(r=2.8583146808697, theta=1.473013568997 * u.rad, phi=3.4482969442579 * u.rad), + 1: dict(r=2.8583146808697, theta=1.473013568997 * u.rad, phi=3.4482969442579 * u.rad), + 2: dict( + r=[59.15672032022, 2.842480998054, 71.71466505664, 5.471148006362], + theta=[0.36517953566424, 1.4761907683040, 0.33207251545636, 1.1267111320704] + * u.rad, + phi=[6.076027676095, 3.438361627636, 6.11155607905, 4.491321348792] * u.rad, + ), + } + # /def + + # =============================================================== + # Method Tests - @pytest.mark.skip("TODO!") + # TODO! make sure these are correct + @pytest.mark.parametrize( + "r, theta, phi, expected", + [ + (0, 0, 0, [0, 0.5, 0]), + (1, 0, 0, [0.25, 0.5, 0]), + ([0, 1], [0, 0], [0, 0], [[0, 0.5, 0], [0.25, 0.5, 0]]), + ], + ) def test_cdf(self, sampler, r, theta, phi, expected): """Test :meth:`sample_scf.base.SCFSamplerBase.cdf`.""" assert np.allclose(sampler.cdf(r, theta, phi), expected, atol=1e-16) # /def - @pytest.mark.skip("TODO!") - def test_rvs(self, sampler, id, size, random): - """Test :meth:`sample_scf.base.SCFSamplerBase.rvs`.""" - samples = sampler.rvs(size=size, random_state=random) - sce = coord.PhysicsSphericalRepresentation(**self.expected_rvs[id]) - - assert_allclose(samples.r, sce.r, atol=1e-16) - assert_allclose(samples.theta.value, sce.theta.value, atol=1e-16) - assert_allclose(samples.phi.value, sce.phi.value, atol=1e-16) - - # /def - # /class @@ -235,7 +244,10 @@ def test___init__(self, sampler): """Test initialization.""" super().test___init__(sampler) - # TODO! test mgrid endpoints, cdf, and ppf + # a shape mismatch + Qls = thetaQls(sampler._potential, rgrid[1:-1]) + with pytest.raises(ValueError, match="Qls must be shape"): + sampler.__class__(sampler._potential, rgrid, tgrid, Qls=Qls) # /def @@ -356,10 +368,14 @@ def setup_class(self): # =============================================================== # Method Tests - @pytest.mark.skip("TODO!") - def test___init__(self): + def test___init__(self, sampler): """Test :meth:`sample_scf.sample_intrp.SCFPhiSampler._cdf`.""" - assert False + # super().test___init__(sampler) # doesn't work TODO! + + # a shape mismatch + RSms = phiRSms(sampler._potential, rgrid[1:-1], tgrid[1:-1]) + with pytest.raises(ValueError, match="Rm, Sm must be shape"): + sampler.__class__(sampler._potential, rgrid, tgrid, pgrid, RSms=RSms) # /def From 1773aff6282d84ea809e101adaec88abc56f96e9 Mon Sep 17 00:00:00 2001 From: "Nathaniel Starkman (@nstarman)" Date: Thu, 29 Jul 2021 17:46:18 -0400 Subject: [PATCH 21/31] start plot tests Signed-off-by: Nathaniel Starkman (@nstarman) --- pyproject.toml | 2 +- sample_scf/conftest.py | 23 ++- sample_scf/tests/test_base.py | 14 +- sample_scf/tests/test_sample_intrp.py | 269 +++++++++++++++++++++++++- 4 files changed, 286 insertions(+), 22 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index dc35c22..f88b8ff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,7 +15,7 @@ force_grid_wrap = 0 use_parentheses = "True" ensure_newline_before_comments = "True" sections = ["FUTURE", "STDLIB", "THIRDPARTY", "FIRSTPARTY", "LOCALFOLDER"] -known_third_party = ["astropy", "extension_helpers", "galpy", "numpy", "pytest", "scipy", "setuptools"] +known_third_party = ["astropy", "extension_helpers", "galpy", "matplotlib", "numpy", "pytest", "scipy", "setuptools"] known_localfolder = "sample_scf" import_heading_stdlib = "BUILT-IN" diff --git a/sample_scf/conftest.py b/sample_scf/conftest.py index 2f3540b..c220e42 100644 --- a/sample_scf/conftest.py +++ b/sample_scf/conftest.py @@ -16,7 +16,8 @@ # THIRD PARTY import numpy as np import pytest -from galpy.potential import SCFPotential +from galpy.df import isotropicHernquistdf +from galpy.potential import HernquistPotential, SCFPotential try: # THIRD PARTY @@ -74,17 +75,18 @@ def pytest_configure(config): # ============================================================================ # Fixtures +# Hernquist +_Acos = np.zeros((5, 6, 6)) +_Acos_hern = _Acos.copy() +_Acos_hern[0, 0, 0] = 1 +_hernquist_potential = SCFPotential(Acos=_Acos_hern) +hernquist_df = isotropicHernquistdf(HernquistPotential()) + @pytest.fixture(autouse=True, scope="session") def hernquist_scf_potential(): """Make a SCF of a Hernquist potential.""" - Acos = np.zeros((5, 6, 6)) - - Acos_hern = Acos.copy() - Acos_hern[0, 0, 0] = 1 - - hernpot = SCFPotential(Acos=Acos_hern) - return hernpot + return _hernquist_potential # /def @@ -109,9 +111,6 @@ def hernquist_scf_potential(): ) def potentials(request): if request.param in ("hernquist_scf_potential", "other_hernquist_scf_potential"): - Acos = np.zeros((5, 6, 6)) - Acos_hern = Acos.copy() - Acos_hern[0, 0, 0] = 1 - potential = SCFPotential(Acos=Acos_hern) + potential = _hernquist_potential yield potential diff --git a/sample_scf/tests/test_base.py b/sample_scf/tests/test_base.py index d60214c..24a17eb 100644 --- a/sample_scf/tests/test_base.py +++ b/sample_scf/tests/test_base.py @@ -54,6 +54,9 @@ def setup_class(self): self.cls = rvtestsampler self.cls_args = () + self.cdf_args = (0,) + self.cdf_kwargs = {} + self.cdf_time_scale = 3e-6 self.rvs_time_scale = 1e-4 @@ -71,6 +74,13 @@ def sampler(self, potentials): # =============================================================== # Method Tests + @pytest.mark.skip("TODO") + def test_cdf(self, sampler, expected): + """Test :meth:`sample_scf.base.rv_potential.cdf`.""" + assert False + + # /def + @pytest.mark.parametrize( "size, random, expected", [ @@ -119,9 +129,6 @@ def test_rvs_time_scaling(self, sampler, size): # /def - # =============================================================== - # Image tests - # /class @@ -162,6 +169,7 @@ def sampler(self, potentials): # /def # =============================================================== + # Method Tests def test_rsampler(self, sampler): """Test :meth:`sample_scf.base.SCFSamplerBase.rsampler`.""" diff --git a/sample_scf/tests/test_sample_intrp.py b/sample_scf/tests/test_sample_intrp.py index 25dd4c5..75feffa 100644 --- a/sample_scf/tests/test_sample_intrp.py +++ b/sample_scf/tests/test_sample_intrp.py @@ -10,16 +10,17 @@ import time # THIRD PARTY -import astropy.coordinates as coord import astropy.units as u +import matplotlib.pyplot as plt import numpy as np import pytest +from astropy.utils.misc import NumpyRNGContext from numpy.testing import assert_allclose # LOCAL from .test_base import SCFSamplerTestBase from .test_base import Test_RVPotential as RVPotentialTest -from sample_scf import sample_intrp +from sample_scf import conftest, sample_intrp from sample_scf.utils import phiRSms, r_of_zeta, thetaQls, x_of_theta, zeta_of_r ############################################################################## @@ -56,7 +57,7 @@ def setup_class(self): } # /def - + # =============================================================== # Method Tests @@ -75,6 +76,21 @@ def test_cdf(self, sampler, r, theta, phi, expected): # /def + # =============================================================== + # Plot Tests + + @pytest.mark.skip("TODO!") + def test_interp_cdf_plot(self): + assert False + + # /def + + @pytest.mark.skip("TODO!") + def test_interp_sampling_plot(self): + assert False + + # /def + # /class @@ -131,6 +147,10 @@ def setup_class(self): self.cdf_time_scale = 6e-4 # milliseconds self.rvs_time_scale = 2e-4 # milliseconds + self.theory = dict( + hernquist=conftest.hernquist_df, + ) + # /def # =============================================================== @@ -217,7 +237,90 @@ def test_rvs_time_scaling(self, sampler, size): # /def # =============================================================== - # Usage Tests + # Image Tests + + @pytest.mark.mpl_image_compare( + baseline_dir="baseline_images", + # hash_library="baseline_images/path_to_file.json", # TODO! + ) + def test_interp_r_cdf_plot(self, sampler): + fig = plt.figure(figsize=(10, 3)) + + ax = fig.add_subplot( + 121, + title=r"$m(\leq r) / m_{tot}$", + xlabel="r", + ylabel=r"$m(\leq r) / m_{tot}$", + ) + kw = dict(marker="o", ms=5, c="k", zorder=5, label="CDF") + ax.semilogx(rgrid, sampler.cdf(rgrid), **kw) + ax.axvline(0, c="tab:blue") + ax.axhline(sampler.cdf(0), c="tab:blue", label="r=0") + ax.axvline(1, c="tab:green") + ax.axhline(sampler.cdf(1), c="tab:green", label="r=1") + ax.axvline(1e2, c="tab:red") + ax.axhline(sampler.cdf(1e2), c="tab:red", label="r=100") + + ax.set_xlim((1e-1, None)) + ax.legend(loc="lower right") + + ax = fig.add_subplot( + 122, + title=r"$m(\leq \zeta) / m_{tot}$", + xlabel=r"$\zeta$", + ylabel=r"$m(\leq \zeta) / m_{tot}$", + ) + ax.plot(zeta_of_r(rgrid), sampler.cdf(rgrid), **kw) + ax.axvline(zeta_of_r(0), c="tab:blue") + ax.axhline(sampler.cdf(0), c="tab:blue", label="r=0") + ax.axvline(zeta_of_r(1), c="tab:green") + ax.axhline(sampler.cdf(1), c="tab:green", label="r=1") + ax.axvline(zeta_of_r(1e2), c="tab:red") + ax.axhline(sampler.cdf(1e2), c="tab:red", label="r=100") + ax.legend(loc="upper left") + + fig.tight_layout() + return fig + + # /def + + @pytest.mark.mpl_image_compare( + baseline_dir="baseline_images", + # hash_library="baseline_images/path_to_file.json", + ) + def test_interp_r_sampling_plot(self, request, sampler): + """Test sampling.""" + # fiqure out theory sampler + options = request.fixturenames[0] + if "hernquist" in options: + kind = "hernquist" + else: + raise ValueError + + with NumpyRNGContext(0): # control the random numbers + sample = sampler.rvs(size=int(1e6)) + sample = sample[sample < 1e4] + + theory = self.theory[kind].sample(n=int(1e6)).r() + theory = theory[theory < 1e4] + + fig = plt.figure(figsize=(10, 3)) + ax = fig.add_subplot(121, title="SCF vs theory sampling", xlabel="r", ylabel="frequency") + _, bins, *_ = ax.hist(sample, bins=30, log=True, alpha=0.5, label="SCF sample") + # Comparing to expected + ax.hist( + theory, + bins=bins, + log=True, + alpha=0.5, + label="Hernquist theoretical", + ) + ax.legend() + fig.tight_layout() + + return fig + + # /def # /class @@ -235,6 +338,10 @@ def setup_class(self): self.cdf_time_scale = 3e-4 self.rvs_time_scale = 6e-4 + self.theory = dict( + hernquist=conftest.hernquist_df, + ) + # /def # =============================================================== @@ -345,7 +452,87 @@ def test_rvs_time_scaling(self, sampler, size): # /def # =============================================================== - # Usage Tests + # Image Tests + + @pytest.mark.mpl_image_compare( + baseline_dir="baseline_images", + # hash_library="baseline_images/path_to_file.json", # TODO! + ) + def test_interp_theta_cdf_plot(self, sampler): + fig = plt.figure(figsize=(10, 3)) + + ax = fig.add_subplot( + 121, + title=r"CDF($\theta$)", + xlabel=r"$\theta$", + ylabel=r"CDF($\theta$)", + ) + kw = dict(marker="o", ms=5, c="k", zorder=5, label="CDF") + ax.plot(tgrid, sampler.cdf(tgrid, r=10), **kw) + ax.axvline(-np.pi / 2, c="tab:blue") + ax.axhline(sampler.cdf(-np.pi / 2, r=10), c="tab:blue", label=r"$\theta=-\frac{\pi}{2}$") + ax.axvline(0, c="tab:green") + ax.axhline(sampler.cdf(0, r=10), c="tab:green", label=r"$\theta=0$") + ax.axvline(np.pi / 2, c="tab:red") + ax.axhline(sampler.cdf(np.pi / 2, r=10), c="tab:red", label=r"$\theta=\frac{\pi}{2}$") + ax.legend(loc="lower right") + + ax = fig.add_subplot( + 122, + title=r"CDF($x$)", + xlabel=r"x$", + ylabel=r"CDF($x$)", + ) + ax.plot(x_of_theta(tgrid), sampler.cdf(tgrid, r=10), **kw) + ax.axvline(x_of_theta(-1), c="tab:blue") + ax.axhline(sampler.cdf(-1, r=10), c="tab:blue", label=r"$\theta=-\frac{\pi}{2}$") + ax.axvline(x_of_theta(0), c="tab:green") + ax.axhline(sampler.cdf(0, r=10), c="tab:green", label=r"$\theta=0$") + ax.axvline(x_of_theta(1), c="tab:red") + ax.axhline(sampler.cdf(1, r=10), c="tab:red", label=r"$\theta=\frac{\pi}{2}$") + ax.legend(loc="upper left") + + fig.tight_layout() + return fig + + # /def + + @pytest.mark.mpl_image_compare( + baseline_dir="baseline_images", + # hash_library="baseline_images/path_to_file.json", + ) + def test_interp_theta_sampling_plot(self, request, sampler): + """Test sampling.""" + # fiqure out theory sampler + options = request.fixturenames[0] + if "hernquist" in options: + kind = "hernquist" + else: + raise ValueError + + with NumpyRNGContext(0): # control the random numbers + sample = sampler.rvs(size=int(1e6), r=10) + sample = sample[sample < 1e4] + + theory = self.theory[kind].sample(n=int(1e6)).theta() - np.pi / 2 + + fig = plt.figure(figsize=(10, 3)) + ax = fig.add_subplot(121, title="SCF vs theory sampling", xlabel="r", ylabel="frequency") + _, bins, *_ = ax.hist(sample, bins=30, log=True, alpha=0.5, label="SCF sample") + # Comparing to expected + ax.hist( + theory, + bins=bins, + log=True, + alpha=0.5, + label="Hernquist theoretical", + ) + ax.legend() + fig.tight_layout() + + return fig + + # /def # /class @@ -363,6 +550,10 @@ def setup_class(self): self.cdf_time_scale = 12e-4 self.rvs_time_scale = 12e-4 + self.theory = dict( + hernquist=conftest.hernquist_df, + ) + # /def # =============================================================== @@ -441,7 +632,73 @@ def test_rvs_time_scaling(self, sampler, size): # /def # =============================================================== - # Usage Tests + # Image Tests + + @pytest.mark.mpl_image_compare( + baseline_dir="baseline_images", + # hash_library="baseline_images/path_to_file.json", # TODO! + ) + def test_interp_phi_cdf_plot(self, sampler): + fig = plt.figure(figsize=(5, 3)) + + ax = fig.add_subplot( + 111, + title=r"CDF($\phi$)", + xlabel=r"$\phi$", + ylabel=r"CDF($\phi$)", + ) + kw = dict(marker="o", ms=5, c="k", zorder=5, label="CDF") + ax.plot(pgrid, sampler.cdf(pgrid, r=10, theta=np.pi / 6), **kw) + ax.axvline(0, c="tab:blue") + ax.axhline(sampler.cdf(0, r=10, theta=np.pi / 6), c="tab:blue", label=r"$\phi=0$") + ax.axvline(np.pi, c="tab:green") + ax.axhline(sampler.cdf(np.pi, r=10, theta=np.pi / 6), c="tab:green", label=r"$\phi=\pi$") + ax.axvline(2 * np.pi, c="tab:red") + ax.axhline(sampler.cdf(2 * np.pi, r=10, theta=np.pi / 6), c="tab:red", label=r"$\phi=2\pi$") + ax.legend(loc="lower right") + + fig.tight_layout() + return fig + + # /def + + +# @pytest.mark.mpl_image_compare( +# baseline_dir="baseline_images", +# # hash_library="baseline_images/path_to_file.json", +# ) +# def test_interp_phi_sampling_plot(self, request, sampler): +# """Test sampling.""" +# # fiqure out theory sampler +# options = request.fixturenames[0] +# if "hernquist" in options: +# kind = "hernquist" +# else: +# raise ValueError +# +# with NumpyRNGContext(0): # control the random numbers +# sample = sampler.rvs(size=int(1e6), r=10) +# sample = sample[sample < 1e4] +# +# theory = self.theory[kind].sample(n=int(1e6)).theta() - np.pi / 2 +# +# fig = plt.figure(figsize=(10, 3)) +# ax = fig.add_subplot(121, title="SCF vs theory sampling", xlabel="r", ylabel="frequency") +# _, bins, *_ = ax.hist(sample, bins=30, log=True, alpha=0.5, label="SCF sample") +# # Comparing to expected +# ax.hist( +# theory, +# bins=bins, +# log=True, +# alpha=0.5, +# label="Hernquist theoretical", +# ) +# ax.legend() +# fig.tight_layout() +# +# return fig +# +# # /def # /class From 690a3f001050e79e7654e6f631638f7ff1924d15 Mon Sep 17 00:00:00 2001 From: "Nathaniel Starkman (@nstarman)" Date: Thu, 29 Jul 2021 23:34:50 -0400 Subject: [PATCH 22/31] more image tests Signed-off-by: Nathaniel Starkman (@nstarman) --- sample_scf/tests/test_sample_exact.py | 74 +++++++++++------------- sample_scf/tests/test_sample_intrp.py | 83 +++++++++++++++------------ 2 files changed, 79 insertions(+), 78 deletions(-) diff --git a/sample_scf/tests/test_sample_exact.py b/sample_scf/tests/test_sample_exact.py index 624a8e3..533338e 100644 --- a/sample_scf/tests/test_sample_exact.py +++ b/sample_scf/tests/test_sample_exact.py @@ -12,49 +12,16 @@ ############################################################################## # IMPORTS -# BUILT-IN -import pathlib - # THIRD PARTY -# import matplotlib.pyplot as plt import numpy as np -# import pytest -# from astropy.utils.misc import NumpyRNGContext -# from galpy.df import isotropicHernquistdf -from galpy.potential import HernquistPotential, SCFPotential - -# LOCAL -# from sample_scf.sample_exact import SCFPhiSampler, SCFRSampler, SCFSampler, SCFThetaSampler -from sample_scf.utils import zeta_of_r # x_of_theta - -# from .test_base import SCFSamplerTestBase -# from sample_scf import sample_exact - - ############################################################################## # PARAMETERS -# hernpot = TriaxialHernquistPotential(b=0.8, c=1.2) -hernpot = HernquistPotential() -coeffs = np.load(pathlib.Path(__file__).parent / "scf_coeffs.npz") -Acos, Asin = coeffs["Acos"], coeffs["Asin"] - -pot = SCFPotential(Acos=Acos, Asin=Asin) -pot.turn_physical_off() +rgrid = np.concatenate(([0], np.geomspace(1e-1, 1e3, 100))) +tgrid = np.linspace(-np.pi / 2, np.pi / 2, 30) +pgrid = np.linspace(0, 2 * np.pi, 30) -# r sampling -r = np.unique(np.concatenate([[0], np.geomspace(1e-7, 1e3, 100), [np.inf]])) -zeta = zeta_of_r(r) -m = [pot._mass(x) for x in r] -m[0] = 0 -m[-1] = 1 - -# theta sampling -theta = np.linspace(-np.pi / 2, np.pi / 2, 30) - -# phi sampling -phi = np.linspace(0, 2 * np.pi, 30) ############################################################################## # CODE @@ -62,13 +29,38 @@ # class Test_SCFSampler(SCFSamplerTestBase): -# """Test :class:`sample_scf.sample_intrp.SCFSampler`.""" +# """Test :class:`sample_scf.sample_exact.SCFSampler`.""" +# +# self.cls = sample_intrp.SCFSampler +# self.cls_args = (rgrid, tgrid, pgrid) +# self.cls_kwargs = {} +# +# self.expected_rvs = { +# 0: dict(r=2.8583146808697, theta=1.473013568997 * u.rad, phi=3.4482969442579 * u.rad), +# 1: dict(r=2.8583146808697, theta=1.473013568997 * u.rad, phi=3.4482969442579 * u.rad), +# 2: dict( +# r=[59.15672032022, 2.842480998054, 71.71466505664, 5.471148006362], +# theta=[0.36517953566424, 1.4761907683040, 0.33207251545636, 1.1267111320704] +# * u.rad, +# phi=[6.076027676095, 3.438361627636, 6.11155607905, 4.491321348792] * u.rad, +# ), +# } # -# def setup_class(self): -# super().setup_class() +# # =============================================================== +# # Method Tests # -# self.cls = sample_exact.SCFSampler -# self.cls_args = () +# # TODO! make sure these are correct +# @pytest.mark.parametrize( +# "r, theta, phi, expected", +# [ +# (0, 0, 0, [0, 0.5, 0]), +# (1, 0, 0, [0.25, 0.5, 0]), +# ([0, 1], [0, 0], [0, 0], [[0, 0.5, 0], [0.25, 0.5, 0]]), +# ], +# ) +# def test_cdf(self, sampler, r, theta, phi, expected): +# """Test :meth:`sample_scf.base.SCFSamplerBase.cdf`.""" +# assert np.allclose(sampler.cdf(r, theta, phi), expected, atol=1e-16) # # # /def # diff --git a/sample_scf/tests/test_sample_intrp.py b/sample_scf/tests/test_sample_intrp.py index 75feffa..7f681df 100644 --- a/sample_scf/tests/test_sample_intrp.py +++ b/sample_scf/tests/test_sample_intrp.py @@ -517,7 +517,12 @@ def test_interp_theta_sampling_plot(self, request, sampler): theory = self.theory[kind].sample(n=int(1e6)).theta() - np.pi / 2 fig = plt.figure(figsize=(10, 3)) - ax = fig.add_subplot(121, title="SCF vs theory sampling", xlabel="r", ylabel="frequency") + ax = fig.add_subplot( + 121, + title="SCF vs theory sampling", + xlabel=r"$\theta$", + ylabel="frequency", + ) _, bins, *_ = ax.hist(sample, bins=30, log=True, alpha=0.5, label="SCF sample") # Comparing to expected ax.hist( @@ -662,43 +667,47 @@ def test_interp_phi_cdf_plot(self, sampler): # /def + @pytest.mark.mpl_image_compare( + baseline_dir="baseline_images", + # hash_library="baseline_images/path_to_file.json", + ) + def test_interp_phi_sampling_plot(self, request, sampler): + """Test sampling.""" + # fiqure out theory sampler + options = request.fixturenames[0] + if "hernquist" in options: + kind = "hernquist" + else: + raise ValueError + + with NumpyRNGContext(0): # control the random numbers + sample = sampler.rvs(size=int(1e6), r=10, theta=np.pi / 6) + sample = sample[sample < 1e4] + + theory = self.theory[kind].sample(n=int(1e6)).phi() -# @pytest.mark.mpl_image_compare( -# baseline_dir="baseline_images", -# # hash_library="baseline_images/path_to_file.json", -# ) -# def test_interp_phi_sampling_plot(self, request, sampler): -# """Test sampling.""" -# # fiqure out theory sampler -# options = request.fixturenames[0] -# if "hernquist" in options: -# kind = "hernquist" -# else: -# raise ValueError -# -# with NumpyRNGContext(0): # control the random numbers -# sample = sampler.rvs(size=int(1e6), r=10) -# sample = sample[sample < 1e4] -# -# theory = self.theory[kind].sample(n=int(1e6)).theta() - np.pi / 2 -# -# fig = plt.figure(figsize=(10, 3)) -# ax = fig.add_subplot(121, title="SCF vs theory sampling", xlabel="r", ylabel="frequency") -# _, bins, *_ = ax.hist(sample, bins=30, log=True, alpha=0.5, label="SCF sample") -# # Comparing to expected -# ax.hist( -# theory, -# bins=bins, -# log=True, -# alpha=0.5, -# label="Hernquist theoretical", -# ) -# ax.legend() -# fig.tight_layout() -# -# return fig -# -# # /def + fig = plt.figure(figsize=(10, 3)) + ax = fig.add_subplot( + 121, + title="SCF vs theory sampling", + xlabel=r"$\phi$", + ylabel="frequency", + ) + _, bins, *_ = ax.hist(sample, bins=30, log=True, alpha=0.5, label="SCF sample") + # Comparing to expected + ax.hist( + theory, + bins=bins, + log=True, + alpha=0.5, + label="Hernquist theoretical", + ) + ax.legend() + fig.tight_layout() + + return fig + + # /def # /class From 3db74e0ba87c4e0ffdce9ca0a4d6d9661fb4e849 Mon Sep 17 00:00:00 2001 From: "Nathaniel Starkman (@nstarman)" Date: Fri, 30 Jul 2021 00:22:05 -0400 Subject: [PATCH 23/31] correct arg name Signed-off-by: Nathaniel Starkman (@nstarman) --- sample_scf/base.py | 14 ++++----- sample_scf/core.py | 14 ++++----- sample_scf/sample_exact.py | 63 ++++++++++++++++++++++---------------- 3 files changed, 50 insertions(+), 41 deletions(-) diff --git a/sample_scf/base.py b/sample_scf/base.py index f872158..9924d5f 100644 --- a/sample_scf/base.py +++ b/sample_scf/base.py @@ -45,15 +45,15 @@ def __init__( self, potential: SCFPotential, momtype: int = 1, - a: float = None, - b: float = None, + a: T.Optional[float] = None, + b: T.Optional[float] = None, xtol: float = 1e-14, badvalue: T.Optional[float] = None, - name: str = None, - longname: str = None, - shapes: tuple = None, - extradoc: str = None, - seed: int = None, + name: T.Optional[str] = None, + longname: T.Optional[str] = None, + shapes: T.Optional[T.Tuple[int, ...]] = None, + extradoc: T.Optional[str] = None, + seed: T.Optional[int] = None, ): super().__init__( momtype=momtype, diff --git a/sample_scf/core.py b/sample_scf/core.py index d9a4e13..7e049d4 100644 --- a/sample_scf/core.py +++ b/sample_scf/core.py @@ -96,25 +96,25 @@ class SCFSampler(SCFSamplerBase): # metaclass=SCFSamplerSwitch def __init__( self, - pot: SCFPotential, + potential: SCFPotential, method: T.Union[T.Literal["interp", "exact"], MethodsMapping], **kwargs: T.Any ) -> None: - super().__init__(pot) + super().__init__(potential) if isinstance(method, Mapping): sampler = None - rsampler = method["r"](pot, **kwargs) - thetasampler = method["theta"](pot, **kwargs) - phisampler = method["phi"](pot, **kwargs) + rsampler = method["r"](potential, **kwargs) + thetasampler = method["theta"](potential, **kwargs) + phisampler = method["phi"](potential, **kwargs) else: - sampler_cls: rv_potential + sampler_cls: T.Type[SCFSamplerBase] if method == "interp": sampler_cls = SCFSamplerIntrp elif method == "exact": sampler_cls = SCFSamplerExact - sampler = sampler_cls(pot, **kwargs) + sampler = sampler_cls(potential, **kwargs) rsampler = sampler.rsampler thetasampler = sampler.thetasampler phisampler = sampler.phisampler diff --git a/sample_scf/sample_exact.py b/sample_scf/sample_exact.py index 3897452..0272da6 100644 --- a/sample_scf/sample_exact.py +++ b/sample_scf/sample_exact.py @@ -55,11 +55,11 @@ class SCFSampler(SCFSamplerBase): """ - def __init__(self, pot: SCFPotential, **kw: T.Any) -> None: - self._rsampler = SCFRSampler(pot) + def __init__(self, potential: SCFPotential, **kw: T.Any) -> None: + self._rsampler = SCFRSampler(potential) # not fixed r, theta. slower! - self._thetasampler = SCFThetaSampler_of_r(pot, r=None) - self._phisampler = SCFPhiSampler_of_rtheta(pot, r=None, theta=None) + self._thetasampler = SCFThetaSampler_of_r(potential) # r=None + self._phisampler = SCFPhiSampler_of_rtheta(potential) # r=None, theta=None # /def @@ -89,7 +89,7 @@ def __init__(self, potential: SCFPotential, **kw: T.Any) -> None: # /def def _cdf(self, r: npt.ArrayLike, *args: T.Any, **kw: T.Any) -> NDArray64: - mass: NDArray64 = self._pot._mass(r) + mass: NDArray64 = self._potential._mass(r) # (self._scfmass(zeta) - self._mi) / (self._mf - self._mi) # TODO! is this normalization even necessary? return mass @@ -139,7 +139,7 @@ def Qls(self, r: float) -> NDArray64: Ql : ndarray """ - Qls: NDArray64 = thetaQls(self._pot, r) + Qls: NDArray64 = thetaQls(self._potential, r) return Qls # /def @@ -212,9 +212,9 @@ def _cdf(self, theta: npt.ArrayLike, *args: T.Any) -> NDArray64: class SCFThetaSampler_of_r(SCFThetaSamplerBase): - def _cdf(self, theta: NDArray64, *args: T.Any, r: float) -> NDArray64: + def _cdf(self, theta: NDArray64, *args: T.Any, r: T.Optional[float] = None) -> NDArray64: x = x_of_theta(theta) - Qlsatr = self.Qls(r) + Qlsatr = self.Qls(T.cast(float, r)) # l = 0 term0 = (1.0 + x) / 2.0 @@ -276,7 +276,26 @@ def __init__(self, potential: SCFPotential, **kw: T.Any) -> None: # @functools.lru_cache() def RSms(self, r: float, theta: float) -> T.Tuple[NDArray64, NDArray64]: - return phiRSms(self._pot, r, theta) + return phiRSms(self._potential, r, theta) + + # /def + + def _cdf(self, phi: NDArray64, *args: T.Any, **kw: T.Any) -> NDArray64: + Rm = self._Rm + Sm = self._Sm + + # l = 0 + term0: NDArray64 = phi / (2 * np.pi) + + # l = 1+ + factor = 1 / Rm[0] # R0 + ms = np.arange(1, Rm.shape[1] if len(Rm.shape) > 1 else 2) + term1p = np.sum( + (Rm[1:] * np.sin(ms * phi) + Sm[1:] * (1 - np.cos(ms * phi))) / (2 * np.pi * ms), + ) + + cdf: NDArray64 = term0 + factor * term1p + return cdf # /def @@ -302,22 +321,6 @@ def __init__(self, potential: SCFPotential, r: float, theta: float, **kw: T.Any) # /def - def _cdf(self, phi: NDArray64, *args: T.Any, **kw: T.Any) -> NDArray64: - Rm, Sm = self._Rm, self._Sm - - # l = 0 - term0: NDArray64 = phi / (2 * np.pi) - - # l = 1+ - factor = 1 / Rm[0] # R0 - ms = np.arange(1, Rm.shape[1]) - term1p = np.sum( - (Rm[1:] * np.sin(ms * phi) + Sm[1:] * (1 - np.cos(ms * phi))) / (2 * np.pi * ms), - ) - - cdf: NDArray64 = term0 + factor * term1p - return cdf - def cdf(self, phi: NDArray64, *args: T.Any, **kw: T.Any) -> NDArray64: return self._cdf(phi, *args, **kw) @@ -331,8 +334,14 @@ class SCFPhiSampler_of_rtheta(SCFPhiSamplerBase): _Rm: T.Optional[NDArray64] _Sm: T.Optional[NDArray64] - def _cdf(self, phi: npt.ArrayLike, *args: T.Any, r: float, theta: float) -> NDArray64: - self._Rm, self._Sm = self.RSms(float(r), float(theta)) + def _cdf( + self, + phi: npt.ArrayLike, + *args: T.Any, + r: T.Optional[float] = None, + theta: T.Optional[float] = None + ) -> NDArray64: + self._Rm, self._Sm = self.RSms(T.cast(float, r), T.cast(float, theta)) cdf: NDArray64 = super()._cdf(phi, *args) self._Rm, self._Sm = None, None return cdf From a45f3250adfc4308602f734c7b34864bb6ab484a Mon Sep 17 00:00:00 2001 From: "Nathaniel Starkman (@nstarman)" Date: Tue, 3 Aug 2021 00:13:54 -0400 Subject: [PATCH 24/31] more exact tests Signed-off-by: Nathaniel Starkman (@nstarman) --- README.rst | 30 +- sample_scf/__init__.py | 4 +- sample_scf/base.py | 32 +- sample_scf/core.py | 10 +- sample_scf/exact.py | 543 ++++++++++++++++ .../{sample_intrp.py => interpolated.py} | 62 +- sample_scf/sample_exact.py | 387 ------------ sample_scf/tests/common.py | 156 +++++ sample_scf/tests/test_base.py | 38 +- sample_scf/tests/test_core.py | 2 +- sample_scf/tests/test_exact.py | 592 ++++++++++++++++++ sample_scf/tests/test_init.py | 10 +- ...t_sample_intrp.py => test_interpolated.py} | 188 ++---- sample_scf/tests/test_sample_exact.py | 529 ---------------- sample_scf/tests/test_utils.py | 10 +- sample_scf/utils.py | 10 +- 16 files changed, 1456 insertions(+), 1147 deletions(-) create mode 100644 sample_scf/exact.py rename sample_scf/{sample_intrp.py => interpolated.py} (89%) delete mode 100644 sample_scf/sample_exact.py create mode 100644 sample_scf/tests/common.py create mode 100644 sample_scf/tests/test_exact.py rename sample_scf/tests/{test_sample_intrp.py => test_interpolated.py} (75%) delete mode 100644 sample_scf/tests/test_sample_exact.py diff --git a/README.rst b/README.rst index 87d3923..bb891e1 100644 --- a/README.rst +++ b/README.rst @@ -13,8 +13,8 @@ Self-Consistent Field (SCF). License ------- -This project is Copyright (c) nathaniel starkman and licensed under -the terms of the BSD 3-Clause license. This package is based upon +This project is Copyright (c) Nathaniel Starkman and Maintainers and licensed +under the terms of the BSD 3-Clause license. This package is based upon the `Astropy package template `_ which is licensed under the BSD 3-clause license. See the licenses folder for more information. @@ -25,29 +25,3 @@ Contributing We love contributions! sampleSCF is open source, built on open source, and we'd love to have you hang out in our community. - -**Imposter syndrome disclaimer**: We want your help. No, really. - -There may be a little voice inside your head that is telling you that you're not -ready to be an open source contributor; that your skills aren't nearly good -enough to contribute. What could you possibly offer a project like this one? - -We assure you - the little voice in your head is wrong. If you can write code at -all, you can contribute code to open source. Contributing to open source -projects is a fantastic way to advance one's coding skills. Writing perfect code -isn't the measure of a good developer (that would disqualify all of us!); it's -trying to create something, making mistakes, and learning from those -mistakes. That's how we all improve, and we are happy to help others learn. - -Being an open source contributor doesn't just mean writing code, either. You can -help out by writing documentation, tests, or even giving feedback about the -project (and yes - that includes giving feedback about the contribution -process). Some of these contributions may be the most valuable to the project as -a whole, because you're coming to the project with fresh eyes, so you can see -the errors and assumptions that seasoned contributors have glossed over. - -Note: This disclaimer was originally written by -`Adrienne Lowe `_ for a -`PyCon talk `_, and was adapted by -sampleSCF based on its use in the README file for the -`MetPy project `_. diff --git a/sample_scf/__init__.py b/sample_scf/__init__.py index 2f3702f..c31a545 100644 --- a/sample_scf/__init__.py +++ b/sample_scf/__init__.py @@ -4,7 +4,7 @@ # LOCAL from sample_scf._astropy_init import * # isort: +split # noqa: F401, F403 from sample_scf.core import SCFSampler -from sample_scf.sample_exact import SCFSampler as SCFSamplerExact -from sample_scf.sample_intrp import SCFSampler as SCFSamplerInterp +from sample_scf.exact import SCFSampler as SCFSamplerExact +from sample_scf.interpolated import SCFSampler as SCFSamplerInterp __all__ = ["SCFSampler", "SCFSamplerExact", "SCFSamplerInterp"] diff --git a/sample_scf/base.py b/sample_scf/base.py index 9924d5f..dc56e70 100644 --- a/sample_scf/base.py +++ b/sample_scf/base.py @@ -13,12 +13,14 @@ # BUILT-IN import typing as T +from abc import ABCMeta # THIRD PARTY import astropy.units as u import numpy as np import numpy.typing as npt from astropy.coordinates import PhysicsSphericalRepresentation +from astropy.utils.misc import NumpyRNGContext from galpy.potential import SCFPotential from scipy._lib._util import check_random_state from scipy.stats import rv_continuous @@ -34,7 +36,7 @@ ############################################################################## -class rv_potential(rv_continuous): +class rv_potential(rv_continuous, metaclass=ABCMeta): """ Modified :class:`scipy.stats.rv_continuous` to use custom rvs methods. Made by stripping down the original scipy implementation. @@ -201,7 +203,11 @@ def cdf( # /def def rvs( - self, *, size: T.Optional[int] = None, random_state: RandomLike = None + self, + *, + size: T.Optional[int] = None, + random_state: RandomLike = None, + vectorized=True, ) -> PhysicsSphericalRepresentation: """Sample random variates. @@ -220,8 +226,26 @@ def rvs( `~astropy.coordinates.PhysicsSphericalRepresentation` """ rs = self.rsampler.rvs(size=size, random_state=random_state) - thetas = self.thetasampler.rvs(rs, size=size, random_state=random_state) - phis = self.phisampler.rvs(rs, thetas, size=size, random_state=random_state) + + if vectorized: + thetas = self.thetasampler.rvs(rs, size=size, random_state=random_state) + phis = self.phisampler.rvs(rs, thetas, size=size, random_state=random_state) + + else: + # TODO! speed up + with NumpyRNGContext(random_state): + thetas = np.array( + [ + self.thetasampler.rvs(r, size=1, random_state=None) + for r in np.atleast_1d(rs) + ], + ) + phis = np.array( + [ + self.phisampler.rvs(r, th, size=1, random_state=None) + for r, th in zip(np.atleast_1d(rs), np.atleast_1d(thetas)) + ], + ) crd = PhysicsSphericalRepresentation( r=rs, diff --git a/sample_scf/core.py b/sample_scf/core.py index 7e049d4..d52c2e5 100644 --- a/sample_scf/core.py +++ b/sample_scf/core.py @@ -20,8 +20,8 @@ # LOCAL from .base import SCFSamplerBase, rv_potential -from .sample_exact import SCFSampler as SCFSamplerExact -from .sample_intrp import SCFSampler as SCFSamplerIntrp +from .exact import SCFSampler as SCFSamplerExact +from .interpolated import SCFSampler as SCFSamplerInterp __all__: T.List[str] = ["SCFSampler"] @@ -54,13 +54,13 @@ class MethodsMapping(T.TypedDict): # # if method == "interp": # # LOCAL -# from sample_scf.sample_intrp import SCFSampler as interpcls +# from sample_scf.interpolated import SCFSampler as interpcls # # bases = (interpcls,) # # elif method == "exact": # # LOCAL -# from sample_scf.sample_exact import SCFSampler as exactcls +# from sample_scf.exact import SCFSampler as exactcls # # bases = (exactcls,) # elif isinstance(method, Mapping): @@ -110,7 +110,7 @@ def __init__( else: sampler_cls: T.Type[SCFSamplerBase] if method == "interp": - sampler_cls = SCFSamplerIntrp + sampler_cls = SCFSamplerInterp elif method == "exact": sampler_cls = SCFSamplerExact diff --git a/sample_scf/exact.py b/sample_scf/exact.py new file mode 100644 index 0000000..988eab3 --- /dev/null +++ b/sample_scf/exact.py @@ -0,0 +1,543 @@ +# -*- coding: utf-8 -*- + +"""**DOCSTRING**. + +Description. + +""" + +############################################################################## +# IMPORTS + +from __future__ import annotations + +# BUILT-IN +import abc +import typing as T + +# THIRD PARTY +import astropy.units as u +import numpy as np +import numpy.typing as npt +from astropy.coordinates import PhysicsSphericalRepresentation +from galpy.potential import SCFPotential + +# LOCAL +from ._typing import NDArray64, RandomLike +from .base import SCFSamplerBase, rv_potential +from .utils import difPls, phiRSms, theta_of_x, thetaQls, x_of_theta + +__all__: T.List[str] = [ + "SCFSampler", + "SCFRSampler", + "SCFThetaFixedSampler", + "SCFThetaSampler", + "SCFPhiFixedSampler", + "SCFPhiSampler", +] + + +############################################################################## +# CODE +############################################################################## + + +class SCFSampler(SCFSamplerBase): + """SCF sampler in spherical coordinates. + + The coordinate system is: + - r : [0, infinity) + - theta : [-pi/2, pi/2] (positive at the North pole) + - phi : [0, 2pi) + + Parameters + ---------- + pot : `~galpy.potential.SCFPotential` + **kw + Not used. + + """ + + def __init__(self, potential: SCFPotential, **kw: T.Any) -> None: + total_mass = kw.pop("total_mass", None) + # make samplers + self._rsampler = SCFRSampler(potential, total_mass=total_mass, **kw) + self._thetasampler = SCFThetaSampler(potential, **kw) # r=None + self._phisampler = SCFPhiSampler(potential, **kw) # r=None, theta=None + + # /def + + def rvs( + self, *, size: T.Optional[int] = None, random_state: RandomLike = None + ) -> PhysicsSphericalRepresentation: + """Sample random variates. + + Parameters + ---------- + size : int or None (optional, keyword-only) + Defining number of random variates. + random_state : int, `~numpy.random.RandomState`, or None (optional, keyword-only) + If seed is None (or numpy.random), the `numpy.random.RandomState` + singleton is used. If seed is an int, a new RandomState instance is + used, seeded with seed. If seed is already a Generator or + RandomState instance then that instance is used. + + Returns + ------- + `~astropy.coordinates.PhysicsSphericalRepresentation` + """ + return super().rvs(size=size, random_state=random_state, vectorized=False) + + # /def + + +# /class + + +# ------------------------------------------------------------------- +# radial sampler + + +class SCFRSampler(rv_potential): + """Sample radial coordinate from an SCF potential. + + Parameters + ---------- + pot : `~galpy.potential.SCFPotential` + A potential that can be used to calculate the enclosed mass. + **kw + Not used. + """ + + def __init__(self, potential: SCFPotential, total_mass=None, **kw: T.Any) -> None: + # make sampler + kw["a"], kw["b"] = 0, np.inf # allowed range of r + super().__init__(potential, **kw) + + # normalization for total mass + if total_mass is None: + total_mass = potential._mass(np.inf) + if np.isnan(total_mass): + raise ValueError( + "Total mass is NaN. Need to pass kwarg " "`total_mass` with a non-NaN value.", + ) + self._mtot = total_mass + # vectorize mass function, which is scalar + self._vec_cdf = np.vectorize(self._potential._mass) + + # /def + + def _cdf(self, r: npt.ArrayLike, *args: T.Any, **kw: T.Any) -> NDArray64: + """Cumulative Distribution Function. + + Parameters + ---------- + r : array-like + *args + **kwargs + + Returns + ------- + mass : array-like + Shape matches 'r'. + """ + mass: NDArray64 = np.atleast_1d(self._vec_cdf(r)) / self._mtot + mass[r == 0] = 0 + mass[r == np.inf] = 1 + return mass.item() if mass.shape == (1,) else mass + + cdf = _cdf + # /def + + +# /class + +############################################################################## +# Inclination sampler + + +class SCFThetaSamplerBase(rv_potential): + """Base class for sampling the inclination coordinate.""" + + def __init__(self, potential: SCFPotential, **kw: T.Any) -> None: + kw["a"], kw["b"] = -np.pi / 2, np.pi / 2 # allowed range of theta + super().__init__(potential, **kw) + self._lrange = np.arange(0, self._lmax + 1) # lmax inclusive + + # /def + + @abc.abstractmethod + def _cdf(self, x: NDArray64, Qls: NDArray64) -> NDArray64: + xs = np.atleast_1d(x) + Qls = np.atleast_2d(Qls) + + # l = 0 + term0 = 0.5 * (xs + 1.0) # (T,) + # l = 1+ : non-symmetry + factor = 1.0 / (2.0 * Qls[:, 0]) # (R,) + term1p = np.sum( + (Qls[None, :, 1:] * difPls(xs, self._lmax - 1).T[:, None, :]).T, + axis=0, + ) + + cdf = term0[None, :] + np.nan_to_num(factor[:, None] * term1p) # (R, T) + return cdf + + # /def + + def _rvs( + self, + *args: T.Union[float, npt.ArrayLike], + size: T.Optional[int] = None, + random_state: RandomLike = None, + ) -> NDArray64: + xs = super()._rvs(*args, size=size, random_state=random_state) + rvs = theta_of_x(xs) + return rvs + + # /def + + def _ppf_to_solve(self, x: float, q: float, *args: T.Any) -> NDArray64: + ppf: NDArray64 = self._cdf(*(x,) + args) - q + return ppf + + # /def + + +# /class + + +class SCFThetaFixedSampler(SCFThetaSamplerBase): + """ + Sample inclination coordinate from an SCF potential. + + Parameters + ---------- + pot : `~galpy.potential.SCFPotential` + r : float or None, optional + If passed, these are the locations at which the theta CDF will be + evaluated. If None (default), then the r coordinate must be given + to the CDF and RVS functions. + **kw: + Not used. + """ + + def __init__(self, potential: SCFPotential, r: float, **kw: T.Any) -> None: + super().__init__(potential) + + # points at which CDF is defined + self._r = r + self._Qlsatr = thetaQls(self._potential, r) + + # /def + + def _cdf(self, x: npt.ArrayLike, *args: T.Any) -> NDArray64: + cdf = super()._cdf(x, self._Qlsatr) + return cdf + + # /def + + def cdf(self, theta: npt.ArrayLike) -> NDArray64: + """ + Cumulative distribution function of the given RV. + + Parameters + ---------- + theta : quantity-like['angle'] + + Returns + ------- + cdf : ndarray + Cumulative distribution function evaluated at `theta` + + """ + return self._cdf(x_of_theta(u.Quantity(theta, u.rad).value)) + + # /def + + +# /class + + +class SCFThetaSampler(SCFThetaSamplerBase): + """ + Sample inclination coordinate from an SCF potential. + + Parameters + ---------- + pot : `~galpy.potential.SCFPotential` + + """ + + def _cdf(self, theta: NDArray64, *args: T.Any, r: T.Optional[float] = None) -> NDArray64: + Qls = thetaQls(self._potential, T.cast(float, r)) + cdf = super()._cdf(theta, Qls) + return cdf + + # /def + + def cdf(self, theta: npt.ArrayLike, *args: T.Any, r: float) -> NDArray64: + """ + Cumulative distribution function of the given RV. + + Parameters + ---------- + theta : quantity-like['angle'] + *args + Not used. + r : array-like[float] (optional, keyword-only) + + Returns + ------- + cdf : ndarray + Cumulative distribution function evaluated at `theta` + + """ + return self._cdf(x_of_theta(u.Quantity(theta, u.rad).value), *args, r=r) + + # /def + + def rvs( + self, r: npt.ArrayLike, *, size: T.Optional[int] = None, random_state: RandomLike = None + ) -> NDArray64: + # not thread safe! + getattr(self._cdf, "__kwdefaults__", {})["r"] = r + vals = super().rvs(size=size, random_state=random_state) + getattr(self._cdf, "__kwdefaults__", {})["r"] = None + return vals + + # /def + + +# /class + + +############################################################################### +# Azimuth sampler + + +class SCFPhiSamplerBase(rv_potential): + """Sample Azimuthal Coordinate. + + Parameters + ---------- + potential : `galpy.potential.SCFPotential` + **kw + Passed to `scipy.stats.rv_continuous` + "a", "b" are set to [0, 2 pi] + + """ + + def __init__(self, potential: SCFPotential, **kw: T.Any) -> None: + kw["a"], kw["b"] = 0, 2 * np.pi + super().__init__(potential, **kw) + self._lrange = np.arange(0, self._lmax + 1) + + # for compatibility + self._Rm: T.Optional[NDArray64] = None + self._Sm: T.Optional[NDArray64] = None + + # /def + + def _cdf(self, phi: NDArray64, *args: T.Any, **kw: T.Any) -> NDArray64: + """Cumulative Distribution Function. + + Parameters + ---------- + phi : float or ndarray[float] ['radian'] + Azimuthal coordinate in radians, :math:`\in [0, 2\pi]`. + *args + **kw + + Returns + ------- + cdf : float or ndarray[float] + Shape (len(r), len(theta), len(phi)). + :meth:`numpy.ndarray.squeeze` applied so scalar inputs has scalar + output. + + """ + Rm, Sm = kw.get("RSms", (self._Rm, self._Sm)) # (len(r), len(theta), L) + + Phis: NDArray64 = np.atleast_1d(phi)[None, None, :, None] # ({R}, {T}, P, {L}) + + # l = 0 : spherical symmetry + term0: NDArray64 = Phis[..., 0] / (2 * np.pi) # (1, 1, P) + + # l = 1+ : non-symmetry + factor = 1 / Rm[:, :, :1] # R0 (R, T, 1) # can be inf + ms = np.arange(1, self._lmax)[None, None, None, :] # ({R}, {T}, {P}, L) + term1p = np.sum( + (Rm[:, :, None, 1:] * np.sin(ms * Phis) + Sm[:, :, None, 1:] * (1 - np.cos(ms * Phis))) + / (2 * np.pi * ms), + axis=-1, + ) + + cdf: NDArray64 = term0 + np.nan_to_num(factor * term1p) # (R, T, P) + # 'factor' can be inf and term1p 0 => inf * 0 = nan -> 0 + + # return cdf, squeezed of extra dimensions so scalar phi -> scalar + return cdf.squeeze() + + # /def + + def _ppf_to_solve(self, phi: float, q: float, *args: T.Any) -> NDArray64: + # changed from .cdf() to ._cdf() to use default 'r', 'theta' + return self._cdf(*(phi,) + args) - q + + # /def + + +# /class + + +class SCFPhiFixedSampler(SCFPhiSamplerBase): + """Sample Azimuthal Coordinate at fixed r, theta. + + Parameters + ---------- + potential : `galpy.potential.SCFPotential` + r, theta : float or ndarray[float] + + """ + + def __init__( + self, potential: SCFPotential, r: NDArray64, theta: NDArray64, **kw: T.Any + ) -> None: + super().__init__(potential, **kw) + + # assign fixed r, theta + self._r, self._theta = r, theta + # and can compute the associated assymetry measures + self._Rm, self._Sm = phiRSms(potential, r, theta) + + # /def + + def cdf(self, phi: NDArray64, *args: T.Any, **kw: T.Any) -> NDArray64: + """Cumulative Distribution Function. + + Parameters + ---------- + phi : float or ndarray[float] ['radian'] + Azimuthal coordinate in radians, :math:`\in [0, 2\pi]`. + *args + **kw + + Returns + ------- + cdf : float or ndarray[float] + Shape (len(r), len(theta), len(phi)). + :meth:`numpy.ndarray.squeeze` applied so scalar inputs has scalar + output. + """ + return self._cdf(phi, *args, **kw) + + # /def + + +# /class + + +class SCFPhiSampler(SCFPhiSamplerBase): + def _cdf( + self, + phi: npt.ArrayLike, + *args: T.Any, + r: T.Optional[float] = None, + theta: T.Optional[float] = None, + ) -> NDArray64: + """Cumulative Distribution Function. + + Parameters + ---------- + phi : float or ndarray[float] ['radian'] + Azimuthal coordinate in radians, :math:`\in [0, 2\pi]`. + *args + r : float or ndarray[float], keyword-only + Radial coordinate at which to evaluate the CDF. Not optional. + theta : float or ndarray[float], keyword-only + Inclination coordinate at which to evaluate the CDF. Not optional. + In [-pi/2, pi/2]. + + Returns + ------- + cdf : float or ndarray[float] + Shape (len(r), len(theta), len(phi)). + :meth:`numpy.ndarray.squeeze` applied so scalar inputs has scalar + output. + + Raises + ------ + ValueError + If 'r' or 'theta' are None. + """ + RSms = phiRSms(self._potential, T.cast(float, r), T.cast(float, theta)) + cdf: NDArray64 = super()._cdf(phi, *args, RSms=RSms) + return cdf + + # /def + + def cdf(self, phi: npt.ArrayLike, *args: T.Any, r: float, theta: float) -> NDArray64: + """Cumulative Distribution Function. + + Parameters + ---------- + phi : quantity-like or array-like ['radian'] + Azimuthal angular coordinate, :math:`\in [0, 2\pi]`. If doesn't + have units, must be in radians. + *args + r : float or ndarray[float], keyword-only + Radial coordinate at which to evaluate the CDF. Not optional. + theta : quantity-like or array-like ['radian'], keyword-only + Inclination coordinate at which to evaluate the CDF. Not optional. + In [-pi/2, pi/2]. If doesn't have units, must be in radians. + + Returns + ------- + cdf : float or ndarray[float] + Shape (len(r), len(theta), len(phi)). + :meth:`numpy.ndarray.squeeze` applied so scalar inputs has scalar + output. + """ + phi = u.Quantity(phi, u.rad).value + cdf: NDArray64 = self._cdf(phi, *args, r=r, theta=u.Quantity(theta, u.rad).value) + return cdf + + # /def + + def rvs( # type: ignore + self, + r: float, + theta: float, + *, + size: T.Optional[int] = None, + random_state: RandomLike = None, + ) -> NDArray64: + """Random Variate Sample. + + Parameters + ---------- + r : float + theta : float + size : int or None (optional, keyword-only) + random_state : int or `numpy.random.RandomState` or None (optional, keyword-only) + + Returns + ------- + vals : ndarray[float] + + """ + getattr(self._cdf, "__kwdefaults__", {})["r"] = r + getattr(self._cdf, "__kwdefaults__", {})["theta"] = theta + vals = super().rvs(size=size, random_state=random_state) + getattr(self._cdf, "__kwdefaults__", {})["r"] = None + getattr(self._cdf, "__kwdefaults__", {})["theta"] = None + return vals + + # /def + + +# /class + +############################################################################## +# END diff --git a/sample_scf/sample_intrp.py b/sample_scf/interpolated.py similarity index 89% rename from sample_scf/sample_intrp.py rename to sample_scf/interpolated.py index 66e8166..ad345dc 100644 --- a/sample_scf/sample_intrp.py +++ b/sample_scf/interpolated.py @@ -34,7 +34,12 @@ from .base import SCFSamplerBase, rv_potential from .utils import _phiRSms, difPls, phiRSms, r_of_zeta, thetaQls, x_of_theta, zeta_of_r -__all__: T.List[str] = ["SCFSampler", "SCFRSampler", "SCFThetaSampler", "SCFPhiSampler"] +__all__: T.List[str] = [ + "SCFSampler", + "SCFRSampler", + "SCFThetaSampler", + "SCFPhiSampler", +] ############################################################################## @@ -88,48 +93,48 @@ class SCFSampler(SCFSamplerBase): Now we can evaluate the CDF >>> sampler.cdf(10, np.pi/3, np.pi) - array([[0.82644628, 0.9330127 , 0.5 ]]) + array([[0.82666461, 0.9330127 , 0.5 ]]) And draw samples >>> sampler.rvs(size=5, random_state=3) + [(3.46076529, 1.46902493, 2.90496213), + (4.44942399, 1.141429 , 5.33343759), + (1.82780838, 2.0022487 , 1.19407968), + (3.2096245 , 1.54913942, 2.53149096), + (5.61055118, 0.66665592, 17.0125581 )]> """ def __init__( self, - pot: SCFPotential, + potential: SCFPotential, rgrid: NDArray64, thetagrid: NDArray64, phigrid: NDArray64, **kw: T.Any, ) -> None: # compute the r-dependent coefficient matrix $\tilde{\rho}$ - nmax, lmax = pot._Acos.shape[:2] + nmax, lmax = potential._Acos.shape[:2] rhoTilde = np.array( - [pot._rhoTilde(r, N=nmax, L=lmax) for r in rgrid], + [potential._rhoTilde(r, N=nmax, L=lmax) for r in rgrid], ) # (R, N, L) # ---------- # theta Qls # radial sums over $\cos$ portion of the density function # the $\sin$ part disappears in the integral. - Qls = np.sum(pot._Acos[None, :, :, 0] * rhoTilde, axis=1) # ({R}, L) + Qls = np.sum(potential._Acos[None, :, :, 0] * rhoTilde, axis=1) # ({R}, L) # ---------- # phi Rm, Sm # radial and inclination sums - Rm, Sm = _phiRSms( + RSms = _phiRSms( rhoTilde, - Acos=pot._Acos, - Asin=pot._Asin, + Acos=potential._Acos, + Asin=potential._Asin, r=rgrid, theta=thetagrid, ) @@ -137,9 +142,9 @@ def __init__( # ---------- # make samplers - self._rsampler = SCFRSampler(pot, rgrid, **kw) - self._thetasampler = SCFThetaSampler(pot, rgrid, thetagrid, Qls=Qls, **kw) - self._phisampler = SCFPhiSampler(pot, rgrid, thetagrid, phigrid, RSms=(Rm, Sm), **kw) + self._rsampler = SCFRSampler(potential, rgrid, **kw) + self._thetasampler = SCFThetaSampler(potential, rgrid, thetagrid, Qls=Qls, **kw) + self._phisampler = SCFPhiSampler(potential, rgrid, thetagrid, phigrid, RSms=RSms, **kw) # /def @@ -165,14 +170,18 @@ class SCFRSampler(rv_potential): """ def __init__(self, potential: SCFPotential, rgrid: NDArray64, **kw: T.Any) -> None: - kw["a"], kw["b"] = 0, np.inf # allowed range of r + kw["a"], kw["b"] = 0, np.nanmax(rgrid) # allowed range of r super().__init__(potential, **kw) mgrid = np.array([potential._mass(x) for x in rgrid]) # :( - # manual fixes for endpoints + # manual fixes for endpoints and normalization ind = np.where(np.isnan(mgrid))[0] mgrid[ind[rgrid[ind] == 0]] = 0 - mgrid[ind[rgrid[ind] == np.inf]] = 1 + mgrid = (mgrid - np.nanmin(mgrid)) / (np.nanmax(mgrid) - np.nanmin(mgrid)) # rescale + infind = ind[rgrid[ind] == np.inf].squeeze() + mgrid[infind] = 1 + if mgrid[infind - 1] == 1: # munge the rescaling TODO! do better + mgrid[infind - 1] -= min(1e-8, np.diff(mgrid[slice(infind - 2, infind)]) / 2) # work in zeta, not r, since it is more numerically stable zeta = zeta_of_r(rgrid) @@ -261,7 +270,7 @@ def __init__( raise ValueError(f"Qls must be shape ({len(rgrid)}, {self._lmax})") # l = 0 : spherical symmetry - term0 = T.cast(NDArray64, 0.5 * (xs + 1)) # (T,) + term0 = T.cast(NDArray64, 0.5 * (xs + 1.0)) # (T,) # l = 1+ : non-symmetry factor = 1.0 / (2.0 * Qls[:, 0]) # (R,) term1p = np.sum( @@ -404,7 +413,7 @@ def rvs( # type: ignore # /def -# ------------------------------------------------------------------- +############################################################################### # Azimuth sampler @@ -460,15 +469,18 @@ def __init__( raise ValueError(f"Rm, Sm must be shape ({lR}, {lT}, {self._lmax})") # l = 0 : spherical symmetry - term0 = pgrid[None, None, :] / (2 * np.pi) # (1, 1, P) + term0 = Phis[..., 0] / (2 * np.pi) # (1, 1, P) # l = 1+ : non-symmetry with warnings.catch_warnings(): # ignore true_divide RuntimeWarnings warnings.simplefilter("ignore") factor = 1 / Rm[:, :, :1] # R0 (R, T, 1) # can be inf - ms = np.arange(1, self._lmax)[None, None, None, :] # (1, 1, 1, L) + ms = np.arange(1, self._lmax)[None, None, None, :] # ({R}, {T}, {P}, L) term1p = np.sum( - (Rm[:, :, None, 1:] * np.sin(ms * Phis) + Sm[:, :, None, 1:] * (1 - np.cos(ms * Phis))) + ( + (Rm[:, :, None, 1:] * np.sin(ms * Phis)) + + (Sm[:, :, None, 1:] * (1 - np.cos(ms * Phis))) + ) / (2 * np.pi * ms), axis=-1, ) diff --git a/sample_scf/sample_exact.py b/sample_scf/sample_exact.py deleted file mode 100644 index 0272da6..0000000 --- a/sample_scf/sample_exact.py +++ /dev/null @@ -1,387 +0,0 @@ -# -*- coding: utf-8 -*- - -"""**DOCSTRING**. - -Description. - -""" - -############################################################################## -# IMPORTS - -from __future__ import annotations - -# BUILT-IN -import typing as T - -# THIRD PARTY -import astropy.units as u -import numpy as np -import numpy.typing as npt -from galpy.potential import SCFPotential - -# LOCAL -from ._typing import NDArray64, RandomLike -from .base import SCFSamplerBase, rv_potential -from .utils import difPls, phiRSms, thetaQls, x_of_theta - -__all__: T.List[str] = ["SCFSampler", "SCFRSampler", "SCFThetaSampler", "SCFPhiSampler"] - - -############################################################################## -# PARAMETERS - -TSCFPhi = T.TypeVar("TSCFPhi", bound="SCFPhiSampler") -TSCFThetaSamplerBase = T.TypeVar("TSCFThetaSamplerBase", bound="SCFThetaSamplerBase") - -############################################################################## -# CODE -############################################################################## - - -class SCFSampler(SCFSamplerBase): - """SCF sampler in spherical coordinates. - - The coordinate system is: - - r : [0, infinity) - - theta : [-pi/2, pi/2] (positive at the North pole) - - phi : [0, 2pi) - - Parameters - ---------- - pot : `~galpy.potential.SCFPotential` - **kw - Not used. - - """ - - def __init__(self, potential: SCFPotential, **kw: T.Any) -> None: - self._rsampler = SCFRSampler(potential) - # not fixed r, theta. slower! - self._thetasampler = SCFThetaSampler_of_r(potential) # r=None - self._phisampler = SCFPhiSampler_of_rtheta(potential) # r=None, theta=None - - # /def - - -# /class - - -# ------------------------------------------------------------------- -# radial sampler - - -class SCFRSampler(rv_potential): - """Sample radial coordinate from an SCF potential. - - Parameters - ---------- - pot : `~galpy.potential.SCFPotential` - A potential that can be used to calculate the enclosed mass. - **kw - Not used. - """ - - def __init__(self, potential: SCFPotential, **kw: T.Any) -> None: - kw["a"], kw["b"] = 0, np.inf # allowed range of r - super().__init__(potential, **kw) - - # /def - - def _cdf(self, r: npt.ArrayLike, *args: T.Any, **kw: T.Any) -> NDArray64: - mass: NDArray64 = self._potential._mass(r) - # (self._scfmass(zeta) - self._mi) / (self._mf - self._mi) - # TODO! is this normalization even necessary? - return mass - - # /def - - -# /class - -# ------------------------------------------------------------------- -# inclination sampler - - -class SCFThetaSamplerBase(rv_potential): - def __new__( - cls: T.Type[TSCFThetaSamplerBase], - potential: SCFPotential, - r: T.Optional[float] = None, - **kw: T.Any - ) -> TSCFThetaSamplerBase: - if cls is SCFThetaSampler and r is None: - cls = SCFThetaSampler_of_r - - self: TSCFThetaSamplerBase = super().__new__(cls) - return self - - # /def - - def __init__(self, potential: SCFPotential, **kw: T.Any) -> None: - kw["a"], kw["b"] = -np.pi / 2, np.pi / 2 # allowed range of theta - super().__init__(potential, **kw) - self._lrange = np.arange(0, self._lmax + 1) # lmax inclusive - - # /def - - # @functools.lru_cache() - def Qls(self, r: float) -> NDArray64: - r""" - :math:`Q_l(r) = \sum_{n=0}^{n_{\max}}A_{nl} \tilde{\rho}_{nl0}(r)` - - Parameters - ---------- - r : float ['kpc'] - - Returns - ------- - Ql : ndarray - - """ - Qls: NDArray64 = thetaQls(self._potential, r) - return Qls - - # /def - - def _ppf_to_solve(self, x: float, q: float, *args: T.Any) -> NDArray64: - ppf: NDArray64 = self._cdf(*(x,) + args) - q # FIXME? x or theta - return ppf - - # /def - - -# /class - - -class SCFThetaSampler(SCFThetaSamplerBase): - """ - Sample inclination coordinate from an SCF potential. - - Parameters - ---------- - pot : `~galpy.potential.SCFPotential` - r : float or None, optional - If passed, these are the locations at which the theta CDF will be - evaluated. If None (default), then the r coordinate must be given - to the CDF and RVS functions. - **kw: - Not used. - """ - - def __init__(self, potential: SCFPotential, r: float, **kw: T.Any) -> None: - super().__init__(potential) - - # points at which CDF is defined - self._r = r - self._Qlsatr = self.Qls(r) - - # /def - - def _cdf(self, theta: npt.ArrayLike, *args: T.Any) -> NDArray64: - """ - Cumulative distribution function of the given RV. - - Parameters - ---------- - theta : array-like ['radian'] - r : array-like [float] (optional, keyword-only) - - Returns - ------- - cdf : ndarray - Cumulative distribution function evaluated at `theta` - - """ - x = x_of_theta(theta) - Qlsatr = self._Qlsatr - - # l = 0 - term0 = (1.0 + x) / 2.0 - # l = 1+ - factor = 1.0 / (2.0 * Qlsatr[0]) - term1p = np.sum((Qlsatr[None, 1:] * difPls(x, self._lmax - 1).T).T, axis=0) - - cdf: NDArray64 = term0 + factor * term1p - return cdf - - # /def - - -# /class - - -class SCFThetaSampler_of_r(SCFThetaSamplerBase): - def _cdf(self, theta: NDArray64, *args: T.Any, r: T.Optional[float] = None) -> NDArray64: - x = x_of_theta(theta) - Qlsatr = self.Qls(T.cast(float, r)) - - # l = 0 - term0 = (1.0 + x) / 2.0 - # l = 1+ - factor = 1.0 / (2.0 * Qlsatr[0]) - term1p = np.sum((Qlsatr[None, 1:] * difPls(x, self._lmax - 1).T).T, axis=0) - - cdf: NDArray64 = term0 + factor * term1p - return cdf - - # /def - - def cdf(self, theta: npt.ArrayLike, *args: T.Any, r: float) -> NDArray64: - return self._cdf(u.Quantity(theta, u.rad).value, *args, r=r) - - # /def - - def rvs( # type: ignore - self, r: npt.ArrayLike, *, size: T.Optional[int] = None, random_state: RandomLike = None - ) -> NDArray64: - # not thread safe! - getattr(self._cdf, "__kwdefaults__", {})["r"] = r - vals = super().rvs(size=size, random_state=random_state) - getattr(self._cdf, "__kwdefaults__", {})["r"] = None - return vals - - # /def - - -# /class - - -# ------------------------------------------------------------------- -# azimuth sampler - - -class SCFPhiSamplerBase(rv_potential): - def __new__( - cls: T.Type[TSCFPhi], - potential: SCFPotential, - r: T.Optional[float] = None, - theta: T.Optional[float] = None, - **kw: T.Any - ) -> TSCFPhi: - if cls is SCFPhiSampler and (r is None or theta is None): - cls = SCFPhiSampler_of_rtheta - - self: TSCFPhi = super().__new__(cls) - return self - - # /def - - def __init__(self, potential: SCFPotential, **kw: T.Any) -> None: - kw["a"], kw["b"] = 0, 2 * np.pi - super().__init__(potential, **kw) - self._lrange = np.arange(0, self._lmax + 1) - - # /def - - # @functools.lru_cache() - def RSms(self, r: float, theta: float) -> T.Tuple[NDArray64, NDArray64]: - return phiRSms(self._potential, r, theta) - - # /def - - def _cdf(self, phi: NDArray64, *args: T.Any, **kw: T.Any) -> NDArray64: - Rm = self._Rm - Sm = self._Sm - - # l = 0 - term0: NDArray64 = phi / (2 * np.pi) - - # l = 1+ - factor = 1 / Rm[0] # R0 - ms = np.arange(1, Rm.shape[1] if len(Rm.shape) > 1 else 2) - term1p = np.sum( - (Rm[1:] * np.sin(ms * phi) + Sm[1:] * (1 - np.cos(ms * phi))) / (2 * np.pi * ms), - ) - - cdf: NDArray64 = term0 + factor * term1p - return cdf - - # /def - - -# /class - - -class SCFPhiSampler(SCFPhiSamplerBase): - """ - - Parameters - ---------- - pot - r, theta : float, optional - - """ - - def __init__(self, potential: SCFPotential, r: float, theta: float, **kw: T.Any) -> None: - super().__init__(potential, **kw) - - self._r, self._theta = r, theta - self._Rm, self._Sm = self.RSms(float(r), float(theta)) - - # /def - - def cdf(self, phi: NDArray64, *args: T.Any, **kw: T.Any) -> NDArray64: - return self._cdf(phi, *args, **kw) - - # /def - - -# /class - - -class SCFPhiSampler_of_rtheta(SCFPhiSamplerBase): - _Rm: T.Optional[NDArray64] - _Sm: T.Optional[NDArray64] - - def _cdf( - self, - phi: npt.ArrayLike, - *args: T.Any, - r: T.Optional[float] = None, - theta: T.Optional[float] = None - ) -> NDArray64: - self._Rm, self._Sm = self.RSms(T.cast(float, r), T.cast(float, theta)) - cdf: NDArray64 = super()._cdf(phi, *args) - self._Rm, self._Sm = None, None - return cdf - - # /def - - def cdf(self, phi: npt.ArrayLike, *args: T.Any, r: float, theta: float) -> NDArray64: - phi = u.Quantity(phi, u.rad).value - cdf: NDArray64 = self._cdf(phi, *args, r=r, theta=theta) - return cdf - - # /def - - def rvs( # type: ignore - self, - r: float, - theta: float, - *, - size: T.Optional[int] = None, - random_state: RandomLike = None - ) -> NDArray64: - getattr(self._cdf, "__kwdefaults__", {})["r"] = r - getattr(self._cdf, "__kwdefaults__", {})["theta"] = theta - vals = super().rvs(size=size, random_state=random_state) - getattr(self._cdf, "__kwdefaults__", {})["r"] = None - getattr(self._cdf, "__kwdefaults__", {})["theta"] = None - return vals - - # /def - - def _ppf_to_solve(self, x: float, q: float, *args: T.Any) -> NDArray64: - # changed from .cdf() to ._cdf() to use default 'r' - r: float = getattr(self._cdf, "__kwdefaults__", {})["r"] - theta: float = getattr(self._cdf, "__kwdefaults__", {})["theta"] - return self._cdf(*(x,) + args, r=r, theta=theta) - q - - # /def - - -# /class - -############################################################################## -# END diff --git a/sample_scf/tests/common.py b/sample_scf/tests/common.py new file mode 100644 index 0000000..9cba6ae --- /dev/null +++ b/sample_scf/tests/common.py @@ -0,0 +1,156 @@ +# -*- coding: utf-8 -*- + +"""Common Test Codes.""" + + +############################################################################## +# IMPORTS + +# BUILT-IN +import time + +# THIRD PARTY +import numpy as np +import pytest +from numpy.testing import assert_allclose + +# LOCAL +from .test_base import Test_RVPotential as RVPotentialTest +from sample_scf import conftest + +############################################################################## +# CODE +############################################################################## + + +class SCFRSamplerTestBase(RVPotentialTest): + def test__cdf(self, sampler, r): + """Test :meth:`sample_scf.interpolated.SCFRSampler._cdf`.""" + # args and kwargs don't matter + assert_allclose(sampler._cdf(r), sampler._cdf(r, 10, test=14)) + + # /def + + def test__cdf_edge(self, sampler): + """Test :meth:`sample_scf.interpolated.SCFRSampler._cdf`.""" + assert np.isclose(sampler._cdf(0.0), 0.0, 1e-20) + assert np.isclose(sampler._cdf(np.inf), 1.0, 1e-20) + + # /def + + +# /class + + +class SCFThetaSamplerTestBase(RVPotentialTest): + def setup_class(self): + self.cls = None + self.cls_args = () + self.cls_kwargs = {} + self.cls_pot_kw = {} + + self.cdf_args = () + self.cdf_kwargs = {"r": 10} + + self.rvs_args = () + self.rvs_kwargs = {"r": 10} + + self.cdf_time_scale = 0 + self.rvs_time_scale = 0 + + self.theory = dict( + hernquist=conftest.hernquist_df, + ) + + # /def + + # =============================================================== + # Method Tests + + # =============================================================== + # Time Scaling Tests + + @pytest.mark.parametrize("size", [1, 10, 100, 1000, 10000]) + def test_cdf_time_scaling(self, sampler, size): + """Test that the time scales as X * size""" + x = np.linspace(-np.pi / 2, np.pi / 2, size) + tic = time.perf_counter() + sampler.cdf(x, *self.cdf_args, **self.cdf_kwargs) + toc = time.perf_counter() + + assert (toc - tic) < self.cdf_time_scale * size # linear scaling + + # /def + + @pytest.mark.parametrize("size", [1, 10, 100, 1000, 10000]) + def test_rvs_time_scaling(self, sampler, size): + """Test that the time scales as X * size""" + tic = time.perf_counter() + sampler.rvs(size=size, *self.cdf_args, **self.cdf_kwargs) + toc = time.perf_counter() + + assert (toc - tic) < self.rvs_time_scale * size # linear scaling + + # /def + + +# /class + + +class SCFPhiSamplerTestBase(RVPotentialTest): + def setup_class(self): + self.cls = None + self.cls_args = () + self.cls_kwargs = {} + self.cls_pot_kw = {} + + self.cdf_args = () + self.cdf_kwargs = {"r": 10, "theta": np.pi / 6} + + self.rvs_args = () + self.rvs_kwargs = {"r": 10, "theta": np.pi / 6} + + self.cdf_time_scale = 0 + self.rvs_time_scale = 0 + + self.theory = dict( + hernquist=conftest.hernquist_df, + ) + + # /def + + # =============================================================== + # Method Tests + + # =============================================================== + # Time Scaling Tests + + @pytest.mark.parametrize("size", [1, 10, 100, 1000, 10000]) + def test_cdf_time_scaling(self, sampler, size): + """Test that the time scales as X * size""" + x = np.linspace(0, 2 * np.pi, size) + tic = time.perf_counter() + sampler.cdf(x, *self.cdf_args, **self.cdf_kwargs) + toc = time.perf_counter() + + assert (toc - tic) < self.cdf_time_scale * size # linear scaling + + # /def + + @pytest.mark.parametrize("size", [1, 10, 100, 1000, 10000]) + def test_rvs_time_scaling(self, sampler, size): + """Test that the time scales as X * size""" + tic = time.perf_counter() + sampler.rvs(size=size, *self.cdf_args, **self.cdf_kwargs) + toc = time.perf_counter() + + assert (toc - tic) < self.rvs_time_scale * size # linear scaling + + # /def + + +# /class + + +############################################################################## +# END diff --git a/sample_scf/tests/test_base.py b/sample_scf/tests/test_base.py index 24a17eb..6acc42e 100644 --- a/sample_scf/tests/test_base.py +++ b/sample_scf/tests/test_base.py @@ -53,10 +53,15 @@ class Test_RVPotential: def setup_class(self): self.cls = rvtestsampler self.cls_args = () + self.cls_kwargs = {} + self.cls_pot_kw = {} - self.cdf_args = (0,) + self.cdf_args = () self.cdf_kwargs = {} + self.rvs_args = () + self.rvs_kwargs = {} + self.cdf_time_scale = 3e-6 self.rvs_time_scale = 1e-4 @@ -65,7 +70,8 @@ def setup_class(self): @pytest.fixture(autouse=True, scope="class") def sampler(self, potentials): """Set up r, theta, or phi sampler.""" - sampler = self.cls(potentials, *self.cls_args) + kw = {**self.cls_kwargs, **self.cls_pot_kw.get(potentials, {})} + sampler = self.cls(potentials, *self.cls_args, **kw) return sampler @@ -110,7 +116,7 @@ def test_cdf_time_scaling(self, sampler, size): """Test that the time scales as X * size""" x = np.linspace(0, 1e4, size) tic = time.perf_counter() - sampler.cdf(x) + sampler.cdf(x, *self.cdf_args, **self.cdf_kwargs) toc = time.perf_counter() assert (toc - tic) < self.cdf_time_scale * size # linear scaling @@ -122,7 +128,7 @@ def test_cdf_time_scaling(self, sampler, size): def test_rvs_time_scaling(self, sampler, size): """Test that the time scales as X * size""" tic = time.perf_counter() - sampler.rvs(size=size) + sampler.rvs(size=size, *self.rvs_args, **self.rvs_kwargs) toc = time.perf_counter() assert (toc - tic) < self.rvs_time_scale * size # linear scaling @@ -159,6 +165,7 @@ def setup_class(self): @pytest.fixture(autouse=True, scope="class") def sampler(self, potentials): """Set up r, theta, & phi sampler.""" + sampler = self.cls(potentials, *self.cls_args, **self.cls_kwargs) sampler._rsampler = rvtestsampler(potentials) sampler._thetasampler = rvtestsampler(potentials) @@ -235,22 +242,25 @@ def test_rvs(self, sampler, id, size, random): class SCFSamplerTestBase(Test_SCFSamplerBase): def setup_class(self): - self.expected_rvs = { - 0: dict(r=0.548813503927, theta=1.021982822867 * u.rad, phi=0.548813503927 * u.rad), - 1: dict(r=0.548813503927, theta=1.021982822867 * u.rad, phi=0.548813503927 * u.rad), - 2: dict( - r=[0.9670298390136, 0.5472322491757, 0.9726843599648, 0.7148159936743], - theta=[0.603766487781, 1.023564077619, 0.598111966830, 0.855980333120] * u.rad, - phi=[0.9670298390136, 0.547232249175, 0.9726843599648, 0.7148159936743] * u.rad, - ), - } + self.cls_pot_kw = {} + + # self.expected_rvs = { + # 0: dict(r=0.548813503927, theta=1.021982822867 * u.rad, phi=0.548813503927 * u.rad), + # 1: dict(r=0.548813503927, theta=1.021982822867 * u.rad, phi=0.548813503927 * u.rad), + # 2: dict( + # r=[0.9670298390136, 0.5472322491757, 0.9726843599648, 0.7148159936743], + # theta=[0.603766487781, 1.023564077619, 0.598111966830, 0.855980333120] * u.rad, + # phi=[0.9670298390136, 0.547232249175, 0.9726843599648, 0.7148159936743] * u.rad, + # ), + # } # /def @pytest.fixture(autouse=True, scope="class") def sampler(self, potentials): """Set up r, theta, phi sampler.""" - sampler = self.cls(potentials, *self.cls_args) + kw = {**self.cls_kwargs, **self.cls_pot_kw.get(potentials, {})} + sampler = self.cls(potentials, *self.cls_args, **kw) return sampler diff --git a/sample_scf/tests/test_core.py b/sample_scf/tests/test_core.py index 3cb7a7f..dbd83a8 100644 --- a/sample_scf/tests/test_core.py +++ b/sample_scf/tests/test_core.py @@ -11,7 +11,7 @@ # LOCAL from .test_base import Test_SCFSamplerBase as SCFSamplerBaseTests -from .test_sample_intrp import pgrid, rgrid, tgrid +from .test_interpolated import pgrid, rgrid, tgrid from sample_scf import core ############################################################################## diff --git a/sample_scf/tests/test_exact.py b/sample_scf/tests/test_exact.py new file mode 100644 index 0000000..b367b4c --- /dev/null +++ b/sample_scf/tests/test_exact.py @@ -0,0 +1,592 @@ +# -*- coding: utf-8 -*- + +"""Tests for :mod:`sample_scf.exact`.""" + + +############################################################################## +# IMPORTS + +# BUILT-IN +import typing as T + +# THIRD PARTY +import astropy.units as u +import matplotlib.pyplot as plt +import numpy as np +import pytest +from astropy.utils.misc import NumpyRNGContext +from numpy.testing import assert_allclose + +# LOCAL +from .common import SCFPhiSamplerTestBase, SCFRSamplerTestBase, SCFThetaSamplerTestBase +from .test_base import SCFSamplerTestBase +from .test_base import Test_RVPotential as RVPotentialTest +from sample_scf import conftest, exact +from sample_scf.utils import r_of_zeta, theta_of_x, thetaQls, x_of_theta + +############################################################################## +# PARAMETERS + +rgrid = np.concatenate(([0], np.geomspace(1e-1, 1e3, 100))) +tgrid = np.linspace(-np.pi / 2, np.pi / 2, 30) +pgrid = np.linspace(0, 2 * np.pi, 30) + + +############################################################################## +# CODE +############################################################################## + + +class _request: + def __init__(self, param): + self.param = param + + +def getpot(name): + return next(conftest.potentials.__wrapped__(_request(name))) + + +class Test_SCFSampler(SCFSamplerTestBase): + """Test :class:`sample_scf.exact.SCFSampler`.""" + + def setup_class(self): + super().setup_class(self) + + self.cls = exact.SCFSampler + self.cls_args = () + self.cls_kwargs = {} + + # TODO! less hacky approach + self.cls_pot_kw = { + getpot("hernquist_scf_potential"): {"total_mass": 1.0}, + getpot("other_hernquist_scf_potential"): {"total_mass": 1.0}, + } + + # TODO! make sure these are right! + self.expected_rvs = { + 0: dict(r=2.85831468, theta=1.473013568997 * u.rad, phi=4.49366731864 * u.rad), + 1: dict(r=2.85831468, theta=1.473013568997 * u.rad, phi=4.49366731864 * u.rad), + 2: dict( + r=[59.156720319468995, 2.8424809956410684, 71.71466505619023, 5.471148006577435], + theta=[0.365179487932, 1.476190768288, 0.3320725403573, 1.126711132015] * u.rad, + phi=[4.383959499105, 1.3577303436664, 6.134113310024, 0.039145847961457] * u.rad, + ), + } + + # /def + + # =============================================================== + # Method Tests + + # TODO! make sure these are correct + @pytest.mark.parametrize( + "r, theta, phi, expected", + [ + (0.0, 0.0, 0.0, [0, 0.5, 0]), + (1.0, 0.0, 0.0, [0.25, 0.5, 0]), + # ([0.0, 1.0], [0.0, 0.0], [0.0, 0.0], [[0, 0.5, 0], [0.25, 0.5, 0]]), + ], + ) + def test_cdf(self, sampler, r, theta, phi, expected): + """Test :meth:`sample_scf.base.SCFSamplerBase.cdf`.""" + assert np.allclose(sampler.cdf(r, theta, phi), expected, atol=1e-16) + + # /def + + # =============================================================== + # Plot Tests + + @pytest.mark.skip("TODO!") + def test_exact_cdf_plot(self): + assert False + + # /def + + @pytest.mark.skip("TODO!") + def test_exact_sampling_plot(self): + assert False + + # /def + + +# /class + + +# ============================================================================ + + +class Test_SCFRSampler(SCFRSamplerTestBase): + """Test :class:`sample_scf.exact.SCFRSampler`""" + + def setup_class(self): + self.cls = exact.SCFRSampler + self.cls_args = () + self.cls_kwargs = {} + self.cls_pot_kw = { # TODO! less hacky approach + getpot("hernquist_scf_potential"): {"total_mass": 1.0}, + getpot("other_hernquist_scf_potential"): {"total_mass": 1.0}, + } + + self.cdf_time_scale = 1e-2 # milliseconds + self.rvs_time_scale = 1e-2 # milliseconds + + self.theory = dict( + hernquist=conftest.hernquist_df, + ) + + # /def + + @pytest.fixture(autouse=True, scope="class") + def sampler(self, potentials): + """Set up r, theta, or phi sampler.""" + kw = {**self.cls_kwargs, **self.cls_pot_kw.get(potentials, {})} + sampler = self.cls(potentials, *self.cls_args, **kw) + + return sampler + + # /def + + # =============================================================== + # Method Tests + + @pytest.mark.skip("TODO!") + def test___init__(self): + assert False + # test if mgrid is SCFPotential + + # TODO! use hypothesis + @pytest.mark.parametrize("r", np.random.default_rng(0).uniform(0, 1e4, 10)) + def test__cdf(self, sampler, r): + """Test :meth:`sample_scf.exact.SCFRSampler._cdf`.""" + super().test__cdf(sampler, r) + + # expected + mass = np.atleast_1d(sampler._potential._mass(r)) / sampler._mtot + assert_allclose(sampler._cdf(r), mass) + + # /def + + @pytest.mark.parametrize( + "size, random, expected", + [ + (None, 0, 2.85831468026), + (1, 2, 1.9437661234293), + ((3, 1), 4, [59.156720319468, 2.8424809956410, 71.71466505619]), + ((3, 1), None, [59.156720319468, 2.8424809956410, 71.71466505619]), + ], + ) + def test_rvs(self, sampler, size, random, expected): + """Test :meth:`sample_scf.exact.SCFRSampler.rvs`.""" + super().test_rvs(sampler, size, random, expected) + + # /def + + # =============================================================== + # Time Scaling Tests + + # TODO! generalize for subclasses + @pytest.mark.parametrize("size", [1, 10, 100, 1000]) # rm 1e4 + def test_rvs_time_scaling(self, sampler, size): + """Test that the time scales as X * size""" + super().test_rvs_time_scaling(sampler, size) + + # /def + + # =============================================================== + # Image Tests + + @pytest.mark.mpl_image_compare( + baseline_dir="baseline_images", + # hash_library="baseline_images/path_to_file.json", # TODO! + ) + def test_exact_r_cdf_plot(self, sampler): + fig = plt.figure(figsize=(10, 3)) + + ax = fig.add_subplot( + 111, + title=r"$m(\leq r) / m_{tot}$", + xlabel="r", + ylabel=r"$m(\leq r) / m_{tot}$", + ) + kw = dict(marker="o", ms=5, c="k", zorder=5, label="CDF") + ax.semilogx(rgrid, sampler.cdf(rgrid), **kw) + ax.axvline(0.0, c="tab:blue") + ax.axhline(sampler.cdf(0.0), c="tab:blue", label="r=0") + ax.axvline(1.0, c="tab:green") + ax.axhline(sampler.cdf(1.0), c="tab:green", label="r=1") + ax.axvline(1e2, c="tab:red") + ax.axhline(sampler.cdf(1e2), c="tab:red", label="r=100") + + ax.set_xlim((1e-1, None)) + ax.legend(loc="lower right") + + fig.tight_layout() + return fig + + # /def + + @pytest.mark.mpl_image_compare( + baseline_dir="baseline_images", + # hash_library="baseline_images/path_to_file.json", + ) + def test_exact_r_sampling_plot(self, request, sampler): + """Test sampling.""" + # fiqure out theory sampler + options = request.fixturenames[0] + if "hernquist" in options: + kind = "hernquist" + else: + raise ValueError + + with NumpyRNGContext(0): # control the random numbers + sample = sampler.rvs(size=int(1e3)) + sample = sample[sample < 1e4] + + theory = self.theory[kind].sample(n=int(1e6)).r() + theory = theory[theory < 1e4] + + fig = plt.figure(figsize=(10, 3)) + ax = fig.add_subplot(121, title="SCF vs theory sampling", xlabel="r", ylabel="frequency") + _, bins, *_ = ax.hist(sample, bins=30, log=True, alpha=0.5, label="SCF sample") + # Comparing to expected + ax.hist( + theory, + bins=bins, + log=True, + alpha=0.5, + label="Hernquist theoretical", + ) + ax.legend() + fig.tight_layout() + + return fig + + # /def + + +# /class + + +# ---------------------------------------------------------------------------- + + +class Test_SCFThetaSampler(SCFThetaSamplerTestBase): + """Test :class:`sample_scf.exact.SCFThetaSampler`.""" + + def setup_class(self): + super().setup_class(self) + + self.cls = exact.SCFThetaSampler + + self.cdf_time_scale = 1e-3 + self.rvs_time_scale = 7e-2 + + # /def + + # =============================================================== + # Method Tests + + # TODO! use hypothesis + + @pytest.mark.parametrize( + "x, r", + [ + *zip( + np.random.default_rng(1).uniform(-1, 1, 10), + r_of_zeta(np.random.default_rng(1).uniform(-1, 1, 10)), + ), + ], + ) + def test__cdf(self, sampler, x, r): + """Test :meth:`sample_scf.exact.SCFThetaSampler._cdf`.""" + Qls = np.atleast_2d(thetaQls(sampler._potential, r)) + + # basically a test it's Hernquist, only the first term matters + if np.allclose(Qls[:, 1:], 0.0): + assert_allclose(sampler._cdf(x, r=r), 0.5 * (x + 1.0)) + + else: + # TODO! a more robust test + + # l = 0 + term0 = 0.5 * (xs + 1.0) # (T,) + # l = 1+ : non-symmetry + factor = 1.0 / (2.0 * Qls[:, 0]) # (R,) + term1p = np.sum( + (Qls[None, :, 1:] * difPls(xs, self._lmax - 1).T[:, None, :]).T, + axis=0, + ) + cdf = term0[None, :] + np.nan_to_num(factor[:, None] * term1p) # (R, T) + + assert_allclose(sampler._cdf(x, r=r), cdf) + + # /def + + @pytest.mark.parametrize("r", r_of_zeta(np.random.default_rng(0).uniform(-1, 1, 10))) + def test__cdf_edge(self, sampler, r): + """Test :meth:`sample_scf.exact.SCFRSampler._cdf`.""" + assert np.isclose(sampler._cdf(-1, r=r), 0.0, atol=1e-16) + assert np.isclose(sampler._cdf(1, r=r), 1.0, atol=1e-16) + + # /def + + @pytest.mark.parametrize( + "theta, r", + [ + *zip( + np.random.default_rng(0).uniform(-np.pi / 2, np.pi / 2, 10), + np.random.default_rng(1).uniform(0, 1e4, 10), + ), + ], + ) + def test_cdf(self, sampler, theta, r): + """Test :meth:`sample_scf.exact.SCFThetaSampler.cdf`.""" + self.test__cdf(sampler, x_of_theta(theta), r) + + # /def + + @pytest.mark.skip("TODO!") + def test__rvs(self): + """Test :meth:`sample_scf.exact.SCFThetaSampler._rvs`.""" + assert False + + # /def + + @pytest.mark.skip("TODO!") + def test_rvs(self): + """Test :meth:`sample_scf.exact.SCFThetaSampler.rvs`.""" + assert False + + # /def + + # =============================================================== + # Time Scaling Tests + + # TODO! generalize for subclasses + @pytest.mark.parametrize("size", [1, 10, 100, 1000]) # rm 1e4 + def test_rvs_time_scaling(self, sampler, size): + """Test that the time scales as X * size""" + super().test_rvs_time_scaling(sampler, size) + + # /def + + # =============================================================== + # Image Tests + + @pytest.mark.mpl_image_compare( + baseline_dir="baseline_images", + # hash_library="baseline_images/path_to_file.json", # TODO! + ) + def test_exact_theta_cdf_plot(self, sampler): + fig = plt.figure(figsize=(10, 3)) + + # plot 1 + ax = fig.add_subplot( + 121, + title=r"CDF($\theta$)", + xlabel=r"$\theta$", + ylabel=r"CDF($\theta$)", + ) + kw = dict(marker="o", ms=5, c="k", zorder=5, label="CDF") + ax.plot(tgrid, sampler.cdf(tgrid, r=10)[0, :], **kw) + ax.axvline(-np.pi / 2, c="tab:blue") + ax.axhline(sampler.cdf(-np.pi / 2, r=10), c="tab:blue", label=r"$\theta=-\frac{\pi}{2}$") + ax.axvline(0, c="tab:green") + ax.axhline(sampler.cdf(0, r=10), c="tab:green", label=r"$\theta=0$") + ax.axvline(np.pi / 2, c="tab:red") + ax.axhline(sampler.cdf(np.pi / 2, r=10), c="tab:red", label=r"$\theta=\frac{\pi}{2}$") + ax.legend(loc="lower right") + + # plot 2 + ax = fig.add_subplot( + 122, + title=r"CDF($x$)", + xlabel=r"x$", + ylabel=r"CDF($x$)", + ) + ax.plot(x_of_theta(tgrid), sampler.cdf(tgrid, r=10)[0, :], **kw) + ax.axvline(x_of_theta(-1), c="tab:blue") + ax.axhline(sampler.cdf(-1, r=10), c="tab:blue", label=r"$\theta=-\frac{\pi}{2}$") + ax.axvline(x_of_theta(0), c="tab:green") + ax.axhline(sampler.cdf(0, r=10), c="tab:green", label=r"$\theta=0$") + ax.axvline(x_of_theta(1), c="tab:red") + ax.axhline(sampler.cdf(1, r=10), c="tab:red", label=r"$\theta=\frac{\pi}{2}$") + ax.legend(loc="upper left") + + fig.tight_layout() + return fig + + # /def + + @pytest.mark.mpl_image_compare( + baseline_dir="baseline_images", + # hash_library="baseline_images/path_to_file.json", + ) + def test_exact_theta_sampling_plot(self, request, sampler): + """Test sampling.""" + # fiqure out theory sampler + options = request.fixturenames[0] + if "hernquist" in options: + kind = "hernquist" + else: + raise ValueError + + with NumpyRNGContext(0): # control the random numbers + sample = sampler.rvs(size=int(1e3), r=10) + sample = sample[sample < 1e4] + + theory = self.theory[kind].sample(n=int(1e6)).theta() - np.pi / 2 + + fig = plt.figure(figsize=(10, 3)) + ax = fig.add_subplot( + 121, + title="SCF vs theory sampling", + xlabel=r"$\theta$", + ylabel="frequency", + ) + _, bins, *_ = ax.hist(sample, bins=30, log=True, alpha=0.5, label="SCF sample") + # Comparing to expected + ax.hist( + theory, + bins=bins, + log=True, + alpha=0.5, + label="Hernquist theoretical", + ) + ax.legend() + fig.tight_layout() + + return fig + + # /def + + +# /class + + +############################################################################### + + +class Test_SCFPhiSampler(SCFPhiSamplerTestBase): + """Test :class:`sample_scf.exact.SCFPhiSampler`.""" + + def setup_class(self): + super().setup_class(self) + + self.cls = exact.SCFPhiSampler + + self.cdf_time_scale = 3e-3 + self.rvs_time_scale = 3e-3 + + # /def + + # =============================================================== + # Method Tests + + @pytest.mark.skip("TODO!") + def test__cdf(self): + """Test :meth:`sample_scf.exactolated.SCFPhiSampler._cdf`.""" + assert False + + # /def + + @pytest.mark.skip("TODO!") + def test_cdf(self): + """Test :meth:`sample_scf.exactolated.SCFPhiSampler.cdf`.""" + assert False + + # /def + + @pytest.mark.skip("TODO!") + def test__rvs(self): + """Test :meth:`sample_scf.exactolated.SCFPhiSampler._rvs`.""" + assert False + + # /def + + @pytest.mark.skip("TODO!") + def test_rvs(self): + """Test :meth:`sample_scf.exactolated.SCFPhiSampler.rvs`.""" + assert False + + # /def + + # =============================================================== + # Image Tests + + @pytest.mark.mpl_image_compare( + baseline_dir="baseline_images", + # hash_library="baseline_images/path_to_file.json", # TODO! + ) + def test_exact_phi_cdf_plot(self, sampler): + fig = plt.figure(figsize=(5, 3)) + + ax = fig.add_subplot( + 111, + title=r"CDF($\phi$)", + xlabel=r"$\phi$", + ylabel=r"CDF($\phi$)", + ) + kw = dict(marker="o", ms=5, c="k", zorder=5, label="CDF") + ax.plot(pgrid, sampler.cdf(pgrid, r=10, theta=np.pi / 6), **kw) + ax.axvline(0, c="tab:blue") + ax.axhline(sampler.cdf(0, r=10, theta=np.pi / 6), c="tab:blue", label=r"$\phi=0$") + ax.axvline(np.pi, c="tab:green") + ax.axhline(sampler.cdf(np.pi, r=10, theta=np.pi / 6), c="tab:green", label=r"$\phi=\pi$") + ax.axvline(2 * np.pi, c="tab:red") + ax.axhline(sampler.cdf(2 * np.pi, r=10, theta=np.pi / 6), c="tab:red", label=r"$\phi=2\pi$") + ax.legend(loc="lower right") + + fig.tight_layout() + return fig + + # /def + + @pytest.mark.mpl_image_compare( + baseline_dir="baseline_images", + # hash_library="baseline_images/path_to_file.json", + ) + def test_exact_phi_sampling_plot(self, request, sampler): + """Test sampling.""" + # fiqure out theory sampler + options = request.fixturenames[0] + if "hernquist" in options: + kind = "hernquist" + else: + raise ValueError + + with NumpyRNGContext(0): # control the random numbers + sample = sampler.rvs(size=int(1e3), r=10, theta=np.pi / 6) + sample = sample[sample < 1e4] + + theory = self.theory[kind].sample(n=int(1e3)).phi() + + fig = plt.figure(figsize=(10, 3)) + ax = fig.add_subplot( + 121, + title="SCF vs theory sampling", + xlabel=r"$\phi$", + ylabel="frequency", + ) + _, bins, *_ = ax.hist(sample, bins=30, log=True, alpha=0.5, label="SCF sample") + # Comparing to expected + ax.hist( + theory, + bins=bins, + log=True, + alpha=0.5, + label="Hernquist theoretical", + ) + ax.legend() + fig.tight_layout() + + return fig + + # /def + + +# /class + + +############################################################################## +# END diff --git a/sample_scf/tests/test_init.py b/sample_scf/tests/test_init.py index 3a61bc5..51460d6 100644 --- a/sample_scf/tests/test_init.py +++ b/sample_scf/tests/test_init.py @@ -22,16 +22,16 @@ def test_expected_imports(): """Test can import expected modules and objects.""" # LOCAL import sample_scf - from sample_scf import core, sample_exact, sample_intrp + from sample_scf import core, exact, interpolated assert inspect.ismodule(sample_scf) assert inspect.ismodule(core) - assert inspect.ismodule(sample_exact) - assert inspect.ismodule(sample_intrp) + assert inspect.ismodule(exact) + assert inspect.ismodule(interpolated) assert sample_scf.SCFSampler is core.SCFSampler - assert sample_scf.SCFSamplerExact is sample_exact.SCFSampler - assert sample_scf.SCFSamplerInterp is sample_intrp.SCFSampler + assert sample_scf.SCFSamplerExact is exact.SCFSampler + assert sample_scf.SCFSamplerInterp is interpolated.SCFSampler # /def diff --git a/sample_scf/tests/test_sample_intrp.py b/sample_scf/tests/test_interpolated.py similarity index 75% rename from sample_scf/tests/test_sample_intrp.py rename to sample_scf/tests/test_interpolated.py index 7f681df..b3a0694 100644 --- a/sample_scf/tests/test_sample_intrp.py +++ b/sample_scf/tests/test_interpolated.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -"""Testing :mod:`scample_scf.sample_intrp`.""" +"""Testing :mod:`scample_scf.interpolated`.""" ############################################################################## @@ -18,15 +18,16 @@ from numpy.testing import assert_allclose # LOCAL +from .common import SCFPhiSamplerTestBase, SCFRSamplerTestBase, SCFThetaSamplerTestBase from .test_base import SCFSamplerTestBase from .test_base import Test_RVPotential as RVPotentialTest -from sample_scf import conftest, sample_intrp +from sample_scf import conftest, interpolated from sample_scf.utils import phiRSms, r_of_zeta, thetaQls, x_of_theta, zeta_of_r ############################################################################## # PARAMETERS -rgrid = np.concatenate(([0], np.geomspace(1e-1, 1e3, 100))) +rgrid = np.concatenate(([0], np.geomspace(1e-1, 1e3, 100), [np.inf])) tgrid = np.linspace(-np.pi / 2, np.pi / 2, 30) pgrid = np.linspace(0, 2 * np.pi, 30) @@ -37,21 +38,22 @@ class Test_SCFSampler(SCFSamplerTestBase): - """Test :class:`sample_scf.sample_intrp.SCFSampler`.""" + """Test :class:`sample_scf.interpolated.SCFSampler`.""" def setup_class(self): + super().setup_class(self) - self.cls = sample_intrp.SCFSampler + self.cls = interpolated.SCFSampler self.cls_args = (rgrid, tgrid, pgrid) self.cls_kwargs = {} + # TODO! make sure these are right! self.expected_rvs = { - 0: dict(r=2.8583146808697, theta=1.473013568997 * u.rad, phi=3.4482969442579 * u.rad), - 1: dict(r=2.8583146808697, theta=1.473013568997 * u.rad, phi=3.4482969442579 * u.rad), + 0: dict(r=2.8473287899985, theta=1.473013568997 * u.rad, phi=3.4482969442579 * u.rad), + 1: dict(r=2.8473287899985, theta=1.473013568997 * u.rad, phi=3.4482969442579 * u.rad), 2: dict( - r=[59.15672032022, 2.842480998054, 71.71466505664, 5.471148006362], - theta=[0.36517953566424, 1.4761907683040, 0.33207251545636, 1.1267111320704] - * u.rad, + r=[55.79997672576021, 2.831600636133138, 66.85343958872159, 5.435971037191061], + theta=[0.3651795356642, 1.476190768304, 0.3320725154563, 1.126711132070] * u.rad, phi=[6.076027676095, 3.438361627636, 6.11155607905, 4.491321348792] * u.rad, ), } @@ -66,8 +68,8 @@ def setup_class(self): "r, theta, phi, expected", [ (0, 0, 0, [0, 0.5, 0]), - (1, 0, 0, [0.25, 0.5, 0]), - ([0, 1], [0, 0], [0, 0], [[0, 0.5, 0], [0.25, 0.5, 0]]), + (1, 0, 0, [0.2505, 0.5, 0]), + ([0, 1], [0, 0], [0, 0], [[0, 0.5, 0], [0.2505, 0.5, 0]]), ], ) def test_cdf(self, sampler, r, theta, phi, expected): @@ -108,6 +110,8 @@ def test___init__(self, sampler): # good newsampler = self.cls(potential, *self.cls_args) + # compare that the knots are the same when initializing a second time + # ie that the splines are stable cdfk = sampler._spl_cdf.get_knots() ncdfk = newsampler._spl_cdf.get_knots() if isinstance(cdfk, np.ndarray): # 1D splines @@ -137,12 +141,14 @@ def test___init__(self, sampler): # ---------------------------------------------------------------------------- -class Test_SCFRSampler(InterpRVPotentialTest): - """Test :class:`sample_scf.`""" +class Test_SCFRSampler(SCFRSamplerTestBase, InterpRVPotentialTest): + """Test :class:`sample_scf.sample_interp.SCFRSampler`""" def setup_class(self): - self.cls = sample_intrp.SCFRSampler + self.cls = interpolated.SCFRSampler self.cls_args = (rgrid,) + self.cls_kwargs = {} + self.cls_pot_kw = {} self.cdf_time_scale = 6e-4 # milliseconds self.rvs_time_scale = 2e-4 # milliseconds @@ -167,26 +173,18 @@ def test___init__(self, sampler): # TODO! use hypothesis @pytest.mark.parametrize("r", np.random.default_rng(0).uniform(0, 1e4, 10)) def test__cdf(self, sampler, r): - """Test :meth:`sample_scf.sample_intrp.SCFRSampler._cdf`.""" + """Test :meth:`sample_scf.interpolated.SCFRSampler._cdf`.""" + super().test__cdf(sampler, r) + # expected assert_allclose(sampler._cdf(r), sampler._spl_cdf(zeta_of_r(r))) - # args and kwargs don't matter - assert_allclose(sampler._cdf(r), sampler._cdf(r, 10, test=14)) - - # /def - - def test__cdf_edge(self, sampler): - """Test :meth:`sample_scf.sample_intrp.SCFRSampler._cdf`.""" - assert np.isclose(sampler._cdf(0), 0.0, 1e-20) - assert np.isclose(sampler._cdf(np.inf), 1.0, 1e-20) - # /def # TODO! use hypothesis @pytest.mark.parametrize("q", np.random.default_rng(0).uniform(0, 1, 10)) def test__ppf(self, sampler, q): - """Test :meth:`sample_scf.sample_intrp.SCFRSampler._ppf`.""" + """Test :meth:`sample_scf.interpolated.SCFRSampler._ppf`.""" # expected assert_allclose(sampler._ppf(q), r_of_zeta(sampler._spl_ppf(q))) @@ -198,44 +196,18 @@ def test__ppf(self, sampler, q): @pytest.mark.parametrize( "size, random, expected", [ - (None, 0, 2.85831468), - (1, 2, 1.94376617), - ((3, 1), 4, (59.15672032, 2.842481, 71.71466506)), - ((3, 1), None, (59.15672032, 2.842481, 71.71466506)), + (None, 0, 2.84732879), + (1, 2, 1.938060987), + ((3, 1), 4, (55.79997672, 2.831600636, 66.85343958)), + ((3, 1), None, (55.79997672, 2.831600636, 66.85343958)), ], ) def test_rvs(self, sampler, size, random, expected): - """Test :meth:`sample_scf.sample_intrp.SCFRSampler.rvs`.""" + """Test :meth:`sample_scf.interpolated.SCFRSampler.rvs`.""" super().test_rvs(sampler, size, random, expected) # /def - # =============================================================== - # Time Scaling Tests - - @pytest.mark.parametrize("size", [1, 10, 100, 1000, 10000]) - def test_cdf_time_scaling(self, sampler, size): - """Test that the time scales as X * size""" - x = np.linspace(0, 1e4, size) - tic = time.perf_counter() - sampler.cdf(x) - toc = time.perf_counter() - - assert (toc - tic) < self.cdf_time_scale * size # linear scaling - - # /def - - @pytest.mark.parametrize("size", [1, 10, 100, 1000, 10000]) - def test_rvs_time_scaling(self, sampler, size): - """Test that the time scales as X * size""" - tic = time.perf_counter() - sampler.rvs(size=size) - toc = time.perf_counter() - - assert (toc - tic) < self.rvs_time_scale * size # linear scaling - - # /def - # =============================================================== # Image Tests @@ -328,20 +300,18 @@ def test_interp_r_sampling_plot(self, request, sampler): # ---------------------------------------------------------------------------- -class Test_SCFThetaSampler(InterpRVPotentialTest): - """Test :class:`sample_scf.sample_intrp.SCFThetaSampler`.""" +class Test_SCFThetaSampler(SCFThetaSamplerTestBase, InterpRVPotentialTest): + """Test :class:`sample_scf.interpolated.SCFThetaSampler`.""" def setup_class(self): - self.cls = sample_intrp.SCFThetaSampler + super().setup_class(self) + + self.cls = interpolated.SCFThetaSampler self.cls_args = (rgrid, tgrid) self.cdf_time_scale = 3e-4 self.rvs_time_scale = 6e-4 - self.theory = dict( - hernquist=conftest.hernquist_df, - ) - # /def # =============================================================== @@ -369,7 +339,7 @@ def test___init__(self, sampler): ], ) def test__cdf(self, sampler, x, zeta): - """Test :meth:`sample_scf.sample_intrp.SCFThetaSampler._cdf`.""" + """Test :meth:`sample_scf.interpolated.SCFThetaSampler._cdf`.""" # expected assert_allclose(sampler._cdf(x, zeta=zeta), sampler._spl_cdf(zeta, x, grid=False)) @@ -380,7 +350,7 @@ def test__cdf(self, sampler, x, zeta): @pytest.mark.parametrize("zeta", np.random.default_rng(0).uniform(-1, 1, 10)) def test__cdf_edge(self, sampler, zeta): - """Test :meth:`sample_scf.sample_intrp.SCFRSampler._cdf`.""" + """Test :meth:`sample_scf.interpolated.SCFRSampler._cdf`.""" assert np.isclose(sampler._cdf(-1, zeta=zeta), 0.0, atol=1e-16) assert np.isclose(sampler._cdf(1, zeta=zeta), 1.0, atol=1e-16) @@ -396,7 +366,7 @@ def test__cdf_edge(self, sampler, zeta): ], ) def test_cdf(self, sampler, theta, r): - """Test :meth:`sample_scf.sample_intrp.SCFThetaSampler.cdf`.""" + """Test :meth:`sample_scf.interpolated.SCFThetaSampler.cdf`.""" assert_allclose( sampler.cdf(theta, r), sampler._spl_cdf(zeta_of_r(r), x_of_theta(u.Quantity(theta, u.rad)), grid=False), @@ -406,51 +376,25 @@ def test_cdf(self, sampler, theta, r): @pytest.mark.skip("TODO!") def test__ppf(self): - """Test :meth:`sample_scf.sample_intrp.SCFThetaSampler._ppf`.""" + """Test :meth:`sample_scf.interpolated.SCFThetaSampler._ppf`.""" assert False # /def @pytest.mark.skip("TODO!") def test__rvs(self): - """Test :meth:`sample_scf.sample_intrp.SCFThetaSampler._rvs`.""" + """Test :meth:`sample_scf.interpolated.SCFThetaSampler._rvs`.""" assert False # /def @pytest.mark.skip("TODO!") def test_rvs(self): - """Test :meth:`sample_scf.sample_intrp.SCFThetaSampler.rvs`.""" + """Test :meth:`sample_scf.interpolated.SCFThetaSampler.rvs`.""" assert False # /def - # =============================================================== - # Time Scaling Tests - - @pytest.mark.parametrize("size", [1, 10, 100, 1000, 10000]) - def test_cdf_time_scaling(self, sampler, size): - """Test that the time scales as X * size""" - x = np.linspace(-np.pi / 2, np.pi / 2, size) - tic = time.perf_counter() - sampler.cdf(x, r=10) - toc = time.perf_counter() - - assert (toc - tic) < self.cdf_time_scale * size # linear scaling - - # /def - - @pytest.mark.parametrize("size", [1, 10, 100, 1000, 10000]) - def test_rvs_time_scaling(self, sampler, size): - """Test that the time scales as X * size""" - tic = time.perf_counter() - sampler.rvs(size=size, r=10) - toc = time.perf_counter() - - assert (toc - tic) < self.rvs_time_scale * size # linear scaling - - # /def - # =============================================================== # Image Tests @@ -545,27 +489,25 @@ def test_interp_theta_sampling_plot(self, request, sampler): # ---------------------------------------------------------------------------- -class Test_SCFPhiSampler(InterpRVPotentialTest): - """Test :class:`sample_scf.sample_intrp.SCFPhiSampler`.""" +class Test_SCFPhiSampler(SCFPhiSamplerTestBase, InterpRVPotentialTest): + """Test :class:`sample_scf.interpolated.SCFPhiSampler`.""" def setup_class(self): - self.cls = sample_intrp.SCFPhiSampler + super().setup_class(self) + + self.cls = interpolated.SCFPhiSampler self.cls_args = (rgrid, tgrid, pgrid) self.cdf_time_scale = 12e-4 self.rvs_time_scale = 12e-4 - self.theory = dict( - hernquist=conftest.hernquist_df, - ) - # /def # =============================================================== # Method Tests def test___init__(self, sampler): - """Test :meth:`sample_scf.sample_intrp.SCFPhiSampler._cdf`.""" + """Test :meth:`sample_scf.interpolated.SCFPhiSampler._cdf`.""" # super().test___init__(sampler) # doesn't work TODO! # a shape mismatch @@ -577,65 +519,39 @@ def test___init__(self, sampler): @pytest.mark.skip("TODO!") def test__cdf(self): - """Test :meth:`sample_scf.sample_intrp.SCFPhiSampler._cdf`.""" + """Test :meth:`sample_scf.interpolated.SCFPhiSampler._cdf`.""" assert False # /def @pytest.mark.skip("TODO!") def test_cdf(self): - """Test :meth:`sample_scf.sample_intrp.SCFPhiSampler.cdf`.""" + """Test :meth:`sample_scf.interpolated.SCFPhiSampler.cdf`.""" assert False # /def @pytest.mark.skip("TODO!") def test__ppf(self): - """Test :meth:`sample_scf.sample_intrp.SCFPhiSampler._ppf`.""" + """Test :meth:`sample_scf.interpolated.SCFPhiSampler._ppf`.""" assert False # /def @pytest.mark.skip("TODO!") def test__rvs(self): - """Test :meth:`sample_scf.sample_intrp.SCFPhiSampler._rvs`.""" + """Test :meth:`sample_scf.interpolated.SCFPhiSampler._rvs`.""" assert False # /def @pytest.mark.skip("TODO!") def test_rvs(self): - """Test :meth:`sample_scf.sample_intrp.SCFPhiSampler.rvs`.""" + """Test :meth:`sample_scf.interpolated.SCFPhiSampler.rvs`.""" assert False # /def - # =============================================================== - # Time Scaling Tests - - @pytest.mark.parametrize("size", [1, 10, 100, 1000, 10000]) - def test_cdf_time_scaling(self, sampler, size): - """Test that the time scales as X * size""" - x = np.linspace(0, 2 * np.pi, size) - tic = time.perf_counter() - sampler.cdf(x, r=10, theta=np.pi / 6) - toc = time.perf_counter() - - assert (toc - tic) < self.cdf_time_scale * size # linear scaling - - # /def - - @pytest.mark.parametrize("size", [1, 10, 100, 1000, 10000]) - def test_rvs_time_scaling(self, sampler, size): - """Test that the time scales as X * size""" - tic = time.perf_counter() - sampler.rvs(size=size, r=10, theta=np.pi / 6) - toc = time.perf_counter() - - assert (toc - tic) < self.rvs_time_scale * size # linear scaling - - # /def - # =============================================================== # Image Tests diff --git a/sample_scf/tests/test_sample_exact.py b/sample_scf/tests/test_sample_exact.py deleted file mode 100644 index 533338e..0000000 --- a/sample_scf/tests/test_sample_exact.py +++ /dev/null @@ -1,529 +0,0 @@ -# -*- coding: utf-8 -*- - -"""Tests for :mod:`sample_scf.sample_exact`.""" - -__all__ = [ - # "Test_SCFRSampler", - # "Test_SCFThetaSampler", - # "Test_SCFThetaSampler_of_r", -] - - -############################################################################## -# IMPORTS - -# THIRD PARTY -import numpy as np - -############################################################################## -# PARAMETERS - -rgrid = np.concatenate(([0], np.geomspace(1e-1, 1e3, 100))) -tgrid = np.linspace(-np.pi / 2, np.pi / 2, 30) -pgrid = np.linspace(0, 2 * np.pi, 30) - - -############################################################################## -# CODE -############################################################################## - - -# class Test_SCFSampler(SCFSamplerTestBase): -# """Test :class:`sample_scf.sample_exact.SCFSampler`.""" -# -# self.cls = sample_intrp.SCFSampler -# self.cls_args = (rgrid, tgrid, pgrid) -# self.cls_kwargs = {} -# -# self.expected_rvs = { -# 0: dict(r=2.8583146808697, theta=1.473013568997 * u.rad, phi=3.4482969442579 * u.rad), -# 1: dict(r=2.8583146808697, theta=1.473013568997 * u.rad, phi=3.4482969442579 * u.rad), -# 2: dict( -# r=[59.15672032022, 2.842480998054, 71.71466505664, 5.471148006362], -# theta=[0.36517953566424, 1.4761907683040, 0.33207251545636, 1.1267111320704] -# * u.rad, -# phi=[6.076027676095, 3.438361627636, 6.11155607905, 4.491321348792] * u.rad, -# ), -# } -# -# # =============================================================== -# # Method Tests -# -# # TODO! make sure these are correct -# @pytest.mark.parametrize( -# "r, theta, phi, expected", -# [ -# (0, 0, 0, [0, 0.5, 0]), -# (1, 0, 0, [0.25, 0.5, 0]), -# ([0, 1], [0, 0], [0, 0], [[0, 0.5, 0], [0.25, 0.5, 0]]), -# ], -# ) -# def test_cdf(self, sampler, r, theta, phi, expected): -# """Test :meth:`sample_scf.base.SCFSamplerBase.cdf`.""" -# assert np.allclose(sampler.cdf(r, theta, phi), expected, atol=1e-16) -# -# # /def -# -# -# # /class - - -# ============================================================================ - - -# class Test_SCFRSampler: -# def setup_class(self): -# self.sampler = SCFRSampler(m, r) -# self.theory = isotropicHernquistdf(hernpot) -# -# # /def -# -# # =============================================================== -# # Method Tests -# -# def test___init__(self): -# pass # TODO! -# # test if mgrid is SCFPotential -# -# # =============================================================== -# # Usage Tests -# -# @pytest.mark.parametrize("size", [1, 10, 100, 1000, 10000]) -# def test_cdf_time_scaling(self, size): -# """Test that the time scales as ~200 microseconds * size""" -# tic = time.perf_counter() -# self.sampler.cdf(np.linspace(0, 1e4, size)) -# toc = time.perf_counter() -# -# assert (toc - tic) < 0.0003 * size # 200 microseconds * linear scaling -# -# # /def -# -# @pytest.mark.parametrize("size", [1, 10, 100, 1000, 10000]) -# def test_rvs_time_scaling(self, size): -# """Test that the time scales as ~300 microseconds * size""" -# tic = time.perf_counter() -# self.sampler.rvs(size=size) -# toc = time.perf_counter() -# -# assert (toc - tic) < 0.0004 * size # 300 microseconds * linear scaling -# -# # /def -# -# # ---------------------------------------------------------------- -# # Image tests -# -# @pytest.mark.mpl_image_compare( -# baseline_dir="baseline_images", -# hash_library="baseline_images/path_to_file.json", -# ) -# def test_r_cdf_plot(self): -# """Compare""" -# fig = plt.figure(figsize=(10, 3)) -# -# ax = fig.add_subplot( -# 121, -# title=r"$m(\leq r) / m_{tot}$", -# xlabel="r", -# ylabel=r"$m(\leq r) / m_{tot}$", -# ) -# ax.semilogx( -# r, -# self.sampler.cdf(r), -# marker="o", -# ms=5, -# c="k", -# zorder=10, -# label="CDF", -# ) -# ax.axvline(0, c="tab:blue") -# ax.axhline(self.sampler.cdf(0), c="tab:blue", label="r=0") -# ax.axvline(1, c="tab:green") -# ax.axhline(self.sampler.cdf(1), c="tab:green", label="r=1") -# ax.axvline(1e2, c="tab:red") -# ax.axhline(self.sampler.cdf(1e2), c="tab:red", label="r=100") -# -# ax.set_xlim((1e-1, None)) -# ax.legend() -# -# ax = fig.add_subplot( -# 122, -# title=r"$m(\leq \zeta) / m_{tot}$", -# xlabel=r"$\zeta$", -# ylabel=r"$m(\leq \zeta) / m_{tot}$", -# ) -# ax.plot( -# zeta, -# self.sampler.cdf(r), -# marker="o", -# ms=4, -# c="k", -# zorder=10, -# label="CDF", -# ) -# ax.axvline(zeta_of_r(0), c="tab:blue") -# ax.axhline(self.sampler.cdf(0), c="tab:blue", label="r=0") -# ax.axvline(zeta_of_r(1), c="tab:green") -# ax.axhline(self.sampler.cdf(1), c="tab:green", label="r=1") -# ax.axvline(zeta_of_r(1e2), c="tab:red") -# ax.axhline(self.sampler.cdf(1e2), c="tab:red", label="r=100") -# -# ax.legend() -# -# fig.tight_layout() -# return fig -# -# # /def -# -# @pytest.mark.mpl_image_compare( -# baseline_dir="baseline_images", -# hash_library="baseline_images/path_to_file.json", -# ) -# def test_r_sampling_plot(self): -# """Test sampling.""" -# with NumpyRNGContext(0): # control the random numbers -# sample = self.sampler.rvs(size=1000000) -# sample = sample[sample < 1e4] -# -# theory = self.theory.sample(n=1000000).r() -# theory = theory[theory < 1e4] -# -# fig = plt.figure(figsize=(10, 3)) -# ax = fig.add_subplot(121, title="SCF vs theory sampling", xlabel="r", ylabel="frequency") -# _, bins, *_ = ax.hist(sample, bins=30, log=True, alpha=0.5, label="SCF sample") -# # Comparing to expected -# ax.hist( -# theory, -# bins=bins, -# log=True, -# alpha=0.5, -# label="Hernquist theoretical", -# ) -# ax.legend() -# fig.tight_layout() -# -# return fig -# -# # /def -# -# -# # /class -# -# -# class Test_SCFThetaSampler: -# def setup_class(self): -# self.sampler = SCFThetaSampler(pot, r=1) -# self.theory = isotropicHernquistdf(hernpot) -# -# # /def -# -# # =============================================================== -# # Method Tests -# -# def test___init__(self): -# pass # TODO! -# # test if mgrid is SCFPotential -# -# # =============================================================== -# # Usage Tests -# -# @pytest.mark.parametrize("size", [1, 10, 100, 1000, 10000]) -# def test_cdf_time_scaling(self, size): -# """Test that the time scales as ~800 microseconds * size""" -# tic = time.perf_counter() -# self.sampler.cdf(np.linspace(0, np.pi, size)) -# toc = time.perf_counter() -# -# assert (toc - tic) < 0.001 * size # 800 microseconds * linear scaling -# -# # /def -# -# @pytest.mark.parametrize("size", [1, 10, 100]) -# def test_rvs_time_scaling(self, size): -# """Test that the time scales as ~4 milliseconds * size""" -# tic = time.perf_counter() -# self.sampler.rvs(size=size) -# toc = time.perf_counter() -# -# assert (toc - tic) < 0.005 * size # linear scaling -# -# # /def -# -# # ---------------------------------------------------------------- -# # Image tests -# -# @pytest.mark.mpl_image_compare( -# baseline_dir="baseline_images", -# hash_library="baseline_images/path_to_file.json", -# ) -# def test_theta_cdf_plot(self): -# """Compare""" -# fig = plt.figure(figsize=(10, 3)) -# -# ax = fig.add_subplot( -# 121, -# title=r"$\Theta(\leq \theta; r=1)$", -# xlabel=r"$\theta$", -# ylabel=r"$\Theta(\leq \theta; r=1)$", -# ) -# ax.plot( -# theta, -# self.sampler.cdf(theta), -# marker="o", -# ms=5, -# c="k", -# zorder=10, -# label="CDF", -# ) -# ax.legend(loc="lower right") -# -# # Plotting CDF against x. -# # This should be a straight line. -# ax = fig.add_subplot( -# 122, -# title=r"$\Theta(\leq \theta; r=1)$", -# xlabel=r"$x=\cos\theta$", -# ylabel=r"$\Theta(\leq \theta; r=1)$", -# ) -# ax.plot( -# x_of_theta(theta), -# self.sampler.cdf(theta), -# marker="o", -# ms=4, -# c="k", -# zorder=10, -# label="CDF", -# ) -# ax.legend(loc="lower right") -# -# fig.tight_layout() -# return fig -# -# # /def -# -# @pytest.mark.mpl_image_compare( -# baseline_dir="baseline_images", -# hash_library="baseline_images/path_to_file.json", -# ) -# def test_theta_sampling_plot(self): -# """Test sampling.""" -# with NumpyRNGContext(0): # control the random numbers -# sample = self.sampler.rvs(size=1000) -# sample = sample[sample < 1e4] -# -# theory = self.theory.sample(n=1000).theta() - np.pi / 2 -# theory = theory[theory < 1e4] -# -# fig = plt.figure(figsize=(7, 5)) -# ax = fig.add_subplot( -# 111, -# title="SCF vs theory sampling", -# xlabel="theta", -# ylabel="frequency", -# ) -# _, bins, *_ = ax.hist(sample, bins=30, log=True, alpha=0.5, label="SCF sample") -# # Comparing to expected -# ax.hist( -# theory, -# bins=bins, -# log=True, -# alpha=0.5, -# label="Hernquist theoretical", -# ) -# ax.legend(fontsize=12) -# fig.tight_layout() -# -# return fig -# -# # /def -# -# -# # /class -# -# -# class Test_SCFThetaSampler_of_r: -# def setup_class(self): -# self.sampler = SCFThetaSampler(pot) -# self.theory = isotropicHernquistdf(hernpot) -# -# # /def -# -# # =============================================================== -# # Method Tests -# -# def test___init__(self): -# pass # TODO! -# # test if mgrid is SCFPotential -# -# # =============================================================== -# # Usage Tests -# -# @pytest.mark.parametrize("size", [1, 10, 100, 1000, 10000]) -# def test_cdf_time_scaling(self, size): -# """Test that the time scales as ~3 milliseconds * size""" -# tic = time.perf_counter() -# self.sampler.cdf(np.linspace(0, np.pi, size), r=1) -# toc = time.perf_counter() -# -# assert (toc - tic) < 0.003 * size # 3 microseconds * linear scaling -# -# # /def -# -# @pytest.mark.parametrize("size", [1, 10, 100]) -# def test_rvs_time_scaling(self, size): -# """Test that the time scales as ~4 milliseconds * size""" -# tic = time.perf_counter() -# self.sampler.rvs(size=size, r=1) -# toc = time.perf_counter() -# -# assert (toc - tic) < 0.04 * size # linear scaling -# -# # /def -# -# def test_cdf_independent_of_r(self): -# """The Hernquist potential CDF(theta) is r independent.""" -# expected = (x_of_theta(theta) + 1) / 2 -# # [-1, 1] -> [0, 1] with a -# -# # assert np.allclose(self.sampler.cdf(theta, r=0), expected) # FIXME! -# assert np.allclose(self.sampler.cdf(theta, r=1), expected) -# assert np.allclose(self.sampler.cdf(theta, r=2), expected) -# # assert np.allclose(self.sampler.cdf(theta, r=np.inf), expected) # FIXME! -# -# -# # ------------------------------------------------------------------------------ -# -# -# class Test_SCFPhiSampler: -# def setup_class(self): -# self.sampler = SCFPhiSampler(pot, r=1, theta=np.pi / 3) -# self.theory = isotropicHernquistdf(hernpot) -# -# # /def -# -# # =============================================================== -# # Method Tests -# -# def test___init__(self): -# pass # TODO! -# # test if mgrid is SCFPotential -# -# # =============================================================== -# # Usage Tests -# -# @pytest.mark.parametrize("size", [1, 10, 100, 1000, 10000]) -# def test_cdf_time_scaling(self, size): -# """Test that the time scales as ~800 microseconds * size""" -# tic = time.perf_counter() -# self.sampler.cdf(np.linspace(0, 2 * np.pi, size)) -# toc = time.perf_counter() -# -# assert (toc - tic) < 0.001 * size # 800 microseconds * linear scaling -# -# # /def -# -# @pytest.mark.parametrize("size", [1, 10, 100]) -# def test_rvs_time_scaling(self, size): -# """Test that the time scales as ~4 milliseconds * size""" -# tic = time.perf_counter() -# self.sampler.rvs(size=size) -# toc = time.perf_counter() -# -# assert (toc - tic) < 0.005 * size # linear scaling -# -# # /def -# -# # ---------------------------------------------------------------- -# # Image tests -# -# @pytest.mark.mpl_image_compare( -# baseline_dir="baseline_images", -# hash_library="baseline_images/path_to_file.json", -# ) -# def test_phi_cdf_plot(self): -# """Compare""" -# fig = plt.figure(figsize=(10, 3)) -# -# ax = fig.add_subplot( -# 121, -# title=r"$\Phi(\leq \phi; r=1)$", -# xlabel=r"$\phi$", -# ylabel=r"$\Phi(\leq \phi; r=1)$", -# ) -# ax.plot( -# phi, -# self.sampler.cdf(phi), -# marker="o", -# ms=5, -# c="k", -# zorder=10, -# label="CDF", -# ) -# ax.legend(loc="lower right") -# -# # Plotting CDF against x. -# # This should be a straight line. -# ax = fig.add_subplot( -# 122, -# title=r"$\Phi(\leq \phi; r=1)$", -# xlabel=r"$\phi/2\pi$", -# ylabel=r"$\Phi(\leq \phi; r=1)$", -# ) -# ax.plot( -# phi / (2 * np.pi), -# self.sampler.cdf(phi), -# marker="o", -# ms=4, -# c="k", -# zorder=10, -# label="CDF", -# ) -# ax.legend(loc="lower right") -# -# fig.tight_layout() -# return fig -# -# # /def -# -# @pytest.mark.mpl_image_compare( -# baseline_dir="baseline_images", -# hash_library="baseline_images/path_to_file.json", -# ) -# def test_phi_sampling_plot(self): -# """Test sampling.""" -# with NumpyRNGContext(0): # control the random numbers -# sample = self.sampler.rvs(size=1000) -# sample = sample[sample < 1e4] -# -# theory = self.theory.sample(n=1000).phi() -# theory = theory[theory < 1e4] -# -# fig = plt.figure(figsize=(7, 5)) -# ax = fig.add_subplot( -# 111, -# title="SCF vs theory sampling", -# xlabel="theta", -# ylabel="frequency", -# ) -# _, bins, *_ = ax.hist(sample, bins=30, log=True, alpha=0.5, label="SCF sample") -# # Comparing to expected -# ax.hist( -# theory, -# bins=bins, -# log=True, -# alpha=0.5, -# label="Hernquist theoretical", -# ) -# ax.legend(fontsize=12) -# fig.tight_layout() -# -# return fig -# -# # /def -# -# -# # /class - - -############################################################################## -# END diff --git a/sample_scf/tests/test_utils.py b/sample_scf/tests/test_utils.py index 525bf0e..c8b01d7 100644 --- a/sample_scf/tests/test_utils.py +++ b/sample_scf/tests/test_utils.py @@ -277,12 +277,14 @@ class Test_phiRSms: ) def test_phiRSms_hernquist(self, hernquist_scf_potential, r, theta, expected): Rm, Sm = phiRSms(hernquist_scf_potential, r, theta) - assert_allclose(Rm[1:], expected[0], atol=1e-16) - assert_allclose(Sm[1:], expected[1], atol=1e-16) + assert Rm.shape == Sm.shape + assert Rm.shape == (1, 1, 6) + assert_allclose(Rm[0, 0, 1:], expected[0], atol=1e-16) + assert_allclose(Sm[0, 0, 1:], expected[1], atol=1e-16) if theta == 0 and r != 0: - assert Rm[0] != 0 - assert Sm[0] == 0 + assert Rm[0, 0, 0] != 0 + assert Sm[0, 0, 0] == 0 # /def diff --git a/sample_scf/utils.py b/sample_scf/utils.py index 52cef67..e5ae1b3 100644 --- a/sample_scf/utils.py +++ b/sample_scf/utils.py @@ -313,9 +313,7 @@ def phiRSms( Returns ------- Rm, Sm : ndarray[float] - Azimuthal weighting factors. Will have shape (len(r), len(theta), L), - with :meth:`numpy.ndarray.squeeze` applied to eliminate extraneous - dimensions, e.g. scalar 'r' + Azimuthal weighting factors. Shape (len(r), len(theta), L). """ # need r and theta to be arrays. The extra dimensions will be 'squeeze'd. rgrid = atleast_1d(r) @@ -326,7 +324,7 @@ def phiRSms( lmax: int nmax, lmax = pot._Acos.shape[:2] rhoTilde = nan_to_num( - array([pot._rhoTilde(r, N=nmax, L=lmax) for r in rgrid]), + array([pot._rhoTilde(r, N=nmax, L=lmax) for r in rgrid]), # todo! vectorize nan=0, posinf=np.inf, neginf=-np.inf, @@ -334,9 +332,7 @@ def phiRSms( # pass to actual calculator, which takes the matrices and r, theta grids. Rm, Sm = _phiRSms(rhoTilde, pot._Acos, pot._Asin, rgrid, tgrid) - - # remove extra dimensions from azimuthal factors - return Rm.squeeze(), Sm.squeeze() + return Rm, Sm # /def From 69572b78c9dd66dc15dd41f47f1d96a7a6855ef1 Mon Sep 17 00:00:00 2001 From: "Nathaniel Starkman (@nstarman)" Date: Tue, 3 Aug 2021 00:25:29 -0400 Subject: [PATCH 25/31] codestyle Signed-off-by: Nathaniel Starkman (@nstarman) --- sample_scf/exact.py | 12 ++++++------ sample_scf/tests/test_exact.py | 10 +++------- sample_scf/tests/test_interpolated.py | 3 --- 3 files changed, 9 insertions(+), 16 deletions(-) diff --git a/sample_scf/exact.py b/sample_scf/exact.py index 988eab3..4109e23 100644 --- a/sample_scf/exact.py +++ b/sample_scf/exact.py @@ -340,14 +340,14 @@ def __init__(self, potential: SCFPotential, **kw: T.Any) -> None: # /def def _cdf(self, phi: NDArray64, *args: T.Any, **kw: T.Any) -> NDArray64: - """Cumulative Distribution Function. + r"""Cumulative Distribution Function. Parameters ---------- phi : float or ndarray[float] ['radian'] Azimuthal coordinate in radians, :math:`\in [0, 2\pi]`. - *args - **kw + *args, **kw + Not used. Returns ------- @@ -414,7 +414,7 @@ def __init__( # /def def cdf(self, phi: NDArray64, *args: T.Any, **kw: T.Any) -> NDArray64: - """Cumulative Distribution Function. + r"""Cumulative Distribution Function. Parameters ---------- @@ -446,7 +446,7 @@ def _cdf( r: T.Optional[float] = None, theta: T.Optional[float] = None, ) -> NDArray64: - """Cumulative Distribution Function. + r"""Cumulative Distribution Function. Parameters ---------- @@ -478,7 +478,7 @@ def _cdf( # /def def cdf(self, phi: npt.ArrayLike, *args: T.Any, r: float, theta: float) -> NDArray64: - """Cumulative Distribution Function. + r"""Cumulative Distribution Function. Parameters ---------- diff --git a/sample_scf/tests/test_exact.py b/sample_scf/tests/test_exact.py index b367b4c..35d76cc 100644 --- a/sample_scf/tests/test_exact.py +++ b/sample_scf/tests/test_exact.py @@ -6,9 +6,6 @@ ############################################################################## # IMPORTS -# BUILT-IN -import typing as T - # THIRD PARTY import astropy.units as u import matplotlib.pyplot as plt @@ -20,9 +17,8 @@ # LOCAL from .common import SCFPhiSamplerTestBase, SCFRSamplerTestBase, SCFThetaSamplerTestBase from .test_base import SCFSamplerTestBase -from .test_base import Test_RVPotential as RVPotentialTest from sample_scf import conftest, exact -from sample_scf.utils import r_of_zeta, theta_of_x, thetaQls, x_of_theta +from sample_scf.utils import difPls, r_of_zeta, thetaQls, x_of_theta ############################################################################## # PARAMETERS @@ -309,11 +305,11 @@ def test__cdf(self, sampler, x, r): # TODO! a more robust test # l = 0 - term0 = 0.5 * (xs + 1.0) # (T,) + term0 = 0.5 * (x + 1.0) # (T,) # l = 1+ : non-symmetry factor = 1.0 / (2.0 * Qls[:, 0]) # (R,) term1p = np.sum( - (Qls[None, :, 1:] * difPls(xs, self._lmax - 1).T[:, None, :]).T, + (Qls[None, :, 1:] * difPls(x, self._lmax - 1).T[:, None, :]).T, axis=0, ) cdf = term0[None, :] + np.nan_to_num(factor[:, None] * term1p) # (R, T) diff --git a/sample_scf/tests/test_interpolated.py b/sample_scf/tests/test_interpolated.py index b3a0694..f33ee10 100644 --- a/sample_scf/tests/test_interpolated.py +++ b/sample_scf/tests/test_interpolated.py @@ -6,9 +6,6 @@ ############################################################################## # IMPORTS -# BUILT-IN -import time - # THIRD PARTY import astropy.units as u import matplotlib.pyplot as plt From 793d5e373c54729f74cb1b8506c2bde6e159e1d1 Mon Sep 17 00:00:00 2001 From: "Nathaniel Starkman (@nstarman)" Date: Tue, 3 Aug 2021 00:40:29 -0400 Subject: [PATCH 26/31] relax a test Signed-off-by: Nathaniel Starkman (@nstarman) --- sample_scf/tests/test_base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sample_scf/tests/test_base.py b/sample_scf/tests/test_base.py index 6acc42e..3994b5e 100644 --- a/sample_scf/tests/test_base.py +++ b/sample_scf/tests/test_base.py @@ -62,7 +62,7 @@ def setup_class(self): self.rvs_args = () self.rvs_kwargs = {} - self.cdf_time_scale = 3e-6 + self.cdf_time_scale = 4e-6 self.rvs_time_scale = 1e-4 # /def From 534562c04ea072501f9b13461a96eb2a44a1a150 Mon Sep 17 00:00:00 2001 From: "Nathaniel Starkman (@nstarman)" Date: Tue, 3 Aug 2021 11:43:55 -0400 Subject: [PATCH 27/31] fix broadcasting Signed-off-by: Nathaniel Starkman (@nstarman) --- sample_scf/base.py | 9 ++-- sample_scf/exact.py | 29 ++++++------ sample_scf/interpolated.py | 8 ++-- sample_scf/tests/test_base.py | 5 +-- sample_scf/tests/test_exact.py | 81 ++++++++++++++++++++++++++++++---- sample_scf/utils.py | 70 ++++++++++++++++++++++++++++- 6 files changed, 167 insertions(+), 35 deletions(-) diff --git a/sample_scf/base.py b/sample_scf/base.py index dc56e70..192434c 100644 --- a/sample_scf/base.py +++ b/sample_scf/base.py @@ -193,12 +193,15 @@ def cdf( ------- (N, 3) ndarray """ + # coordinates # TODO! deprecate whan galpy can do ints + r = np.asanyarray(r, dtype=float) + theta = np.asanyarray(theta, dtype=float) + phi = np.asanyarray(phi, dtype=float) + R: NDArray64 = self.rsampler.cdf(r) Theta: NDArray64 = self.thetasampler.cdf(theta, r=r) Phi: NDArray64 = self.phisampler.cdf(phi, r=r, theta=theta) - - RTP: NDArray64 = np.c_[R, Theta, Phi] - return RTP + return np.c_[R, Theta, Phi].squeeze() # /def diff --git a/sample_scf/exact.py b/sample_scf/exact.py index 4109e23..b697dda 100644 --- a/sample_scf/exact.py +++ b/sample_scf/exact.py @@ -169,18 +169,20 @@ def __init__(self, potential: SCFPotential, **kw: T.Any) -> None: @abc.abstractmethod def _cdf(self, x: NDArray64, Qls: NDArray64) -> NDArray64: xs = np.atleast_1d(x) - Qls = np.atleast_2d(Qls) + Qls = np.atleast_2d(Qls) # ({R}, L) # l = 0 term0 = 0.5 * (xs + 1.0) # (T,) # l = 1+ : non-symmetry factor = 1.0 / (2.0 * Qls[:, 0]) # (R,) term1p = np.sum( - (Qls[None, :, 1:] * difPls(xs, self._lmax - 1).T[:, None, :]).T, + (Qls[:, 1:] * difPls(xs, self._lmax - 1).T).T, axis=0, ) + # difPls shape (L, T) -> (T, L) + # term1p shape (R/T, L) -> (L, R/T) -> sum -> (R/T,) - cdf = term0[None, :] + np.nan_to_num(factor[:, None] * term1p) # (R, T) + cdf = term0 + np.nan_to_num(factor * term1p) # (R/T,) return cdf # /def @@ -357,27 +359,26 @@ def _cdf(self, phi: NDArray64, *args: T.Any, **kw: T.Any) -> NDArray64: output. """ - Rm, Sm = kw.get("RSms", (self._Rm, self._Sm)) # (len(r), len(theta), L) + Rm, Sm = kw.get("RSms", (self._Rm, self._Sm)) # (R/T, L) - Phis: NDArray64 = np.atleast_1d(phi)[None, None, :, None] # ({R}, {T}, P, {L}) + Phis: NDArray64 = np.atleast_1d(phi)[:, None] # (P, {L}) # l = 0 : spherical symmetry - term0: NDArray64 = Phis[..., 0] / (2 * np.pi) # (1, 1, P) + term0: NDArray64 = Phis[..., 0] / (2 * np.pi) # (1, P) # l = 1+ : non-symmetry - factor = 1 / Rm[:, :, :1] # R0 (R, T, 1) # can be inf - ms = np.arange(1, self._lmax)[None, None, None, :] # ({R}, {T}, {P}, L) + factor = 1 / Rm[:, 0] # R0 (R/T,) # can be inf + ms = np.arange(1, self._lmax)[None, :] # ({R/T/P}, L) term1p = np.sum( - (Rm[:, :, None, 1:] * np.sin(ms * Phis) + Sm[:, :, None, 1:] * (1 - np.cos(ms * Phis))) + (Rm[:, 1:] * np.sin(ms * Phis) + Sm[:, 1:] * (1 - np.cos(ms * Phis))) / (2 * np.pi * ms), axis=-1, ) - cdf: NDArray64 = term0 + np.nan_to_num(factor * term1p) # (R, T, P) + cdf: NDArray64 = term0 + np.nan_to_num(factor * term1p) # (R/T/P,) # 'factor' can be inf and term1p 0 => inf * 0 = nan -> 0 - # return cdf, squeezed of extra dimensions so scalar phi -> scalar - return cdf.squeeze() + return cdf # /def @@ -409,7 +410,7 @@ def __init__( # assign fixed r, theta self._r, self._theta = r, theta # and can compute the associated assymetry measures - self._Rm, self._Sm = phiRSms(potential, r, theta) + self._Rm, self._Sm = phiRSms(potential, r, theta, grid=False) # /def @@ -471,7 +472,7 @@ def _cdf( ValueError If 'r' or 'theta' are None. """ - RSms = phiRSms(self._potential, T.cast(float, r), T.cast(float, theta)) + RSms = phiRSms(self._potential, T.cast(float, r), T.cast(float, theta), grid=False) cdf: NDArray64 = super()._cdf(phi, *args, RSms=RSms) return cdf diff --git a/sample_scf/interpolated.py b/sample_scf/interpolated.py index ad345dc..0373e9b 100644 --- a/sample_scf/interpolated.py +++ b/sample_scf/interpolated.py @@ -32,7 +32,7 @@ # LOCAL from ._typing import NDArray64, RandomLike from .base import SCFSamplerBase, rv_potential -from .utils import _phiRSms, difPls, phiRSms, r_of_zeta, thetaQls, x_of_theta, zeta_of_r +from .utils import _grid_phiRSms, difPls, phiRSms, r_of_zeta, thetaQls, x_of_theta, zeta_of_r __all__: T.List[str] = [ "SCFSampler", @@ -92,8 +92,8 @@ class SCFSampler(SCFSamplerBase): Now we can evaluate the CDF - >>> sampler.cdf(10, np.pi/3, np.pi) - array([[0.82666461, 0.9330127 , 0.5 ]]) + >>> sampler.cdf(10.0, np.pi/3, np.pi) + array([0.82666461, 0.9330127 , 0.5 ]) And draw samples @@ -131,7 +131,7 @@ def __init__( # phi Rm, Sm # radial and inclination sums - RSms = _phiRSms( + RSms = _grid_phiRSms( rhoTilde, Acos=potential._Acos, Asin=potential._Asin, diff --git a/sample_scf/tests/test_base.py b/sample_scf/tests/test_base.py index 3994b5e..396b1a8 100644 --- a/sample_scf/tests/test_base.py +++ b/sample_scf/tests/test_base.py @@ -80,10 +80,9 @@ def sampler(self, potentials): # =============================================================== # Method Tests - @pytest.mark.skip("TODO") - def test_cdf(self, sampler, expected): + def test_cdf(self, sampler): """Test :meth:`sample_scf.base.rv_potential.cdf`.""" - assert False + assert sampler.cdf(0.0) == 0.0 # /def diff --git a/sample_scf/tests/test_exact.py b/sample_scf/tests/test_exact.py index 35d76cc..2a19328 100644 --- a/sample_scf/tests/test_exact.py +++ b/sample_scf/tests/test_exact.py @@ -23,7 +23,7 @@ ############################################################################## # PARAMETERS -rgrid = np.concatenate(([0], np.geomspace(1e-1, 1e3, 100))) +rgrid = np.concatenate(([0], np.geomspace(1e-1, 1e3, 29))) # same shape as ↓ tgrid = np.linspace(-np.pi / 2, np.pi / 2, 30) pgrid = np.linspace(0, 2 * np.pi, 30) @@ -37,11 +37,19 @@ class _request: def __init__(self, param): self.param = param + # /def + + +# /class + def getpot(name): return next(conftest.potentials.__wrapped__(_request(name))) +# /def + + class Test_SCFSampler(SCFSamplerTestBase): """Test :class:`sample_scf.exact.SCFSampler`.""" @@ -92,15 +100,70 @@ def test_cdf(self, sampler, r, theta, phi, expected): # =============================================================== # Plot Tests - @pytest.mark.skip("TODO!") - def test_exact_cdf_plot(self): - assert False + def test_exact_cdf_plot(self, sampler): + """Plot cdf.""" + kw = dict(marker="o", ms=5, c="k", zorder=5, label="CDF") + cdf = sampler.cdf(rgrid, tgrid, pgrid) + + fig = plt.figure(figsize=(15, 3)) + + # r + ax = fig.add_subplot( + 131, + title=r"$m(\leq r) / m_{tot}$", + xlabel="r", + ylabel=r"$m(\leq r) / m_{tot}$", + ) + ax.semilogx(rgrid, cdf[:, 0], **kw) + + # theta + ax = fig.add_subplot( + 132, + title=r"CDF($\theta$)", + xlabel=r"$\theta$", + ylabel=r"CDF($\theta$)", + ) + ax.plot(tgrid, cdf[:, 1], **kw) + + # phi + ax = fig.add_subplot( + 133, + title=r"CDF($\phi$)", + xlabel=r"$\phi$", + ylabel=r"CDF($\phi$)", + ) + ax.plot(pgrid, cdf[:, 2], **kw) + + return fig # /def - @pytest.mark.skip("TODO!") - def test_exact_sampling_plot(self): - assert False + def test_exact_sampling_plot(self, sampler): + """Plot sampling.""" + samples = sampler.rvs(size=int(1e3), random_state=3) + + fig = plt.figure(figsize=(15, 4)) + + ax = fig.add_subplot( + 131, + title=r"$m(\leq r) / m_{tot}$", + xlabel="r", + ylabel=r"$m(\leq r) / m_{tot}$", + ) + ax.hist(samples.r.value[samples.r < 5e3], log=True, bins=50, density=True) + + ax = fig.add_subplot( + 132, + title=r"CDF($\theta$)", + xlabel=r"$\theta$", + ylabel=r"CDF($\theta$)", + ) + ax.hist(samples.theta.value, bins=50, density=True) + + ax = fig.add_subplot(133, title=r"CDF($\phi$)", xlabel=r"$\phi$", ylabel=r"CDF($\phi$)") + ax.hist(samples.phi.value, bins=50) + + return fig # /def @@ -384,7 +447,7 @@ def test_exact_theta_cdf_plot(self, sampler): ylabel=r"CDF($\theta$)", ) kw = dict(marker="o", ms=5, c="k", zorder=5, label="CDF") - ax.plot(tgrid, sampler.cdf(tgrid, r=10)[0, :], **kw) + ax.plot(tgrid, sampler.cdf(tgrid, r=10), **kw) ax.axvline(-np.pi / 2, c="tab:blue") ax.axhline(sampler.cdf(-np.pi / 2, r=10), c="tab:blue", label=r"$\theta=-\frac{\pi}{2}$") ax.axvline(0, c="tab:green") @@ -400,7 +463,7 @@ def test_exact_theta_cdf_plot(self, sampler): xlabel=r"x$", ylabel=r"CDF($x$)", ) - ax.plot(x_of_theta(tgrid), sampler.cdf(tgrid, r=10)[0, :], **kw) + ax.plot(x_of_theta(tgrid), sampler.cdf(tgrid, r=10), **kw) ax.axvline(x_of_theta(-1), c="tab:blue") ax.axhline(sampler.cdf(-1, r=10), c="tab:blue", label=r"$\theta=-\frac{\pi}{2}$") ax.axvline(x_of_theta(0), c="tab:green") diff --git a/sample_scf/utils.py b/sample_scf/utils.py index e5ae1b3..10b361d 100644 --- a/sample_scf/utils.py +++ b/sample_scf/utils.py @@ -227,7 +227,69 @@ def thetaQls(pot: SCFPotential, r: T.Union[float, NDArray64]) -> NDArray64: # ------------------------------------------------------------------- -def _phiRSms( +def _pnts_phiRSms( + rhoTilde: NDArray64, + Acos: NDArray64, + Asin: NDArray64, + r: npt.ArrayLike, + theta: npt.ArrayLike, +) -> T.Tuple[NDArray64, NDArray64]: + """Radial and inclination sums for azimuthal weighting factors. + + Parameters + ---------- + rhoTilde: (R, N, L) ndarray + Acos, Asin : (N, L, L) ndarray + r, theta : float or ndarray[float] + With shapes (R,), (T,), respectively. + + Returns + ------- + Rm, Sm : (R, T, L) ndarray + Azimuthal weighting factors. + """ + # need r and theta to be arrays. Maintains units. + tgrid: NDArray64 = atleast_1d(theta) + + # transform to correct shape for vectorized computation + x = x_of_theta(tgrid) # (R/T,) + Xs = x[:, None, None, None] # (R/T, {N}, {L}, {L}) + + # compute the r-dependent coefficient matrix $\tilde{\rho}$ + nmax, lmax = Acos.shape[:2] + RhoT = rhoTilde[:, :, :, None] # (R/T, N, L, {L}) + + # legendre polynomials + with warnings.catch_warnings(): # there's a RuntimeWarning to ignore + warnings.simplefilter("ignore") + lps = lpmn_vec(lmax - 1, lmax - 1, x)[0] # drop deriv + + PP = np.stack(lps, axis=0).astype(float)[:, None, :, :] + # (R/T, {N}, L, L) + + # full R & S matrices + RSnlm = RhoT * sqrt(1 - Xs ** 2) * PP # (R/T, N, L, L) + + # n-sum # (R/T, N, L, L) -> (R/T, L, L) + Rlm = sum(Acos[None, :, :, :] * RSnlm, axis=1) + Slm = sum(Asin[None, :, :, :] * RSnlm, axis=1) + # fix adding +/- inf -> NaN. happens when r=0. + idx = np.all(np.isnan(Rlm[:, 0, :]), axis=-1) + Rlm[idx, 0, :] = nan_to_num(Rlm[idx, 0, :]) + Slm[idx, 0, :] = nan_to_num(Slm[idx, 0, :]) + + # m-sum # (R/T, L) + sumidx = range(Rlm.shape[1]) + Rm = stack([sum(Rlm[:, m:, m], axis=1) for m in sumidx], axis=1) + Sm = stack([sum(Slm[:, m:, m], axis=1) for m in sumidx], axis=1) + + return Rm, Sm + + +# /def + + +def _grid_phiRSms( rhoTilde: NDArray64, Acos: NDArray64, Asin: NDArray64, @@ -293,6 +355,7 @@ def phiRSms( pot: SCFPotential, r: npt.ArrayLike, theta: npt.ArrayLike, + grid: bool = True, ) -> T.Tuple[NDArray64, NDArray64]: r"""Radial and inclination sums for azimuthal weighting factors. @@ -331,7 +394,10 @@ def phiRSms( ) # pass to actual calculator, which takes the matrices and r, theta grids. - Rm, Sm = _phiRSms(rhoTilde, pot._Acos, pot._Asin, rgrid, tgrid) + if grid: + Rm, Sm = _grid_phiRSms(rhoTilde, pot._Acos, pot._Asin, rgrid, tgrid) + else: + Rm, Sm = _pnts_phiRSms(rhoTilde, pot._Acos, pot._Asin, rgrid, tgrid) return Rm, Sm From 629c790db795776aca1bc025a55a491be3f67bd9 Mon Sep 17 00:00:00 2001 From: nstarman Date: Fri, 11 Mar 2022 18:02:09 -0500 Subject: [PATCH 28/31] refactor Signed-off-by: nstarman --- .mypy.ini | 50 ++ docs/sample_scf/index.rst | 10 + sample_scf/__init__.py | 6 +- sample_scf/_typing.py | 12 +- sample_scf/base.py | 235 ++++--- sample_scf/cdf_strategy.py | 116 ++++ sample_scf/conftest.py | 118 ++-- sample_scf/core.py | 79 +-- sample_scf/exact.py | 544 ---------------- sample_scf/exact/__init__.py | 5 + sample_scf/exact/core.py | 78 +++ sample_scf/exact/rvs_azimuth.py | 225 +++++++ sample_scf/exact/rvs_inclination.py | 159 +++++ sample_scf/exact/rvs_radial.py | 81 +++ sample_scf/{ => exact}/tests/test_exact.py | 207 ++---- sample_scf/interpolated.py | 608 ------------------ sample_scf/interpolated/__init__.py | 5 + sample_scf/interpolated/core.py | 168 +++++ sample_scf/interpolated/rvs_azimuth.py | 221 +++++++ sample_scf/interpolated/rvs_inclination.py | 211 ++++++ sample_scf/interpolated/rvs_radial.py | 92 +++ .../tests/test_interpolated.py | 225 ++----- sample_scf/tests/common.py | 121 +--- sample_scf/tests/test_base.py | 172 ++--- sample_scf/tests/test_conftest.py | 120 ++++ sample_scf/tests/test_core.py | 49 +- sample_scf/tests/test_init.py | 27 +- sample_scf/tests/test_utils.py | 48 +- sample_scf/utils.py | 183 +++--- setup.py | 15 + 30 files changed, 2167 insertions(+), 2023 deletions(-) create mode 100644 .mypy.ini create mode 100644 docs/sample_scf/index.rst create mode 100644 sample_scf/cdf_strategy.py delete mode 100644 sample_scf/exact.py create mode 100644 sample_scf/exact/__init__.py create mode 100644 sample_scf/exact/core.py create mode 100644 sample_scf/exact/rvs_azimuth.py create mode 100644 sample_scf/exact/rvs_inclination.py create mode 100644 sample_scf/exact/rvs_radial.py rename sample_scf/{ => exact}/tests/test_exact.py (81%) delete mode 100644 sample_scf/interpolated.py create mode 100644 sample_scf/interpolated/__init__.py create mode 100644 sample_scf/interpolated/core.py create mode 100644 sample_scf/interpolated/rvs_azimuth.py create mode 100644 sample_scf/interpolated/rvs_inclination.py create mode 100644 sample_scf/interpolated/rvs_radial.py rename sample_scf/{ => interpolated}/tests/test_interpolated.py (78%) create mode 100644 sample_scf/tests/test_conftest.py diff --git a/.mypy.ini b/.mypy.ini new file mode 100644 index 0000000..86d2813 --- /dev/null +++ b/.mypy.ini @@ -0,0 +1,50 @@ +# Global options: + +[mypy] +python_version = 3.8 + +disallow_untyped_defs = True +no_implicit_reexport = True + +warn_unused_configs = True +warn_redundant_casts = True +warn_unused_ignores = True +no_warn_no_return = True +warn_return_any = True +warn_unreachable = True + +plugins = numpy.typing.mypy_plugin + + +####################################### +# Per-module options: + +[mypy-*/tests.*] +ignore_errors = True + +####################################### +# missing imports + +[mypy-astropy.*] +ignore_missing_imports = True + +[mypy-numpy.*] +ignore_missing_imports = True + +[mypy-matplotlib.*] +ignore_missing_imports = True + +[mypy-galpy.*] +ignore_missing_imports = True + +[mypy-pytest.*] +ignore_missing_imports = True + +[mypy-pytest_astropy_header.display] +ignore_missing_imports = True + +[mypy-scipy.*] +ignore_missing_imports = True + +[mypy-setuptools_scm.*] +ignore_missing_imports = True diff --git a/docs/sample_scf/index.rst b/docs/sample_scf/index.rst new file mode 100644 index 0000000..53f7dae --- /dev/null +++ b/docs/sample_scf/index.rst @@ -0,0 +1,10 @@ +*********************** +sampleSCF Documentation +*********************** + +This is the documentation for sampleSCF. + +Reference/API +============= + +.. automodapi:: sample_scf diff --git a/sample_scf/__init__.py b/sample_scf/__init__.py index c31a545..9bb0db0 100644 --- a/sample_scf/__init__.py +++ b/sample_scf/__init__.py @@ -4,7 +4,7 @@ # LOCAL from sample_scf._astropy_init import * # isort: +split # noqa: F401, F403 from sample_scf.core import SCFSampler -from sample_scf.exact import SCFSampler as SCFSamplerExact -from sample_scf.interpolated import SCFSampler as SCFSamplerInterp +from sample_scf.exact import ExactSCFSampler +from sample_scf.interpolated import InterpolatedSCFSampler -__all__ = ["SCFSampler", "SCFSamplerExact", "SCFSamplerInterp"] +__all__ = ["SCFSampler", "ExactSCFSampler", "InterpolatedSCFSampler"] diff --git a/sample_scf/_typing.py b/sample_scf/_typing.py index bea1a38..077d45f 100644 --- a/sample_scf/_typing.py +++ b/sample_scf/_typing.py @@ -1,11 +1,15 @@ # -*- coding: utf-8 -*- # Licensed under a 3-clause BSD style license - see LICENSE.rst + +"""Custom typing.""" + # BUILT-IN -import typing as T +from typing import Union # THIRD PARTY import numpy as np -import numpy.typing as npt +from numpy.typing import ArrayLike, NDArray -RandomLike = T.Union[None, int, np.random.RandomState] -NDArray64 = npt.NDArray[np.float64] +RandomGenerator = Union[np.random.RandomState, np.random.Generator] +RandomLike = Union[None, int, RandomGenerator] +NDArrayF = NDArray[np.floating] diff --git a/sample_scf/base.py b/sample_scf/base.py index 192434c..9287ae9 100644 --- a/sample_scf/base.py +++ b/sample_scf/base.py @@ -1,10 +1,6 @@ # -*- coding: utf-8 -*- -"""Base class for sampling from an SCF Potential. - -Description. - -""" +"""Base class for sampling from an SCF Potential.""" ############################################################################## # IMPORTS @@ -12,23 +8,23 @@ from __future__ import annotations # BUILT-IN -import typing as T from abc import ABCMeta +from typing import Optional, Tuple, Union # THIRD PARTY import astropy.units as u import numpy as np -import numpy.typing as npt from astropy.coordinates import PhysicsSphericalRepresentation from astropy.utils.misc import NumpyRNGContext from galpy.potential import SCFPotential +from numpy.typing import ArrayLike from scipy._lib._util import check_random_state from scipy.stats import rv_continuous # LOCAL -from ._typing import NDArray64, RandomLike +from sample_scf._typing import NDArrayF, RandomGenerator, RandomLike -__all__: T.List[str] = [] +__all__ = [] ############################################################################## @@ -38,24 +34,80 @@ class rv_potential(rv_continuous, metaclass=ABCMeta): """ - Modified :class:`scipy.stats.rv_continuous` to use custom rvs methods. + Modified :class:`scipy.stats.rv_continuous` to use custom ``rvs`` methods. Made by stripping down the original scipy implementation. See :class:`scipy.stats.rv_continuous` for details. + + Parameters + ---------- + `rv_continuous` is a base class to construct specific distribution classes + and instances for continuous random variables. It cannot be used + directly as a distribution. + + Parameters + ---------- + potential : `galpy.potential.SCFPotential` + The potential from which to sample. + momtype : int, optional + The type of generic moment calculation to use: 0 for pdf, 1 (default) + for ppf. + a : float, optional + Lower bound of the support of the distribution, default is minus + infinity. + b : float, optional + Upper bound of the support of the distribution, default is plus + infinity. + xtol : float, optional + The tolerance for fixed point calculation for generic ppf. + badvalue : float, optional + The value in a result arrays that indicates a value that for which + some argument restriction is violated, default is np.nan. + name : str, optional + The name of the instance. This string is used to construct the default + example for distributions. + longname : str, optional + This string is used as part of the first line of the docstring returned + when a subclass has no docstring of its own. Note: `longname` exists + for backwards compatibility, do not use for new subclasses. + shapes : str, optional + The shape of the distribution. For example ``"m, n"`` for a + distribution that takes two integers as the two shape arguments for all + its methods. If not provided, shape parameters will be inferred from + the signature of the private methods, ``_pdf`` and ``_cdf`` of the + instance. + extradoc : str, optional, deprecated + This string is used as the last part of the docstring returned when a + subclass has no docstring of its own. Note: `extradoc` exists for + backwards compatibility, do not use for new subclasses. + seed : {None, int, `numpy.random.Generator`, + `numpy.random.RandomState`}, optional + + If `seed` is None (or `np.random`), the `numpy.random.RandomState` + singleton is used. + If `seed` is an int, a new ``RandomState`` instance is used, + seeded with `seed`. + If `seed` is already a ``Generator`` or ``RandomState`` instance then + that instance is used. """ + _random_state: RandomGenerator + _potential: SCFPotential + _nmax: int + _lmax: int + def __init__( self, potential: SCFPotential, momtype: int = 1, - a: T.Optional[float] = None, - b: T.Optional[float] = None, + a: Optional[float] = None, + b: Optional[float] = None, xtol: float = 1e-14, - badvalue: T.Optional[float] = None, - name: T.Optional[str] = None, - longname: T.Optional[str] = None, - shapes: T.Optional[T.Tuple[int, ...]] = None, - extradoc: T.Optional[str] = None, - seed: T.Optional[int] = None, + badvalue: Optional[float] = None, + name: Optional[str] = None, + longname: Optional[str] = None, + shapes: Optional[Tuple[int, ...]] = None, + extradoc: Optional[str] = None, + seed: Optional[int] = None, ): super().__init__( momtype=momtype, @@ -74,17 +126,28 @@ def __init__( raise TypeError( f"potential must be , not {type(potential)}", ) - self._potential: SCFPotential = potential + self._potential = potential self._nmax, self._lmax = potential._Acos.shape[:2] - # /def + @property + def potential(self) -> SCFPotential: + """The potential from which to sample""" + return self._potential + + @property + def nmax(self) -> int: + return self._nmax + + @property + def lmax(self) -> int: + return self._lmax def rvs( self, - *args: T.Union[float, npt.ArrayLike], - size: T.Optional[int] = None, + *args: Union[np.floating, ArrayLike], + size: Optional[int] = None, random_state: RandomLike = None, - ) -> NDArray64: + ) -> NDArrayF: """Random variate sampler. Parameters @@ -103,32 +166,46 @@ def rvs( ndarray[float] Shape 'size'. """ + # copied from `scipy` # extra gymnastics needed for a custom random_state - rndm: np.random.RandomState + rndm: RandomGenerator if random_state is not None: random_state_saved = self._random_state rndm = check_random_state(random_state) else: rndm = self._random_state - vals: NDArray64 = self._rvs(*args, size=size, random_state=rndm) + # go directly to `_rvs` + vals: NDArrayF = self._rvs(*args, size=size, random_state=rndm) + # copied from `scipy` # do not forget to restore the _random_state if random_state is not None: - self._random_state: np.random.RandomState = random_state_saved + self._random_state = random_state_saved return vals.squeeze() - # /def +# ------------------------------------------------------------------- -# /class +class r_distribution_base(rv_potential): + """Sample radial coordinate from an SCF potential. -# ------------------------------------------------------------------- + The potential must have a convergent mass function. + Parameters + ---------- + potential : `galpy.potential.SCFPotential` + """ + + pass -class SCFSamplerBase: + +############################################################################## + + +class SCFSamplerBase(metaclass=ABCMeta): """Sample SCF in spherical coordinates. The coordinate system is: @@ -141,45 +218,47 @@ class SCFSamplerBase: pot : `galpy.potential.SCFPotential` """ - def __init__( - self, - pot: SCFPotential, - ): - self._pot = pot + _potential: SCFPotential + _r_distribution: rv_potential + _theta_distribution: rv_potential + _phi_distribution: rv_potential + + def __init__(self, potential: SCFPotential): + potential.turn_physical_on() + self._potential = potential - # /def + # child classes set up the samplers - _rsampler: rv_potential - _thetasampler: rv_potential - _phisampler: rv_potential + # ----------------------------------------------------- + + @property + def potential(self) -> SCFPotential: + """The SCF Potential instance.""" + return self._potential @property def rsampler(self) -> rv_potential: """Radial coordinate sampler.""" - return self._rsampler - - # /def + return self._r_distribution @property def thetasampler(self) -> rv_potential: """Inclination coordinate sampler.""" - return self._thetasampler - - # /def + return self._theta_distribution @property def phisampler(self) -> rv_potential: """Azimuthal coordinate sampler.""" - return self._phisampler + return self._phi_distribution - # /def + # ----------------------------------------------------- def cdf( self, - r: npt.ArrayLike, - theta: npt.ArrayLike, - phi: npt.ArrayLike, - ) -> NDArray64: + r: ArrayLike, + theta: ArrayLike, + phi: ArrayLike, + ) -> NDArrayF: """ Cumulative Distribution Functions in r, theta(r), phi(r, theta) @@ -198,17 +277,15 @@ def cdf( theta = np.asanyarray(theta, dtype=float) phi = np.asanyarray(phi, dtype=float) - R: NDArray64 = self.rsampler.cdf(r) - Theta: NDArray64 = self.thetasampler.cdf(theta, r=r) - Phi: NDArray64 = self.phisampler.cdf(phi, r=r, theta=theta) + R: NDArrayF = self.rsampler.cdf(r) + Theta: NDArrayF = self.thetasampler.cdf(theta, r=r) + Phi: NDArrayF = self.phisampler.cdf(phi, r=r, theta=theta) return np.c_[R, Theta, Phi].squeeze() - # /def - def rvs( self, *, - size: T.Optional[int] = None, + size: Optional[int] = None, random_state: RandomLike = None, vectorized=True, ) -> PhysicsSphericalRepresentation: @@ -228,39 +305,27 @@ def rvs( ------- `~astropy.coordinates.PhysicsSphericalRepresentation` """ + # TODO! fix that thetasampler is off by pi/2 + rs = self.rsampler.rvs(size=size, random_state=random_state) if vectorized: - thetas = self.thetasampler.rvs(rs, size=size, random_state=random_state) + thetas = np.pi / 2 - self.thetasampler.rvs(rs, size=size, random_state=random_state) phis = self.phisampler.rvs(rs, thetas, size=size, random_state=random_state) - else: - # TODO! speed up - with NumpyRNGContext(random_state): - thetas = np.array( - [ - self.thetasampler.rvs(r, size=1, random_state=None) - for r in np.atleast_1d(rs) - ], - ) - phis = np.array( - [ - self.phisampler.rvs(r, th, size=1, random_state=None) - for r, th in zip(np.atleast_1d(rs), np.atleast_1d(thetas)) - ], - ) - - crd = PhysicsSphericalRepresentation( - r=rs, - theta=(np.pi / 2 - thetas) * u.rad, - phi=phis * u.rad, - ) - return crd - - # /def + else: # TODO! speed up + # sample from theta and phi. Note that each needs to be in a separate + # NumpyRNGContext to ensure that the results match the vectorized + # option, above. + kw = dict(size=1, random_state=None) + with NumpyRNGContext(random_state): + rsd = np.atleast_1d(rs) + thetas = np.pi / 2 - np.array([self.thetasampler.rvs(r, **kw) for r in rsd]) -# /class + with NumpyRNGContext(random_state): + thd = np.atleast_1d(thetas) + phis = np.array([self.phisampler.rvs(r, th, **kw) for r, th in zip(rsd, thd)]) -############################################################################## -# END + crd = PhysicsSphericalRepresentation(r=rs, theta=thetas << u.rad, phi=phis << u.rad) + return crd diff --git a/sample_scf/cdf_strategy.py b/sample_scf/cdf_strategy.py new file mode 100644 index 0000000..49a024d --- /dev/null +++ b/sample_scf/cdf_strategy.py @@ -0,0 +1,116 @@ +# -*- coding: utf-8 -*- + +""" +Deal with non-monotonic CDFs. +The problem can arise if the PDF (density field) ever dips negative because of +an incorrect solution to the SCF coefficients. E.g. when solving for the +coefficients from an analytic density profile. + +""" + +# __all__ = [ +# # functions +# "", +# # other +# "", +# ] + + +############################################################################## +# IMPORTS + +# BUILT-IN +import abc +import inspect + +# THIRD PARTY +import numpy as np +from astropy.utils.state import ScienceState + +############################################################################## +# PARAMETERS + +CDF_STRATEGIES = {} + +############################################################################## +# CODE +############################################################################## + + +class default_cdf_strategy(ScienceState): + + _value = "error" + _default_value = "error" + + @classmethod + def validate(cls, value): + if value is None: + value = self._default_value + + if isinstance(value, str): + if value not in CDF_STRATEGIES: + raise ValueError + return CDF_STRATEGIES[value] + elif inspect.isclass(value) and issubclass(value, CDFStrategy): + return value + else: + raise TypeError() + + +# ============================================================================= + + +class CDFStrategy: + def __init_subclass__(cls, key, **kwargs): + CDF_STRATEGIES[key] = cls + + @classmethod + @abc.abstractmethod + def apply(cls, cdf, **kw): + pass + + +# ------------------------------------------------------------------- + + +class Error(CDFStrategy, key="error"): + @classmethod + def apply(cls, cdf, **kw): + """ + + .. warning:: + operates in-place on numpy arrays + + """ + # find where cdf breaks monotonicity + notreal = np.where(np.diff(cdf) <= 0)[0] + 1 + # raise error if any breaks + if np.any(notreal): + msg = "cdf contains unreal elements " + msg += f"at index {kw['index']}" if "index" in kw else "" + raise ValueError(msg) + + +# ------------------------------------------------------------------- + + +class LinearInterpolate(CDFStrategy, key="linear"): + @classmethod + def apply(cls, cdf, **kw): + """ + + .. warning:: + operates in-place on numpy arrays + + """ + # find where cdf breaks monotonicity + # and the startpoint of each break. + notreal = np.where(np.diff(cdf) <= 0)[0] + 1 + startnotreal = np.concatenate((notreal[:1], notreal[np.where(np.diff(notreal) > 1)[0] + 1])) + + for i in startnotreal[:-1]: + i0 = i - 1 # before it dips negative + i1 = i0 + np.argmax(cdf[i0:] - cdf[i0] > 0) # start of net positive + cdf[i0 : i1 + 1] = np.linspace(cdf[i0], cdf[i1], num=i1 - i0 + 1, endpoint=True) + + return cdf diff --git a/sample_scf/conftest.py b/sample_scf/conftest.py index c220e42..9146335 100644 --- a/sample_scf/conftest.py +++ b/sample_scf/conftest.py @@ -11,13 +11,21 @@ """ # BUILT-IN +import copy import os # THIRD PARTY import numpy as np import pytest -from galpy.df import isotropicHernquistdf -from galpy.potential import HernquistPotential, SCFPotential +from astropy.utils.data import get_pkg_data_filename, get_pkg_data_path +from galpy.df import isotropicHernquistdf, isotropicNFWdf, osipkovmerrittNFWdf +from galpy.potential import ( + HernquistPotential, + NFWPotential, + SCFPotential, + TriaxialNFWPotential, + scf_compute_coeffs_axi, +) try: # THIRD PARTY @@ -55,62 +63,84 @@ def pytest_configure(config): TESTED_VERSIONS[packagename] = __version__ -# /def - - -# Uncomment the last two lines in this block to treat all DeprecationWarnings as -# exceptions. For Astropy v2.0 or later, there are 2 additional keywords, -# as follow (although default should work for most cases). -# To ignore some packages that produce deprecation warnings on import -# (in addition to 'compiler', 'scipy', 'pygments', 'ipykernel', and -# 'setuptools'), add: -# modules_to_ignore_on_import=['module_1', 'module_2'] -# To ignore some specific deprecation warning messages for Python version -# MAJOR.MINOR or later, add: -# warnings_to_ignore_by_pyver={(MAJOR, MINOR): ['Message to ignore']} -# from astropy.tests.helper import enable_deprecations_as_exceptions # noqa: F401 -# enable_deprecations_as_exceptions() - - # ============================================================================ # Fixtures # Hernquist -_Acos = np.zeros((5, 6, 6)) -_Acos_hern = _Acos.copy() -_Acos_hern[0, 0, 0] = 1 -_hernquist_potential = SCFPotential(Acos=_Acos_hern) -hernquist_df = isotropicHernquistdf(HernquistPotential()) - - -@pytest.fixture(autouse=True, scope="session") +hernquist_potential = HernquistPotential() +hernquist_potential.turn_physical_on() +hernquist_df = isotropicHernquistdf(hernquist_potential) + +Acos = np.zeros((5, 6, 6)) +Acos[0, 0, 0] = 1 +_hernquist_scf_potential = SCFPotential(Acos=Acos) +_hernquist_scf_potential.turn_physical_on() + + +# NFW +nfw_potential = NFWPotential(normalize=1) +nfw_potential.turn_physical_on() +nfw_df = isotropicNFWdf(nfw_potential, rmax=1e4) +# FIXME! load this up as a test data file +fpath = get_pkg_data_path("tests/data/nfw.npz", package="sample_scf") +try: + data = np.load(fpath) +except FileNotFoundError: + a_scf = 80 + Acos, Asin = scf_compute_coeffs_axi(nfw_potential.dens, N=40, L=30, a=a_scf) + np.savez(fpath, Acos=Acos, Asin=Asin, a_scf=a_scf) +else: + data = np.load(fpath, allow_pickle=True) + Acos = copy.deepcopy(data["Acos"]) + Asin = None + a_scf = data["a_scf"] + +_nfw_scf_potential = SCFPotential(Acos=Acos, Asin=None, a=a_scf, normalize=1.0) +_nfw_scf_potential.turn_physical_on() + + +# Triaxial NFW +# tnfw_potential = TriaxialNFWPotential(normalize=1.0, c=1.4, a=1.0) +# tnfw_potential.turn_physical_on() +# tnfw_df = osipkovmerrittNFWdf(tnfw_potential, rmax=1e4) + + +# ------------------------ +cls_pot_kw = { + _hernquist_scf_potential: {"total_mass": 1.0}, + _nfw_scf_potential: {"total_mass": 1.0}, +} +theory = { + _hernquist_scf_potential: hernquist_df, + _nfw_scf_potential: nfw_df, +} + + +@pytest.fixture(scope="session") def hernquist_scf_potential(): - """Make a SCF of a Hernquist potential.""" - return _hernquist_potential + """Make a SCF of a Hernquist potential. - -# /def + This is tested for quality in ``test_conftest.py`` + """ + return _hernquist_scf_potential -# @pytest.fixture(autouse=True, scope="session") -# def nfw_scf_potential(): -# """Make a SCF of a triaxial NFW potential.""" -# raise NotImplementedError("TODO") -# -# -# # /def +@pytest.fixture(scope="session") +def nfw_scf_potential(): + """Make a SCF of a triaxial NFW potential.""" + return _nfw_scf_potential @pytest.fixture( - # autouse=True, - scope="session", params=[ - "hernquist_scf_potential", # TODO! use hernquist_scf_potential - "other_hernquist_scf_potential", + "hernquist_scf_potential", + # "nfw_scf_potential", # TODO! turn on ], ) def potentials(request): - if request.param in ("hernquist_scf_potential", "other_hernquist_scf_potential"): - potential = _hernquist_potential + if request.param in ("hernquist_scf_potential"): + potential = hernquist_scf_potential.__wrapped__() + elif request.param == "nfw_scf_potential": + potential = nfw_scf_potential.__wrapped__() yield potential diff --git a/sample_scf/core.py b/sample_scf/core.py index d52c2e5..033917f 100644 --- a/sample_scf/core.py +++ b/sample_scf/core.py @@ -12,25 +12,25 @@ from __future__ import annotations # BUILT-IN -import typing as T from collections.abc import Mapping +from typing import Any, Literal, Optional, Type, TypedDict, Union # THIRD PARTY from galpy.potential import SCFPotential # LOCAL from .base import SCFSamplerBase, rv_potential -from .exact import SCFSampler as SCFSamplerExact -from .interpolated import SCFSampler as SCFSamplerInterp +from .exact import ExactSCFSampler +from .interpolated import InterpolatedSCFSampler -__all__: T.List[str] = ["SCFSampler"] +__all__ = ["SCFSampler"] ############################################################################## # Parameters -class MethodsMapping(T.TypedDict): +class MethodsMapping(TypedDict): r: rv_potential theta: rv_potential phi: rv_potential @@ -41,42 +41,6 @@ class MethodsMapping(T.TypedDict): ############################################################################## -# class SCFSamplerSwitch(ABCMeta): -# def __new__( -# cls: T.Type[SCFSamplerSwitch], -# name: str, -# bases: T.Tuple[type, ...], -# dct: T.Dict[str, T.Any], -# **kwds: T.Any -# ) -> SCFSamplerSwitch: -# -# method: str = dct["method"] -# -# if method == "interp": -# # LOCAL -# from sample_scf.interpolated import SCFSampler as interpcls -# -# bases = (interpcls,) -# -# elif method == "exact": -# # LOCAL -# from sample_scf.exact import SCFSampler as exactcls -# -# bases = (exactcls,) -# elif isinstance(method, Mapping): -# pass -# else: -# raise ValueError("`method` must be {'interp', 'exact'} or mapping.") -# -# self = super().__new__(cls, name, bases, dct) -# return self -# -# # /def - - -# /class - - class SCFSampler(SCFSamplerBase): # metaclass=SCFSamplerSwitch """Sample SCF in spherical coordinates. @@ -97,37 +61,32 @@ class SCFSampler(SCFSamplerBase): # metaclass=SCFSamplerSwitch def __init__( self, potential: SCFPotential, - method: T.Union[T.Literal["interp", "exact"], MethodsMapping], - **kwargs: T.Any + method: Union[Literal["interp", "exact"], MethodsMapping], + **kwargs: Any, ) -> None: super().__init__(potential) - if isinstance(method, Mapping): + if isinstance(method, Mapping): # mix and match exact and interpolated sampler = None rsampler = method["r"](potential, **kwargs) thetasampler = method["theta"](potential, **kwargs) phisampler = method["phi"](potential, **kwargs) - else: - sampler_cls: T.Type[SCFSamplerBase] + + else: # either exact or interpolated + sampler_cls: Type[SCFSamplerBase] if method == "interp": - sampler_cls = SCFSamplerInterp + sampler_cls = InterpolatedSCFSampler elif method == "exact": - sampler_cls = SCFSamplerExact + sampler_cls = ExactSCFSampler + else: + raise ValueError(f"method = {method} not in " + "{'interp', 'exact'}") sampler = sampler_cls(potential, **kwargs) rsampler = sampler.rsampler thetasampler = sampler.thetasampler phisampler = sampler.phisampler - self._sampler: T.Optional[SCFSamplerBase] = sampler - self._rsampler = rsampler - self._thetasampler = thetasampler - self._phisampler = phisampler - - # /def - - -# /class - -############################################################################## -# END + self._sampler: Optional[SCFSamplerBase] = sampler + self._r_distribution = rsampler + self._theta_distribution = thetasampler + self._phi_distribution = phisampler diff --git a/sample_scf/exact.py b/sample_scf/exact.py deleted file mode 100644 index b697dda..0000000 --- a/sample_scf/exact.py +++ /dev/null @@ -1,544 +0,0 @@ -# -*- coding: utf-8 -*- - -"""**DOCSTRING**. - -Description. - -""" - -############################################################################## -# IMPORTS - -from __future__ import annotations - -# BUILT-IN -import abc -import typing as T - -# THIRD PARTY -import astropy.units as u -import numpy as np -import numpy.typing as npt -from astropy.coordinates import PhysicsSphericalRepresentation -from galpy.potential import SCFPotential - -# LOCAL -from ._typing import NDArray64, RandomLike -from .base import SCFSamplerBase, rv_potential -from .utils import difPls, phiRSms, theta_of_x, thetaQls, x_of_theta - -__all__: T.List[str] = [ - "SCFSampler", - "SCFRSampler", - "SCFThetaFixedSampler", - "SCFThetaSampler", - "SCFPhiFixedSampler", - "SCFPhiSampler", -] - - -############################################################################## -# CODE -############################################################################## - - -class SCFSampler(SCFSamplerBase): - """SCF sampler in spherical coordinates. - - The coordinate system is: - - r : [0, infinity) - - theta : [-pi/2, pi/2] (positive at the North pole) - - phi : [0, 2pi) - - Parameters - ---------- - pot : `~galpy.potential.SCFPotential` - **kw - Not used. - - """ - - def __init__(self, potential: SCFPotential, **kw: T.Any) -> None: - total_mass = kw.pop("total_mass", None) - # make samplers - self._rsampler = SCFRSampler(potential, total_mass=total_mass, **kw) - self._thetasampler = SCFThetaSampler(potential, **kw) # r=None - self._phisampler = SCFPhiSampler(potential, **kw) # r=None, theta=None - - # /def - - def rvs( - self, *, size: T.Optional[int] = None, random_state: RandomLike = None - ) -> PhysicsSphericalRepresentation: - """Sample random variates. - - Parameters - ---------- - size : int or None (optional, keyword-only) - Defining number of random variates. - random_state : int, `~numpy.random.RandomState`, or None (optional, keyword-only) - If seed is None (or numpy.random), the `numpy.random.RandomState` - singleton is used. If seed is an int, a new RandomState instance is - used, seeded with seed. If seed is already a Generator or - RandomState instance then that instance is used. - - Returns - ------- - `~astropy.coordinates.PhysicsSphericalRepresentation` - """ - return super().rvs(size=size, random_state=random_state, vectorized=False) - - # /def - - -# /class - - -# ------------------------------------------------------------------- -# radial sampler - - -class SCFRSampler(rv_potential): - """Sample radial coordinate from an SCF potential. - - Parameters - ---------- - pot : `~galpy.potential.SCFPotential` - A potential that can be used to calculate the enclosed mass. - **kw - Not used. - """ - - def __init__(self, potential: SCFPotential, total_mass=None, **kw: T.Any) -> None: - # make sampler - kw["a"], kw["b"] = 0, np.inf # allowed range of r - super().__init__(potential, **kw) - - # normalization for total mass - if total_mass is None: - total_mass = potential._mass(np.inf) - if np.isnan(total_mass): - raise ValueError( - "Total mass is NaN. Need to pass kwarg " "`total_mass` with a non-NaN value.", - ) - self._mtot = total_mass - # vectorize mass function, which is scalar - self._vec_cdf = np.vectorize(self._potential._mass) - - # /def - - def _cdf(self, r: npt.ArrayLike, *args: T.Any, **kw: T.Any) -> NDArray64: - """Cumulative Distribution Function. - - Parameters - ---------- - r : array-like - *args - **kwargs - - Returns - ------- - mass : array-like - Shape matches 'r'. - """ - mass: NDArray64 = np.atleast_1d(self._vec_cdf(r)) / self._mtot - mass[r == 0] = 0 - mass[r == np.inf] = 1 - return mass.item() if mass.shape == (1,) else mass - - cdf = _cdf - # /def - - -# /class - -############################################################################## -# Inclination sampler - - -class SCFThetaSamplerBase(rv_potential): - """Base class for sampling the inclination coordinate.""" - - def __init__(self, potential: SCFPotential, **kw: T.Any) -> None: - kw["a"], kw["b"] = -np.pi / 2, np.pi / 2 # allowed range of theta - super().__init__(potential, **kw) - self._lrange = np.arange(0, self._lmax + 1) # lmax inclusive - - # /def - - @abc.abstractmethod - def _cdf(self, x: NDArray64, Qls: NDArray64) -> NDArray64: - xs = np.atleast_1d(x) - Qls = np.atleast_2d(Qls) # ({R}, L) - - # l = 0 - term0 = 0.5 * (xs + 1.0) # (T,) - # l = 1+ : non-symmetry - factor = 1.0 / (2.0 * Qls[:, 0]) # (R,) - term1p = np.sum( - (Qls[:, 1:] * difPls(xs, self._lmax - 1).T).T, - axis=0, - ) - # difPls shape (L, T) -> (T, L) - # term1p shape (R/T, L) -> (L, R/T) -> sum -> (R/T,) - - cdf = term0 + np.nan_to_num(factor * term1p) # (R/T,) - return cdf - - # /def - - def _rvs( - self, - *args: T.Union[float, npt.ArrayLike], - size: T.Optional[int] = None, - random_state: RandomLike = None, - ) -> NDArray64: - xs = super()._rvs(*args, size=size, random_state=random_state) - rvs = theta_of_x(xs) - return rvs - - # /def - - def _ppf_to_solve(self, x: float, q: float, *args: T.Any) -> NDArray64: - ppf: NDArray64 = self._cdf(*(x,) + args) - q - return ppf - - # /def - - -# /class - - -class SCFThetaFixedSampler(SCFThetaSamplerBase): - """ - Sample inclination coordinate from an SCF potential. - - Parameters - ---------- - pot : `~galpy.potential.SCFPotential` - r : float or None, optional - If passed, these are the locations at which the theta CDF will be - evaluated. If None (default), then the r coordinate must be given - to the CDF and RVS functions. - **kw: - Not used. - """ - - def __init__(self, potential: SCFPotential, r: float, **kw: T.Any) -> None: - super().__init__(potential) - - # points at which CDF is defined - self._r = r - self._Qlsatr = thetaQls(self._potential, r) - - # /def - - def _cdf(self, x: npt.ArrayLike, *args: T.Any) -> NDArray64: - cdf = super()._cdf(x, self._Qlsatr) - return cdf - - # /def - - def cdf(self, theta: npt.ArrayLike) -> NDArray64: - """ - Cumulative distribution function of the given RV. - - Parameters - ---------- - theta : quantity-like['angle'] - - Returns - ------- - cdf : ndarray - Cumulative distribution function evaluated at `theta` - - """ - return self._cdf(x_of_theta(u.Quantity(theta, u.rad).value)) - - # /def - - -# /class - - -class SCFThetaSampler(SCFThetaSamplerBase): - """ - Sample inclination coordinate from an SCF potential. - - Parameters - ---------- - pot : `~galpy.potential.SCFPotential` - - """ - - def _cdf(self, theta: NDArray64, *args: T.Any, r: T.Optional[float] = None) -> NDArray64: - Qls = thetaQls(self._potential, T.cast(float, r)) - cdf = super()._cdf(theta, Qls) - return cdf - - # /def - - def cdf(self, theta: npt.ArrayLike, *args: T.Any, r: float) -> NDArray64: - """ - Cumulative distribution function of the given RV. - - Parameters - ---------- - theta : quantity-like['angle'] - *args - Not used. - r : array-like[float] (optional, keyword-only) - - Returns - ------- - cdf : ndarray - Cumulative distribution function evaluated at `theta` - - """ - return self._cdf(x_of_theta(u.Quantity(theta, u.rad).value), *args, r=r) - - # /def - - def rvs( - self, r: npt.ArrayLike, *, size: T.Optional[int] = None, random_state: RandomLike = None - ) -> NDArray64: - # not thread safe! - getattr(self._cdf, "__kwdefaults__", {})["r"] = r - vals = super().rvs(size=size, random_state=random_state) - getattr(self._cdf, "__kwdefaults__", {})["r"] = None - return vals - - # /def - - -# /class - - -############################################################################### -# Azimuth sampler - - -class SCFPhiSamplerBase(rv_potential): - """Sample Azimuthal Coordinate. - - Parameters - ---------- - potential : `galpy.potential.SCFPotential` - **kw - Passed to `scipy.stats.rv_continuous` - "a", "b" are set to [0, 2 pi] - - """ - - def __init__(self, potential: SCFPotential, **kw: T.Any) -> None: - kw["a"], kw["b"] = 0, 2 * np.pi - super().__init__(potential, **kw) - self._lrange = np.arange(0, self._lmax + 1) - - # for compatibility - self._Rm: T.Optional[NDArray64] = None - self._Sm: T.Optional[NDArray64] = None - - # /def - - def _cdf(self, phi: NDArray64, *args: T.Any, **kw: T.Any) -> NDArray64: - r"""Cumulative Distribution Function. - - Parameters - ---------- - phi : float or ndarray[float] ['radian'] - Azimuthal coordinate in radians, :math:`\in [0, 2\pi]`. - *args, **kw - Not used. - - Returns - ------- - cdf : float or ndarray[float] - Shape (len(r), len(theta), len(phi)). - :meth:`numpy.ndarray.squeeze` applied so scalar inputs has scalar - output. - - """ - Rm, Sm = kw.get("RSms", (self._Rm, self._Sm)) # (R/T, L) - - Phis: NDArray64 = np.atleast_1d(phi)[:, None] # (P, {L}) - - # l = 0 : spherical symmetry - term0: NDArray64 = Phis[..., 0] / (2 * np.pi) # (1, P) - - # l = 1+ : non-symmetry - factor = 1 / Rm[:, 0] # R0 (R/T,) # can be inf - ms = np.arange(1, self._lmax)[None, :] # ({R/T/P}, L) - term1p = np.sum( - (Rm[:, 1:] * np.sin(ms * Phis) + Sm[:, 1:] * (1 - np.cos(ms * Phis))) - / (2 * np.pi * ms), - axis=-1, - ) - - cdf: NDArray64 = term0 + np.nan_to_num(factor * term1p) # (R/T/P,) - # 'factor' can be inf and term1p 0 => inf * 0 = nan -> 0 - - return cdf - - # /def - - def _ppf_to_solve(self, phi: float, q: float, *args: T.Any) -> NDArray64: - # changed from .cdf() to ._cdf() to use default 'r', 'theta' - return self._cdf(*(phi,) + args) - q - - # /def - - -# /class - - -class SCFPhiFixedSampler(SCFPhiSamplerBase): - """Sample Azimuthal Coordinate at fixed r, theta. - - Parameters - ---------- - potential : `galpy.potential.SCFPotential` - r, theta : float or ndarray[float] - - """ - - def __init__( - self, potential: SCFPotential, r: NDArray64, theta: NDArray64, **kw: T.Any - ) -> None: - super().__init__(potential, **kw) - - # assign fixed r, theta - self._r, self._theta = r, theta - # and can compute the associated assymetry measures - self._Rm, self._Sm = phiRSms(potential, r, theta, grid=False) - - # /def - - def cdf(self, phi: NDArray64, *args: T.Any, **kw: T.Any) -> NDArray64: - r"""Cumulative Distribution Function. - - Parameters - ---------- - phi : float or ndarray[float] ['radian'] - Azimuthal coordinate in radians, :math:`\in [0, 2\pi]`. - *args - **kw - - Returns - ------- - cdf : float or ndarray[float] - Shape (len(r), len(theta), len(phi)). - :meth:`numpy.ndarray.squeeze` applied so scalar inputs has scalar - output. - """ - return self._cdf(phi, *args, **kw) - - # /def - - -# /class - - -class SCFPhiSampler(SCFPhiSamplerBase): - def _cdf( - self, - phi: npt.ArrayLike, - *args: T.Any, - r: T.Optional[float] = None, - theta: T.Optional[float] = None, - ) -> NDArray64: - r"""Cumulative Distribution Function. - - Parameters - ---------- - phi : float or ndarray[float] ['radian'] - Azimuthal coordinate in radians, :math:`\in [0, 2\pi]`. - *args - r : float or ndarray[float], keyword-only - Radial coordinate at which to evaluate the CDF. Not optional. - theta : float or ndarray[float], keyword-only - Inclination coordinate at which to evaluate the CDF. Not optional. - In [-pi/2, pi/2]. - - Returns - ------- - cdf : float or ndarray[float] - Shape (len(r), len(theta), len(phi)). - :meth:`numpy.ndarray.squeeze` applied so scalar inputs has scalar - output. - - Raises - ------ - ValueError - If 'r' or 'theta' are None. - """ - RSms = phiRSms(self._potential, T.cast(float, r), T.cast(float, theta), grid=False) - cdf: NDArray64 = super()._cdf(phi, *args, RSms=RSms) - return cdf - - # /def - - def cdf(self, phi: npt.ArrayLike, *args: T.Any, r: float, theta: float) -> NDArray64: - r"""Cumulative Distribution Function. - - Parameters - ---------- - phi : quantity-like or array-like ['radian'] - Azimuthal angular coordinate, :math:`\in [0, 2\pi]`. If doesn't - have units, must be in radians. - *args - r : float or ndarray[float], keyword-only - Radial coordinate at which to evaluate the CDF. Not optional. - theta : quantity-like or array-like ['radian'], keyword-only - Inclination coordinate at which to evaluate the CDF. Not optional. - In [-pi/2, pi/2]. If doesn't have units, must be in radians. - - Returns - ------- - cdf : float or ndarray[float] - Shape (len(r), len(theta), len(phi)). - :meth:`numpy.ndarray.squeeze` applied so scalar inputs has scalar - output. - """ - phi = u.Quantity(phi, u.rad).value - cdf: NDArray64 = self._cdf(phi, *args, r=r, theta=u.Quantity(theta, u.rad).value) - return cdf - - # /def - - def rvs( # type: ignore - self, - r: float, - theta: float, - *, - size: T.Optional[int] = None, - random_state: RandomLike = None, - ) -> NDArray64: - """Random Variate Sample. - - Parameters - ---------- - r : float - theta : float - size : int or None (optional, keyword-only) - random_state : int or `numpy.random.RandomState` or None (optional, keyword-only) - - Returns - ------- - vals : ndarray[float] - - """ - getattr(self._cdf, "__kwdefaults__", {})["r"] = r - getattr(self._cdf, "__kwdefaults__", {})["theta"] = theta - vals = super().rvs(size=size, random_state=random_state) - getattr(self._cdf, "__kwdefaults__", {})["r"] = None - getattr(self._cdf, "__kwdefaults__", {})["theta"] = None - return vals - - # /def - - -# /class - -############################################################################## -# END diff --git a/sample_scf/exact/__init__.py b/sample_scf/exact/__init__.py new file mode 100644 index 0000000..6feec3e --- /dev/null +++ b/sample_scf/exact/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- +# Licensed under a 3-clause BSD style license - see LICENSE.rst + +# LOCAL +from .core import ExactSCFSampler diff --git a/sample_scf/exact/core.py b/sample_scf/exact/core.py new file mode 100644 index 0000000..ac99922 --- /dev/null +++ b/sample_scf/exact/core.py @@ -0,0 +1,78 @@ +# -*- coding: utf-8 -*- + +"""Exact sampling.""" + +############################################################################## +# IMPORTS + +from __future__ import annotations + +# BUILT-IN +import abc +import typing as T +from typing import Any + +# THIRD PARTY +from astropy.coordinates import PhysicsSphericalRepresentation +from galpy.potential import SCFPotential + +# LOCAL +from .rvs_azimuth import phi_distribution +from .rvs_inclination import theta_distribution +from .rvs_radial import r_distribution +from sample_scf._typing import NDArrayF, RandomLike +from sample_scf.base import SCFSamplerBase + +__all__ = ["SCFSampler"] + + +############################################################################## +# CODE +############################################################################## + + +class ExactSCFSampler(SCFSamplerBase): + """SCF sampler in spherical coordinates. + + The coordinate system is: + - r : [0, infinity) + - theta : [-pi/2, pi/2] (positive at the North pole) + - phi : [0, 2pi) + + Parameters + ---------- + pot : `~galpy.potential.SCFPotential` + **kw + Not used. + + """ + + def __init__(self, potential: SCFPotential, **kw: Any) -> None: + super().__init__(potential) + + # make samplers + total_mass = kw.pop("total_mass", None) + self._r_distribution = r_distribution(potential, total_mass=total_mass, **kw) + self._theta_distribution = theta_distribution(potential, **kw) # r=None + self._phi_distribution = phi_distribution(potential, **kw) # r=None, theta=None + + def rvs( + self, *, size: Optional[int] = None, random_state: RandomLike = None + ) -> PhysicsSphericalRepresentation: + """Sample random variates. + + Parameters + ---------- + size : int or None (optional, keyword-only) + Defining number of random variates. + random_state : int, `~numpy.random.RandomState`, or None (optional, keyword-only) + If seed is None (or numpy.random), the `numpy.random.RandomState` + singleton is used. If seed is an int, a new RandomState instance is + used, seeded with seed. If seed is already a Generator or + RandomState instance then that instance is used. + + Returns + ------- + `~astropy.coordinates.PhysicsSphericalRepresentation` + """ + return super().rvs(size=size, random_state=random_state, vectorized=False) diff --git a/sample_scf/exact/rvs_azimuth.py b/sample_scf/exact/rvs_azimuth.py new file mode 100644 index 0000000..0ec6c05 --- /dev/null +++ b/sample_scf/exact/rvs_azimuth.py @@ -0,0 +1,225 @@ +# -*- coding: utf-8 -*- + +"""Exact sampling.""" + +############################################################################## +# IMPORTS + +from __future__ import annotations + +# BUILT-IN +from typing import Any, Optional, cast + +# THIRD PARTY +import astropy.units as u +import numpy as np +from galpy.potential import SCFPotential +from numpy.typing import ArrayLike + +# LOCAL +from sample_scf._typing import NDArrayF, RandomLike +from sample_scf.base import rv_potential +from sample_scf.utils import phiRSms + +__all__ = ["phi_fixed_distribution", "phi_distribution"] + + +############################################################################## +# CODE +############################################################################## + + +class phi_distribution_base(rv_potential): + """Sample Azimuthal Coordinate. + + Parameters + ---------- + potential : `galpy.potential.SCFPotential` + **kw + Passed to `scipy.stats.rv_continuous` + "a", "b" are set to [0, 2 pi] + + """ + + def __init__(self, potential: SCFPotential, **kw: Any) -> None: + kw["a"], kw["b"] = 0, 2 * np.pi + super().__init__(potential, **kw) + self._lrange = np.arange(0, self._lmax + 1) + + # for compatibility + self._Rm: Optional[NDArrayF] = None + self._Sm: Optional[NDArrayF] = None + + def _cdf(self, phi: NDArrayF, *args: Any, **kw: Any) -> NDArrayF: + r"""Cumulative Distribution Function. + + Parameters + ---------- + phi : float or ndarray[float] ['radian'] + Azimuthal coordinate in radians, :math:`\in [0, 2\pi]`. + *args, **kw + Not used. + + Returns + ------- + cdf : float or ndarray[float] + Shape (len(r), len(theta), len(phi)). + :meth:`numpy.ndarray.squeeze` applied so scalar inputs has scalar + output. + + """ + Rm, Sm = kw.get("RSms", (self._Rm, self._Sm)) # (R/T, L) + + Phis: NDArrayF = np.atleast_1d(phi)[:, None] # (P, {L}) + + # l = 0 : spherical symmetry + term0: NDArrayF = Phis[..., 0] / (2 * np.pi) # (1, P) + + # l = 1+ : non-symmetry + factor = 1 / Rm[:, 0] # R0 (R/T,) # can be inf + ms = np.arange(1, self._lmax)[None, :] # ({R/T/P}, L) + term1p = np.sum( + (Rm[:, 1:] * np.sin(ms * Phis) + Sm[:, 1:] * (1 - np.cos(ms * Phis))) + / (2 * np.pi * ms), + axis=-1, + ) + + cdf: NDArrayF = term0 + np.nan_to_num(factor * term1p) # (R/T/P,) + # 'factor' can be inf and term1p 0 => inf * 0 = nan -> 0 + + return cdf + + def _ppf_to_solve(self, phi: float, q: float, *args: Any) -> NDArrayF: + # changed from .cdf() to ._cdf() to use default 'r', 'theta' + return self._cdf(*(phi,) + args) - q + + +class phi_fixed_distribution(phi_distribution_base): + """Sample Azimuthal Coordinate at fixed r, theta. + + Parameters + ---------- + potential : `galpy.potential.SCFPotential` + r, theta : float or ndarray[float] + + """ + + def __init__(self, potential: SCFPotential, r: NDArrayF, theta: NDArrayF, **kw: Any) -> None: + super().__init__(potential, **kw) + + # assign fixed r, theta + self._r, self._theta = r, theta + # and can compute the associated assymetry measures + self._Rm, self._Sm = phiRSms(potential, r, theta, grid=False, warn=False) + + def cdf(self, phi: NDArrayF, *args: Any, **kw: Any) -> NDArrayF: + r"""Cumulative Distribution Function. + + Parameters + ---------- + phi : float or ndarray[float] ['radian'] + Azimuthal coordinate in radians, :math:`\in [0, 2\pi]`. + *args + **kw + + Returns + ------- + cdf : float or ndarray[float] + Shape (len(r), len(theta), len(phi)). + :meth:`numpy.ndarray.squeeze` applied so scalar inputs has scalar + output. + """ + return self._cdf(phi, *args, **kw) + + +class phi_distribution(phi_distribution_base): + def _cdf( + self, + phi: ArrayLike, + *args: Any, + r: Optional[float] = None, + theta: Optional[float] = None, + ) -> NDArrayF: + r"""Cumulative Distribution Function. + + Parameters + ---------- + phi : float or ndarray[float] ['radian'] + Azimuthal coordinate in radians, :math:`\in [0, 2\pi]`. + *args + r : float or ndarray[float], keyword-only + Radial coordinate at which to evaluate the CDF. Not optional. + theta : float or ndarray[float], keyword-only + Inclination coordinate at which to evaluate the CDF. Not optional. + In [-pi/2, pi/2]. + + Returns + ------- + cdf : float or ndarray[float] + Shape (len(r), len(theta), len(phi)). + :meth:`numpy.ndarray.squeeze` applied so scalar inputs has scalar + output. + + Raises + ------ + ValueError + If 'r' or 'theta' are None. + """ + RSms = phiRSms(self._potential, cast(float, r), cast(float, theta), grid=False, warn=False) + cdf: NDArrayF = super()._cdf(phi, *args, RSms=RSms) + return cdf + + def cdf(self, phi: ArrayLike, *args: Any, r: float, theta: float) -> NDArrayF: + r"""Cumulative Distribution Function. + + Parameters + ---------- + phi : quantity-like or array-like ['radian'] + Azimuthal angular coordinate, :math:`\in [0, 2\pi]`. If doesn't + have units, must be in radians. + *args + r : float or ndarray[float], keyword-only + Radial coordinate at which to evaluate the CDF. Not optional. + theta : quantity-like or array-like ['radian'], keyword-only + Inclination coordinate at which to evaluate the CDF. Not optional. + In [-pi/2, pi/2]. If doesn't have units, must be in radians. + + Returns + ------- + cdf : float or ndarray[float] + Shape (len(r), len(theta), len(phi)). + :meth:`numpy.ndarray.squeeze` applied so scalar inputs has scalar + output. + """ + phi = u.Quantity(phi, u.rad).value + cdf: NDArrayF = self._cdf(phi, *args, r=r, theta=u.Quantity(theta, u.rad).value) + return cdf + + def rvs( # type: ignore + self, + r: float, + theta: float, + *, + size: Optional[int] = None, + random_state: RandomLike = None, + ) -> NDArrayF: + """Random Variate Sample. + + Parameters + ---------- + r : float + theta : float + size : int or None (optional, keyword-only) + random_state : int or `numpy.random.RandomState` or None (optional, keyword-only) + + Returns + ------- + vals : ndarray[float] + + """ + getattr(self._cdf, "__kwdefaults__", {})["r"] = r + getattr(self._cdf, "__kwdefaults__", {})["theta"] = theta + vals = super().rvs(size=size, random_state=random_state) + getattr(self._cdf, "__kwdefaults__", {})["r"] = None + getattr(self._cdf, "__kwdefaults__", {})["theta"] = None + return vals diff --git a/sample_scf/exact/rvs_inclination.py b/sample_scf/exact/rvs_inclination.py new file mode 100644 index 0000000..27d5193 --- /dev/null +++ b/sample_scf/exact/rvs_inclination.py @@ -0,0 +1,159 @@ +# -*- coding: utf-8 -*- + +"""Exact sampling of inclination coordinate.""" + +############################################################################## +# IMPORTS + +from __future__ import annotations + +# BUILT-IN +import abc +from typing import Any, Optional, Union, cast + +# THIRD PARTY +import astropy.units as u +import numpy as np +from galpy.potential import SCFPotential +from numpy.typing import ArrayLike + +# LOCAL +from sample_scf._typing import NDArrayF, RandomLike +from sample_scf.base import rv_potential +from sample_scf.utils import difPls, theta_of_x, thetaQls, x_of_theta + +__all__ = ["theta_fixed_distribution", "theta_distribution"] + + +############################################################################## +# CODE +############################################################################## + + +class theta_distribution_base(rv_potential): + """Base class for sampling the inclination coordinate.""" + + def __init__(self, potential: SCFPotential, **kw: Any) -> None: + kw["a"], kw["b"] = -np.pi / 2, np.pi / 2 # allowed range of theta + super().__init__(potential, **kw) + self._lrange = np.arange(0, self._lmax + 1) # lmax inclusive + + @abc.abstractmethod + def _cdf(self, x: NDArrayF, Qls: NDArrayF) -> NDArrayF: + xs = np.atleast_1d(x) + Qls = np.atleast_2d(Qls) # ({R}, L) + + # l = 0 + term0 = 0.5 * (xs + 1.0) # (T,) + # l = 1+ : non-symmetry + factor = 1.0 / (2.0 * Qls[:, 0]) # (R,) + term1p = np.sum( + (Qls[:, 1:] * difPls(xs, self._lmax - 1).T).T, + axis=0, + ) + # difPls shape (L, T) -> (T, L) + # term1p shape (R/T, L) -> (L, R/T) -> sum -> (R/T,) + + cdf = term0 + np.nan_to_num(factor * term1p) # (R/T,) + return cdf + + def _rvs( + self, + *args: Union[np.floating, ArrayLike], + size: Optional[int] = None, + random_state: RandomLike = None, + ) -> NDArrayF: + xs = super()._rvs(*args, size=size, random_state=random_state) + rvs = theta_of_x(xs) + return rvs + + def _ppf_to_solve(self, x: float, q: float, *args: Any) -> NDArrayF: + ppf: NDArrayF = self._cdf(*(x,) + args) - q + return ppf + + +class theta_fixed_distribution(theta_distribution_base): + """ + Sample inclination coordinate from an SCF potential. + + Parameters + ---------- + pot : `~galpy.potential.SCFPotential` + r : float or None, optional + If passed, these are the locations at which the theta CDF will be + evaluated. If None (default), then the r coordinate must be given + to the CDF and RVS functions. + **kw: + Not used. + """ + + def __init__(self, potential: SCFPotential, r: float, **kw: Any) -> None: + super().__init__(potential) + + # points at which CDF is defined + self._r = r + self._Qlsatr = thetaQls(self._potential, r) + + def _cdf(self, x: ArrayLike, *args: Any) -> NDArrayF: + cdf = super()._cdf(x, self._Qlsatr) + return cdf + + def cdf(self, theta: ArrayLike) -> NDArrayF: + """ + Cumulative distribution function of the given RV. + + Parameters + ---------- + theta : quantity-like['angle'] + + Returns + ------- + cdf : ndarray + Cumulative distribution function evaluated at `theta` + + """ + return self._cdf(x_of_theta(u.Quantity(theta, u.rad).value)) + + +class theta_distribution(theta_distribution_base): + """ + Sample inclination coordinate from an SCF potential. + + Parameters + ---------- + pot : `~galpy.potential.SCFPotential` + + """ + + def _cdf(self, theta: NDArrayF, *args: Any, r: Optional[float] = None) -> NDArrayF: + Qls = thetaQls(self._potential, cast(float, r)) + cdf = super()._cdf(theta, Qls) + return cdf + + def cdf(self, theta: ArrayLike, *args: Any, r: float) -> NDArrayF: + """ + Cumulative distribution function of the given RV. + + Parameters + ---------- + theta : quantity-like['angle'] + *args + Not used. + r : array-like[float] (optional, keyword-only) + + Returns + ------- + cdf : ndarray + Cumulative distribution function evaluated at `theta` + + """ + return self._cdf(x_of_theta(u.Quantity(theta, u.rad).value), *args, r=r) + + def rvs( + self, r: ArrayLike, *, size: Optional[int] = None, random_state: RandomLike = None + ) -> NDArrayF: + # not thread safe! + getattr(self._cdf, "__kwdefaults__", {})["r"] = r + vals = super().rvs(size=size, random_state=random_state) + getattr(self._cdf, "__kwdefaults__", {})["r"] = None + return vals diff --git a/sample_scf/exact/rvs_radial.py b/sample_scf/exact/rvs_radial.py new file mode 100644 index 0000000..f14b65c --- /dev/null +++ b/sample_scf/exact/rvs_radial.py @@ -0,0 +1,81 @@ +# -*- coding: utf-8 -*- + +"""Exact sampling of radial coordinate.""" + +############################################################################## +# IMPORTS + +from __future__ import annotations + +# BUILT-IN +import abc +from typing import Any, Optional, Union, cast + +# THIRD PARTY +import astropy.units as u +import numpy as np +from astropy.coordinates import PhysicsSphericalRepresentation +from numpy.typing import ArrayLike + +# LOCAL +from sample_scf._typing import NDArrayF, RandomLike +from sample_scf.base import SCFSamplerBase, rv_potential +from sample_scf.utils import difPls, phiRSms, theta_of_x, thetaQls, x_of_theta + +__all__ = ["r_distribution"] + + +############################################################################## +# CODE +############################################################################## + + +class r_distribution(rv_potential): + """Sample radial coordinate from an SCF potential. + + Parameters + ---------- + pot : `~galpy.potential.SCFPotential` + A potential that can be used to calculate the enclosed mass. + total_mass : Optional + **kw + Not used. + """ + + def __init__(self, potential: SCFPotential, total_mass=None, **kw: Any) -> None: + # make sampler + kw["a"], kw["b"] = 0, np.inf # allowed range of r + super().__init__(potential, **kw) + + # normalization for total mass + # TODO! if mass has units + if total_mass is None: + total_mass = potential._mass(np.inf) + if np.isnan(total_mass): + raise ValueError( + "total mass is NaN. Need to pass kwarg `total_mass` with a non-NaN value.", + ) + self._mtot = total_mass + # vectorize mass function, which is scalar + self._vec_cdf = np.vectorize(self._potential._mass) + + def _cdf(self, r: ArrayLike, *args: Any, **kw: Any) -> NDArrayF: + """Cumulative Distribution Function. + + Parameters + ---------- + r : array-like + *args + **kwargs + + Returns + ------- + mass : array-like + Shape matches 'r'. + """ + mass: NDArrayF = np.atleast_1d(self._vec_cdf(r)) / self._mtot + mass[r == 0] = 0 + mass[r == np.inf] = 1 + return mass.item() if mass.shape == (1,) else mass + + cdf = _cdf diff --git a/sample_scf/tests/test_exact.py b/sample_scf/exact/tests/test_exact.py similarity index 81% rename from sample_scf/tests/test_exact.py rename to sample_scf/exact/tests/test_exact.py index 2a19328..704be4b 100644 --- a/sample_scf/tests/test_exact.py +++ b/sample_scf/exact/tests/test_exact.py @@ -15,7 +15,7 @@ from numpy.testing import assert_allclose # LOCAL -from .common import SCFPhiSamplerTestBase, SCFRSamplerTestBase, SCFThetaSamplerTestBase +from .common import phi_distributionTestBase, r_distributionTestBase, theta_distributionTestBase from .test_base import SCFSamplerTestBase from sample_scf import conftest, exact from sample_scf.utils import difPls, r_of_zeta, thetaQls, x_of_theta @@ -33,38 +33,17 @@ ############################################################################## -class _request: - def __init__(self, param): - self.param = param - - # /def - - -# /class - - -def getpot(name): - return next(conftest.potentials.__wrapped__(_request(name))) - - -# /def - - class Test_SCFSampler(SCFSamplerTestBase): """Test :class:`sample_scf.exact.SCFSampler`.""" def setup_class(self): super().setup_class(self) + # sampler initialization self.cls = exact.SCFSampler self.cls_args = () self.cls_kwargs = {} - - # TODO! less hacky approach - self.cls_pot_kw = { - getpot("hernquist_scf_potential"): {"total_mass": 1.0}, - getpot("other_hernquist_scf_potential"): {"total_mass": 1.0}, - } + self.cls_pot_kw = conftest.cls_pot_kw # TODO! make sure these are right! self.expected_rvs = { @@ -77,11 +56,20 @@ def setup_class(self): ), } - # /def - # =============================================================== # Method Tests + def test_init(self, potentials): + kw = {**self.cls_kwargs, **self.cls_pot_kw.get(potentials, {})} + instance = self.cls(potentials, *self.cls_args, **kw) + + assert isinstance(instance.rsampler, exact.r_distribution) + assert isinstance(instance.thetasampler, exact.theta_distribution) + assert isinstance(instance.phisampler, exact.phi_distribution) + + def test_rvs(self, sampler): + """Test Random Variates Sampler.""" + # TODO! make sure these are correct @pytest.mark.parametrize( "r, theta, phi, expected", @@ -95,8 +83,6 @@ def test_cdf(self, sampler, r, theta, phi, expected): """Test :meth:`sample_scf.base.SCFSamplerBase.cdf`.""" assert np.allclose(sampler.cdf(r, theta, phi), expected, atol=1e-16) - # /def - # =============================================================== # Plot Tests @@ -136,8 +122,6 @@ def test_exact_cdf_plot(self, sampler): return fig - # /def - def test_exact_sampling_plot(self, sampler): """Plot sampling.""" samples = sampler.rvs(size=int(1e3), random_state=3) @@ -165,37 +149,27 @@ def test_exact_sampling_plot(self, sampler): return fig - # /def - - -# /class - # ============================================================================ -class Test_SCFRSampler(SCFRSamplerTestBase): - """Test :class:`sample_scf.exact.SCFRSampler`""" +class Test_r_distribution(r_distributionTestBase): + """Test :class:`sample_scf.exact.r_distribution`""" def setup_class(self): - self.cls = exact.SCFRSampler + super().setup_class(self) + + # sampler initialization + self.cls = exact.r_distribution self.cls_args = () self.cls_kwargs = {} - self.cls_pot_kw = { # TODO! less hacky approach - getpot("hernquist_scf_potential"): {"total_mass": 1.0}, - getpot("other_hernquist_scf_potential"): {"total_mass": 1.0}, - } + self.cls_pot_kw = conftest.cls_pot_kw + # time-scale tests self.cdf_time_scale = 1e-2 # milliseconds self.rvs_time_scale = 1e-2 # milliseconds - self.theory = dict( - hernquist=conftest.hernquist_df, - ) - - # /def - - @pytest.fixture(autouse=True, scope="class") + @pytest.fixture() def sampler(self, potentials): """Set up r, theta, or phi sampler.""" kw = {**self.cls_kwargs, **self.cls_pot_kw.get(potentials, {})} @@ -203,28 +177,24 @@ def sampler(self, potentials): return sampler - # /def - # =============================================================== # Method Tests @pytest.mark.skip("TODO!") - def test___init__(self): + def test_init(self): assert False # test if mgrid is SCFPotential # TODO! use hypothesis @pytest.mark.parametrize("r", np.random.default_rng(0).uniform(0, 1e4, 10)) def test__cdf(self, sampler, r): - """Test :meth:`sample_scf.exact.SCFRSampler._cdf`.""" + """Test :meth:`sample_scf.exact.r_distribution._cdf`.""" super().test__cdf(sampler, r) # expected mass = np.atleast_1d(sampler._potential._mass(r)) / sampler._mtot assert_allclose(sampler._cdf(r), mass) - # /def - @pytest.mark.parametrize( "size, random, expected", [ @@ -235,11 +205,9 @@ def test__cdf(self, sampler, r): ], ) def test_rvs(self, sampler, size, random, expected): - """Test :meth:`sample_scf.exact.SCFRSampler.rvs`.""" + """Test :meth:`sample_scf.exact.r_distribution.rvs`.""" super().test_rvs(sampler, size, random, expected) - # /def - # =============================================================== # Time Scaling Tests @@ -249,8 +217,6 @@ def test_rvs_time_scaling(self, sampler, size): """Test that the time scales as X * size""" super().test_rvs_time_scaling(sampler, size) - # /def - # =============================================================== # Image Tests @@ -282,34 +248,25 @@ def test_exact_r_cdf_plot(self, sampler): fig.tight_layout() return fig - # /def - @pytest.mark.mpl_image_compare( baseline_dir="baseline_images", # hash_library="baseline_images/path_to_file.json", ) - def test_exact_r_sampling_plot(self, request, sampler): + def test_exact_r_sampling_plot(self, sampler): """Test sampling.""" - # fiqure out theory sampler - options = request.fixturenames[0] - if "hernquist" in options: - kind = "hernquist" - else: - raise ValueError - with NumpyRNGContext(0): # control the random numbers sample = sampler.rvs(size=int(1e3)) sample = sample[sample < 1e4] - theory = self.theory[kind].sample(n=int(1e6)).r() - theory = theory[theory < 1e4] + theory = self.theory[sampler._potential].sample(n=int(1e6)).r() + theory = theory[theory < 1e4 * u.kpc] fig = plt.figure(figsize=(10, 3)) ax = fig.add_subplot(121, title="SCF vs theory sampling", xlabel="r", ylabel="frequency") _, bins, *_ = ax.hist(sample, bins=30, log=True, alpha=0.5, label="SCF sample") # Comparing to expected ax.hist( - theory, + theory.to_value(u.kpc), bins=bins, log=True, alpha=0.5, @@ -320,28 +277,21 @@ def test_exact_r_sampling_plot(self, request, sampler): return fig - # /def - - -# /class - # ---------------------------------------------------------------------------- -class Test_SCFThetaSampler(SCFThetaSamplerTestBase): - """Test :class:`sample_scf.exact.SCFThetaSampler`.""" +class Test_theta_distribution(theta_distributionTestBase): + """Test :class:`sample_scf.exact.theta_distribution`.""" def setup_class(self): super().setup_class(self) - self.cls = exact.SCFThetaSampler + self.cls = exact.theta_distribution self.cdf_time_scale = 1e-3 self.rvs_time_scale = 7e-2 - # /def - # =============================================================== # Method Tests @@ -357,7 +307,7 @@ def setup_class(self): ], ) def test__cdf(self, sampler, x, r): - """Test :meth:`sample_scf.exact.SCFThetaSampler._cdf`.""" + """Test :meth:`sample_scf.exact.theta_distribution._cdf`.""" Qls = np.atleast_2d(thetaQls(sampler._potential, r)) # basically a test it's Hernquist, only the first term matters @@ -379,16 +329,12 @@ def test__cdf(self, sampler, x, r): assert_allclose(sampler._cdf(x, r=r), cdf) - # /def - @pytest.mark.parametrize("r", r_of_zeta(np.random.default_rng(0).uniform(-1, 1, 10))) def test__cdf_edge(self, sampler, r): - """Test :meth:`sample_scf.exact.SCFRSampler._cdf`.""" + """Test :meth:`sample_scf.exact.r_distribution._cdf`.""" assert np.isclose(sampler._cdf(-1, r=r), 0.0, atol=1e-16) assert np.isclose(sampler._cdf(1, r=r), 1.0, atol=1e-16) - # /def - @pytest.mark.parametrize( "theta, r", [ @@ -399,25 +345,19 @@ def test__cdf_edge(self, sampler, r): ], ) def test_cdf(self, sampler, theta, r): - """Test :meth:`sample_scf.exact.SCFThetaSampler.cdf`.""" + """Test :meth:`sample_scf.exact.theta_distribution.cdf`.""" self.test__cdf(sampler, x_of_theta(theta), r) - # /def - @pytest.mark.skip("TODO!") def test__rvs(self): - """Test :meth:`sample_scf.exact.SCFThetaSampler._rvs`.""" + """Test :meth:`sample_scf.exact.theta_distribution._rvs`.""" assert False - # /def - @pytest.mark.skip("TODO!") def test_rvs(self): - """Test :meth:`sample_scf.exact.SCFThetaSampler.rvs`.""" + """Test :meth:`sample_scf.exact.theta_distribution.rvs`.""" assert False - # /def - # =============================================================== # Time Scaling Tests @@ -427,8 +367,6 @@ def test_rvs_time_scaling(self, sampler, size): """Test that the time scales as X * size""" super().test_rvs_time_scaling(sampler, size) - # /def - # =============================================================== # Image Tests @@ -475,26 +413,18 @@ def test_exact_theta_cdf_plot(self, sampler): fig.tight_layout() return fig - # /def - @pytest.mark.mpl_image_compare( baseline_dir="baseline_images", # hash_library="baseline_images/path_to_file.json", ) - def test_exact_theta_sampling_plot(self, request, sampler): + def test_exact_theta_sampling_plot(self, sampler): """Test sampling.""" - # fiqure out theory sampler - options = request.fixturenames[0] - if "hernquist" in options: - kind = "hernquist" - else: - raise ValueError - with NumpyRNGContext(0): # control the random numbers sample = sampler.rvs(size=int(1e3), r=10) sample = sample[sample < 1e4] - theory = self.theory[kind].sample(n=int(1e6)).theta() - np.pi / 2 + theory = self.theory[sampler._potential].sample(n=int(1e6)).theta() + theory -= np.pi / 2 * u.rad fig = plt.figure(figsize=(10, 3)) ax = fig.add_subplot( @@ -506,7 +436,7 @@ def test_exact_theta_sampling_plot(self, request, sampler): _, bins, *_ = ax.hist(sample, bins=30, log=True, alpha=0.5, label="SCF sample") # Comparing to expected ax.hist( - theory, + theory.to_value(u.rad), bins=bins, log=True, alpha=0.5, @@ -517,59 +447,44 @@ def test_exact_theta_sampling_plot(self, request, sampler): return fig - # /def - - -# /class - ############################################################################### -class Test_SCFPhiSampler(SCFPhiSamplerTestBase): - """Test :class:`sample_scf.exact.SCFPhiSampler`.""" +class Test_phi_distribution(phi_distributionTestBase): + """Test :class:`sample_scf.exact.phi_distribution`.""" def setup_class(self): super().setup_class(self) - self.cls = exact.SCFPhiSampler + self.cls = exact.phi_distribution self.cdf_time_scale = 3e-3 self.rvs_time_scale = 3e-3 - # /def - # =============================================================== # Method Tests @pytest.mark.skip("TODO!") def test__cdf(self): - """Test :meth:`sample_scf.exactolated.SCFPhiSampler._cdf`.""" + """Test :meth:`sample_scf.exactolated.phi_distribution._cdf`.""" assert False - # /def - @pytest.mark.skip("TODO!") def test_cdf(self): - """Test :meth:`sample_scf.exactolated.SCFPhiSampler.cdf`.""" + """Test :meth:`sample_scf.exactolated.phi_distribution.cdf`.""" assert False - # /def - @pytest.mark.skip("TODO!") def test__rvs(self): - """Test :meth:`sample_scf.exactolated.SCFPhiSampler._rvs`.""" + """Test :meth:`sample_scf.exactolated.phi_distribution._rvs`.""" assert False - # /def - @pytest.mark.skip("TODO!") def test_rvs(self): - """Test :meth:`sample_scf.exactolated.SCFPhiSampler.rvs`.""" + """Test :meth:`sample_scf.exactolated.phi_distribution.rvs`.""" assert False - # /def - # =============================================================== # Image Tests @@ -599,26 +514,17 @@ def test_exact_phi_cdf_plot(self, sampler): fig.tight_layout() return fig - # /def - @pytest.mark.mpl_image_compare( baseline_dir="baseline_images", # hash_library="baseline_images/path_to_file.json", ) - def test_exact_phi_sampling_plot(self, request, sampler): + def test_exact_phi_sampling_plot(self, sampler): """Test sampling.""" - # fiqure out theory sampler - options = request.fixturenames[0] - if "hernquist" in options: - kind = "hernquist" - else: - raise ValueError - with NumpyRNGContext(0): # control the random numbers sample = sampler.rvs(size=int(1e3), r=10, theta=np.pi / 6) sample = sample[sample < 1e4] - theory = self.theory[kind].sample(n=int(1e3)).phi() + theory = self.theory[sampler._potential].sample(n=int(1e3)).phi() fig = plt.figure(figsize=(10, 3)) ax = fig.add_subplot( @@ -630,7 +536,7 @@ def test_exact_phi_sampling_plot(self, request, sampler): _, bins, *_ = ax.hist(sample, bins=30, log=True, alpha=0.5, label="SCF sample") # Comparing to expected ax.hist( - theory, + theory.to_value(u.rad), bins=bins, log=True, alpha=0.5, @@ -640,12 +546,3 @@ def test_exact_phi_sampling_plot(self, request, sampler): fig.tight_layout() return fig - - # /def - - -# /class - - -############################################################################## -# END diff --git a/sample_scf/interpolated.py b/sample_scf/interpolated.py deleted file mode 100644 index 0373e9b..0000000 --- a/sample_scf/interpolated.py +++ /dev/null @@ -1,608 +0,0 @@ -# -*- coding: utf-8 -*- - -"""**DOCSTRING**. - -Description. - -""" - -############################################################################## -# IMPORTS - -from __future__ import annotations - -# BUILT-IN -import itertools -import typing as T -import warnings - -# THIRD PARTY -import astropy.units as u -import numpy as np -import numpy.typing as npt -from galpy.potential import SCFPotential -from scipy.interpolate import ( - InterpolatedUnivariateSpline, - RectBivariateSpline, - RegularGridInterpolator, - splev, - splrep, -) - -# LOCAL -from ._typing import NDArray64, RandomLike -from .base import SCFSamplerBase, rv_potential -from .utils import _grid_phiRSms, difPls, phiRSms, r_of_zeta, thetaQls, x_of_theta, zeta_of_r - -__all__: T.List[str] = [ - "SCFSampler", - "SCFRSampler", - "SCFThetaSampler", - "SCFPhiSampler", -] - - -############################################################################## -# CODE -############################################################################## - - -class SCFSampler(SCFSamplerBase): - r"""Interpolated SCF Sampler. - - Parameters - ---------- - pot : `~galpy.potential.SCFPotential` - rtgrid : array-like[float] - The radial component of the interpolation grid. - thetagrid : array-like[float] - The inclination component of the interpolation grid. - :math:`\theta \in [-\pi/2, \pi/2]`, from the South to North pole, so - :math:`\theta = 0` is the equator. - phigrid : array-like[float] - The azimuthal component of the interpolation grid. - :math:`phi \in [0, 2\pi)`. - - **kw: - passed to :class:`~sample_scf.sample_interp.SCFRSampler`, - :class:`~sample_scf.sample_interp.SCFThetaSampler`, - :class:`~sample_scf.sample_interp.SCFPhiSampler` - - Examples - -------- - For all examples we assume the following imports - - >>> import numpy as np - >>> from galpy import potential - - For the SCF Potential we will use the simple example of a Hernquist sphere. - - >>> Acos = np.zeros((20, 24, 24)) - >>> Acos[0, 0, 0] = 1 # Hernquist potential - >>> pot = potential.SCFPotential(Acos=Acos) - - Now we make the sampler, specifying the grid from which the interpolation - will be built. - - >>> rgrid = np.geomspace(1e-1, 1e3, 100) - >>> thetagrid = np.linspace(-np.pi / 2, np.pi / 2, 30) - >>> phigrid = np.linspace(0, 2 * np.pi, 30) - - >>> sampler = SCFSampler(pot, rgrid=rgrid, thetagrid=thetagrid, phigrid=phigrid) - - Now we can evaluate the CDF - - >>> sampler.cdf(10.0, np.pi/3, np.pi) - array([0.82666461, 0.9330127 , 0.5 ]) - - And draw samples - - >>> sampler.rvs(size=5, random_state=3) - - - """ - - def __init__( - self, - potential: SCFPotential, - rgrid: NDArray64, - thetagrid: NDArray64, - phigrid: NDArray64, - **kw: T.Any, - ) -> None: - # compute the r-dependent coefficient matrix $\tilde{\rho}$ - nmax, lmax = potential._Acos.shape[:2] - rhoTilde = np.array( - [potential._rhoTilde(r, N=nmax, L=lmax) for r in rgrid], - ) # (R, N, L) - - # ---------- - # theta Qls - # radial sums over $\cos$ portion of the density function - # the $\sin$ part disappears in the integral. - Qls = np.sum(potential._Acos[None, :, :, 0] * rhoTilde, axis=1) # ({R}, L) - - # ---------- - # phi Rm, Sm - # radial and inclination sums - - RSms = _grid_phiRSms( - rhoTilde, - Acos=potential._Acos, - Asin=potential._Asin, - r=rgrid, - theta=thetagrid, - ) - - # ---------- - # make samplers - - self._rsampler = SCFRSampler(potential, rgrid, **kw) - self._thetasampler = SCFThetaSampler(potential, rgrid, thetagrid, Qls=Qls, **kw) - self._phisampler = SCFPhiSampler(potential, rgrid, thetagrid, phigrid, RSms=RSms, **kw) - - # /def - - -# /class - -# ------------------------------------------------------------------- -# radial sampler - - -class SCFRSampler(rv_potential): - """Sample radial coordinate from an SCF potential. - - The potential must have a convergent mass function. - - Parameters - ---------- - potential : `galpy.potential.SCFPotential` - rgrid : ndarray - **kw - Passed to `scipy.stats.rv_continuous` - "a", "b" are set to [0, inf] - """ - - def __init__(self, potential: SCFPotential, rgrid: NDArray64, **kw: T.Any) -> None: - kw["a"], kw["b"] = 0, np.nanmax(rgrid) # allowed range of r - super().__init__(potential, **kw) - - mgrid = np.array([potential._mass(x) for x in rgrid]) # :( - # manual fixes for endpoints and normalization - ind = np.where(np.isnan(mgrid))[0] - mgrid[ind[rgrid[ind] == 0]] = 0 - mgrid = (mgrid - np.nanmin(mgrid)) / (np.nanmax(mgrid) - np.nanmin(mgrid)) # rescale - infind = ind[rgrid[ind] == np.inf].squeeze() - mgrid[infind] = 1 - if mgrid[infind - 1] == 1: # munge the rescaling TODO! do better - mgrid[infind - 1] -= min(1e-8, np.diff(mgrid[slice(infind - 2, infind)]) / 2) - - # work in zeta, not r, since it is more numerically stable - zeta = zeta_of_r(rgrid) - # make splines for fast calculation - self._spl_cdf = InterpolatedUnivariateSpline( - zeta, - mgrid, - ext="raise", - bbox=[-1, 1], - ) - self._spl_ppf = InterpolatedUnivariateSpline( - mgrid, - zeta, - ext="raise", - bbox=[0, 1], - ) - - # TODO! make sure - # # store endpoint values to ensure CDF normalized to [0, 1] - # self._mi = self._spl_cdf(min(zeta)) - # self._mf = self._spl_cdf(max(zeta)) - - # /def - - def _cdf(self, r: npt.ArrayLike, *args: T.Any, **kw: T.Any) -> NDArray64: - cdf: NDArray64 = self._spl_cdf(zeta_of_r(r)) - # (self._scfmass(zeta) - self._mi) / (self._mf - self._mi) - # TODO! is this normalization even necessary? - return cdf - - # /def - - def _ppf(self, q: npt.ArrayLike, *args: T.Any, **kw: T.Any) -> NDArray64: - return r_of_zeta(self._spl_ppf(q)) - - # /def - - -# /class - -# ------------------------------------------------------------------- -# inclination sampler - - -class SCFThetaSampler(rv_potential): - """ - Sample inclination coordinate from an SCF potential. - - Parameters - ---------- - pot : `~galpy.potential.SCFPotential` - rgrid, tgrid : ndarray - **kw - Passed to `scipy.stats.rv_continuous` - "a", "b" are set to [-pi/2, pi/2] - """ - - def __init__( - self, - potential: SCFPotential, - rgrid: NDArray64, - tgrid: NDArray64, - intrp_step: float = 0.01, - **kw: T.Any, - ) -> None: - kw["a"], kw["b"] = -np.pi / 2, np.pi / 2 - Qls: NDArray64 = kw.pop("Qls", None) - super().__init__(potential, **kw) # allowed range of theta - - self._theta_interpolant = np.arange(-np.pi / 2, np.pi / 2, intrp_step) - self._x_interpolant = x_of_theta(self._theta_interpolant) - self._q_interpolant = np.linspace(0, 1, len(self._theta_interpolant)) - - self._lrange = np.arange(0, self._lmax + 1) - - # ------- - # build CDF in shells - # TODO: clean up shape stuff - - zetas = zeta_of_r(rgrid) # (R,) - xs = x_of_theta(tgrid) # (T,) - - Qls = Qls if Qls is not None else thetaQls(potential, rgrid) - # check it's the right shape (R, Lmax) - if Qls.shape != (len(rgrid), self._lmax): - raise ValueError(f"Qls must be shape ({len(rgrid)}, {self._lmax})") - - # l = 0 : spherical symmetry - term0 = T.cast(NDArray64, 0.5 * (xs + 1.0)) # (T,) - # l = 1+ : non-symmetry - factor = 1.0 / (2.0 * Qls[:, 0]) # (R,) - term1p = np.sum( - (Qls[None, :, 1:] * difPls(xs, self._lmax - 1).T[:, None, :]).T, - axis=0, - ) - - cdfs = term0[None, :] + np.nan_to_num(factor[:, None] * term1p) # (R, T) - - # ------- - # interpolate - # currently assumes a regular grid - - self._spl_cdf = RectBivariateSpline( - zetas, - xs, - cdfs, - bbox=[-1, 1, -1, 1], # [zetamin, zetamax, xmin, xmax] - kx=kw.get("kx", 3), - ky=kw.get("ky", 3), - s=kw.get("s", 0), - ) - - # ppf, one per r - # TODO! see if can use this to avoid resplining - _cdfs = self._spl_cdf(zetas, self._x_interpolant) - spls = [ # work through the rs - splrep(_cdfs[i, :], self._theta_interpolant, s=0) for i in range(_cdfs.shape[0]) - ] - ppfs = np.array([splev(self._q_interpolant, spl, ext=0) for spl in spls]) - self._spl_ppf = RectBivariateSpline( - zetas, - self._q_interpolant, - ppfs, - bbox=[-1, 1, 0, 1], # [zetamin, zetamax, xmin, xmax] - kx=kw.get("kx", 3), - ky=kw.get("ky", 3), - s=kw.get("s", 0), - ) - - # /def - - def _cdf(self, x: npt.ArrayLike, *args: T.Any, zeta: npt.ArrayLike, **kw: T.Any) -> NDArray64: - cdf: NDArray64 = self._spl_cdf(zeta, x, grid=False) - return cdf - - # /def - - def cdf(self, theta: npt.ArrayLike, r: npt.ArrayLike) -> NDArray64: - """Cumulative Distribution Function. - - Parameters - ---------- - theta : array-like or Quantity-like - r : array-like or Quantity-like - - Returns - ------- - cdf : ndarray[float] - """ - # TODO! make sure r, theta in right domain - cdf = self._cdf(x_of_theta(u.Quantity(theta, u.rad)), zeta=zeta_of_r(r)) - return cdf - - # /def - - def _ppf( - self, - q: npt.ArrayLike, - *, - r: npt.ArrayLike, - **kw: T.Any, - ) -> NDArray64: - """Percent-point function. - - Parameters - ---------- - q : float or (N,) array-like[float] - r : float or (N,) array-like[float] - - Returns - ------- - float or (N,) array-like[float] - Same shape as 'r', 'q'. - """ - ppf: NDArray64 = self._spl_ppf(zeta_of_r(r), q, grid=False) - return ppf - - # /def - - def _rvs( - self, - r: npt.ArrayLike, - *, - random_state: T.Union[np.random.RandomState, np.random.Generator], - size: T.Optional[int] = None, - ) -> NDArray64: - """Random variate sampling. - - Parameters - ---------- - r : float or (N,) array-like[float] - size : int (optional, keyword-only) - random_state : int or None (optional, keyword-only) - - Returns - ------- - float or array-like[float] - Shape 'size'. - """ - # Use inverse cdf algorithm for RV generation. - U = random_state.uniform(size=size) - Y = self._ppf(U, r=r, grid=False) - return Y - - # /def - - def rvs( # type: ignore - self, - r: npt.ArrayLike, - *, - size: T.Optional[int] = None, - random_state: RandomLike = None, - ) -> NDArray64: - """Random variate sampling. - - Parameters - ---------- - r : float or (N,) array-like[float] - size : int or None (optional, keyword-only) - random_state : int or None (optional, keyword-only) - - Returns - ------- - float or array-like[float] - Shape 'size'. - """ - return super().rvs(r, size=size, random_state=random_state) - - # /def - - -############################################################################### -# Azimuth sampler - - -class SCFPhiSampler(rv_potential): - """SCF phi sampler. - - .. todo:: - - Make sure that stuff actually goes from 0 to 1. - - Parameters - ---------- - potential : `galpy.potential.SCFPotential` - rgrid : ndarray[float] - tgrid : ndarray[float] - pgrid : ndarray[float] - intrp_step : float, optional - **kw - Passed to `scipy.stats.rv_continuous` - "a", "b" are set to [0, 2 pi] - """ - - def __init__( - self, - potential: SCFPotential, - rgrid: NDArray64, - tgrid: NDArray64, - pgrid: NDArray64, - intrp_step: float = 0.01, - **kw: T.Any, - ) -> None: - kw["a"], kw["b"] = 0, 2 * np.pi - (Rm, Sm) = kw.pop("RSms", (None, None)) - super().__init__(potential, **kw) # allowed range of r - - self._phi_interpolant = np.arange(0, 2 * np.pi, intrp_step) - self._ninterpolant = len(self._phi_interpolant) - self._q_interpolant = qarr = np.linspace(0, 1, self._ninterpolant) - - # ------- - # build CDF - - zetas = zeta_of_r(rgrid) # (R,) - xs = x_of_theta(tgrid) # (T,) - - lR, lT, _ = len(rgrid), len(tgrid), len(pgrid) - - Phis = pgrid[None, None, :, None] # ({R}, {T}, P, {L}) - - Rm, Sm = (Rm, Sm) if Rm is not None else phiRSms(potential, rgrid, tgrid) # (R, T, L) - # check it's the right shape - if (Rm.shape != Sm.shape) or (Rm.shape != (lR, lT, self._lmax)): - raise ValueError(f"Rm, Sm must be shape ({lR}, {lT}, {self._lmax})") - - # l = 0 : spherical symmetry - term0 = Phis[..., 0] / (2 * np.pi) # (1, 1, P) - # l = 1+ : non-symmetry - with warnings.catch_warnings(): # ignore true_divide RuntimeWarnings - warnings.simplefilter("ignore") - factor = 1 / Rm[:, :, :1] # R0 (R, T, 1) # can be inf - - ms = np.arange(1, self._lmax)[None, None, None, :] # ({R}, {T}, {P}, L) - term1p = np.sum( - ( - (Rm[:, :, None, 1:] * np.sin(ms * Phis)) - + (Sm[:, :, None, 1:] * (1 - np.cos(ms * Phis))) - ) - / (2 * np.pi * ms), - axis=-1, - ) - - cdfs = term0 + np.nan_to_num(factor * term1p) # (R, T, P) - # 'factor' can be inf and term1p 0 => inf * 0 = nan -> 0 - - # interpolate - # currently assumes a regular grid - self._spl_cdf = RegularGridInterpolator((zetas, xs, pgrid), cdfs) - - # ------- - # ppf - # start by supersampling - Zetas, Xs, Phis = np.meshgrid(zetas, xs, self._phi_interpolant, indexing="ij") - _cdfs = self._spl_cdf((Zetas.ravel(), Xs.ravel(), Phis.ravel())).reshape( - lR, - lT, - len(self._phi_interpolant), - ) - # build reverse spline - ppfs = np.empty((lR, lT, self._ninterpolant), dtype=np.float64) - for (i, j) in itertools.product(*map(range, ppfs.shape[:2])): - spl = splrep(_cdfs[i, j, :], self._phi_interpolant, s=0) - ppfs[i, j, :] = splev(qarr, spl, ext=0) - # interpolate - self._spl_ppf = RegularGridInterpolator( - (zetas, xs, self._q_interpolant), - ppfs, - bounds_error=False, - ) - - # /def - - def _cdf( - self, - phi: npt.ArrayLike, - *args: T.Any, - zeta: npt.ArrayLike, - x: npt.ArrayLike, - ) -> NDArray64: - cdf: NDArray64 = self._spl_cdf((zeta, x, phi)) - return cdf - - # /def - - def cdf( - self, - phi: npt.ArrayLike, - r: npt.ArrayLike, - theta: npt.ArrayLike, - ) -> NDArray64: - # TODO! make sure r, theta in right domain - cdf = self._cdf( - phi, - zeta=zeta_of_r(r), - x=x_of_theta(u.Quantity(theta, u.rad)), - ) - return cdf - - # /def - - def _ppf( - self, - q: npt.ArrayLike, - *args: T.Any, - r: npt.ArrayLike, - theta: NDArray64, - **kw: T.Any, - ) -> NDArray64: - ppf: NDArray64 = self._spl_ppf((zeta_of_r(r), x_of_theta(theta), q)) - return ppf - - # /def - - def _rvs( - self, - r: npt.ArrayLike, - theta: NDArray64, - *args: T.Any, - random_state: np.random.RandomState, - size: T.Optional[int] = None, - ) -> NDArray64: - # Use inverse cdf algorithm for RV generation. - U = random_state.uniform(size=size) - Y = self._ppf(U, *args, r=r, theta=theta) - return Y - - # /def - - def rvs( # type: ignore - self, - r: T.Union[float, npt.ArrayLike], - theta: T.Union[float, npt.ArrayLike], - *, - size: T.Optional[int] = None, - random_state: RandomLike = None, - ) -> NDArray64: - """Random variate sampler. - - Parameters - ---------- - r, theta : array-like[float] - size : int or None (optional, keyword-only) - Size of random variates to generate. - random_state : int, `~numpy.random.RandomState`, or None (optional, keyword-only) - If seed is None (or numpy.random), the `numpy.random.RandomState` - singleton is used. If seed is an int, a new RandomState instance is - used, seeded with seed. If seed is already a Generator or - RandomState instance then that instance is used. - - Returns - ------- - ndarray[float] - Shape 'size'. - """ - return super().rvs(r, theta, size=size, random_state=random_state) - - # /def - - -# /class - -############################################################################## -# END diff --git a/sample_scf/interpolated/__init__.py b/sample_scf/interpolated/__init__.py new file mode 100644 index 0000000..f3ada6e --- /dev/null +++ b/sample_scf/interpolated/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- +# Licensed under a 3-clause BSD style license - see LICENSE.rst + +# LOCAL +from .core import InterpolatedSCFSampler diff --git a/sample_scf/interpolated/core.py b/sample_scf/interpolated/core.py new file mode 100644 index 0000000..50f9077 --- /dev/null +++ b/sample_scf/interpolated/core.py @@ -0,0 +1,168 @@ +# -*- coding: utf-8 -*- + +"""**DOCSTRING**. + +Description. + +""" + +############################################################################## +# IMPORTS + +from __future__ import annotations + +# BUILT-IN +import warnings +from typing import Any + +# THIRD PARTY +import astropy.units as u +import numpy as np +from galpy.potential import SCFPotential + +# LOCAL +from sample_scf._typing import NDArrayF +from sample_scf.base import SCFSamplerBase +from sample_scf.utils import _grid_phiRSms + +from .rvs_azimuth import phi_distribution +from .rvs_inclination import theta_distribution +from .rvs_radial import r_distribution + +__all__ = ["InterpolatedSCFSampler"] + + +############################################################################## +# CODE +############################################################################## + + +class InterpolatedSCFSampler(SCFSamplerBase): + r"""Interpolated SCF Sampler. + + Parameters + ---------- + pot : `~galpy.potential.SCFPotential` + rgrid : array-like[float] + The radial component of the interpolation grid. + thetagrid : array-like[float] + The inclination component of the interpolation grid. + :math:`\theta \in [-\pi/2, \pi/2]`, from the South to North pole, so + :math:`\theta = 0` is the equator. + phigrid : array-like[float] + The azimuthal component of the interpolation grid. + :math:`phi \in [0, 2\pi)`. + + **kw: + passed to :class:`~sample_scf.sample_interp.r_distribution`, + :class:`~sample_scf.sample_interp.theta_distribution`, + :class:`~sample_scf.sample_interp.phi_distribution` + + Examples + -------- + For all examples we assume the following imports + + >>> import numpy as np + >>> from galpy import potential + + For the SCF Potential we will use the simple example of a Hernquist sphere. + + >>> Acos = np.zeros((20, 24, 24)) + >>> Acos[0, 0, 0] = 1 # Hernquist potential + >>> pot = potential.SCFPotential(Acos=Acos) + + Now we make the sampler, specifying the grid from which the interpolation + will be built. + + >>> rgrid = np.geomspace(1e-1, 1e3, 100) + >>> thetagrid = np.linspace(-np.pi / 2, np.pi / 2, 30) + >>> phigrid = np.linspace(0, 2 * np.pi, 30) + + >>> sampler = SCFSampler(pot, rgrid=rgrid, thetagrid=thetagrid, phigrid=phigrid) + + Now we can evaluate the CDF + + >>> sampler.cdf(10.0, np.pi/3, np.pi) + array([0.82666461, 0.9330127 , 0.5 ]) + + And draw samples + + >>> sampler.rvs(size=5, random_state=3) + + """ + + def __init__( + self, + potential: SCFPotential, + rgrid: NDArrayF, + thetagrid: NDArrayF, + phigrid: NDArrayF, + **kw: Any, + ) -> None: + super().__init__(potential) + + # compute the r-dependent coefficient matrix $\tilde{\rho}$ + nmax, lmax = potential._Acos.shape[:2] + rhoTilde = np.array([potential._rhoTilde(r, N=nmax, L=lmax) for r in rgrid]) # (R, N, L) + # this matrix can have incorrect NaN values when rgrid=0, inf + # and needs to be corrected + ind = (rgrid == 0) | (rgrid == np.inf) + rhoTilde[ind] = np.nan_to_num( + rhoTilde[ind], + copy=False, + nan=0, + posinf=np.inf, + neginf=-np.inf, + ) + + # ---------- + # theta Qls + # radial sums over $\cos$ portion of the density function + # the $\sin$ part disappears in the integral. + + Qls = kw.pop("Qls", None) + if Qls is None: + Qls = np.sum(potential._Acos[None, :, :, 0] * rhoTilde, axis=1) # ({R}, L) + # this matrix can have incorrect NaN values when rgrid=0 because + # rhoTilde will have +/- infs which when summed produce a NaN. + # at r=0 this can be changed to 0. # TODO! double confirm math + ind0 = rgrid == 0 + if not np.sum(np.nan_to_num(rhoTilde[ind0, :, 0], posinf=1, neginf=-1)) == 0: + # note: this if statement works even if ind0 is all False + warnings.warn("Qls have non-cancelling infinities at r==0") + else: + Qls[ind0] = np.nan_to_num(Qls[ind0], copy=False) + + # ---------- + # phi Rm, Sm + # radial and inclination sums + + RSms = kw.pop("RSms", None) + if RSms is None: + with warnings.catch_warnings(): + warnings.filterwarnings( + "ignore", + category=RuntimeWarning, + message="(^invalid value)|(^overflow encountered)", + ) + RSms = _grid_phiRSms( + rhoTilde, + Acos=potential._Acos, + Asin=potential._Asin, + r=rgrid, + theta=thetagrid, + ) + + # ---------- + # make samplers + + self._r_distribution = r_distribution(potential, rgrid, **kw) + self._theta_distribution = theta_distribution(potential, rgrid, thetagrid, Qls=Qls, **kw) + self._phi_distribution = phi_distribution( + potential, rgrid, thetagrid, phigrid, RSms=RSms, **kw + ) diff --git a/sample_scf/interpolated/rvs_azimuth.py b/sample_scf/interpolated/rvs_azimuth.py new file mode 100644 index 0000000..43f84a0 --- /dev/null +++ b/sample_scf/interpolated/rvs_azimuth.py @@ -0,0 +1,221 @@ +# -*- coding: utf-8 -*- + +"""**DOCSTRING**. + +Description. + +""" + +############################################################################## +# IMPORTS + +from __future__ import annotations + +# BUILT-IN +import itertools +import warnings +from typing import Any, Optional, Union, cast + +# THIRD PARTY +import astropy.units as u +import numpy as np +from galpy.potential import SCFPotential +from numpy.typing import ArrayLike +from scipy.interpolate import RegularGridInterpolator, splev, splrep + +# LOCAL +from sample_scf._typing import NDArrayF, RandomLike +from sample_scf.base import rv_potential +from sample_scf.cdf_strategy import default_cdf_strategy +from sample_scf.utils import phiRSms, x_of_theta, zeta_of_r + +__all__ = ["phi_distribution"] + + +############################################################################## +# CODE +############################################################################## + + +class phi_distribution(rv_potential): + """SCF phi sampler. + + .. todo:: + + Make sure that stuff actually goes from 0 to 1. + + Parameters + ---------- + potential : `galpy.potential.SCFPotential` + rgrid : ndarray[float] + tgrid : ndarray[float] + pgrid : ndarray[float] + intrp_step : float, optional + **kw + Passed to `scipy.stats.rv_continuous` + "a", "b" are set to [0, 2 pi] + """ + + def __init__( + self, + potential: SCFPotential, + rgrid: NDArrayF, + tgrid: NDArrayF, + pgrid: NDArrayF, + intrp_step: float = 0.01, + **kw: Any, + ) -> None: + kw["a"], kw["b"] = 0, 2 * np.pi + (Rm, Sm) = kw.pop("RSms", (None, None)) + super().__init__(potential, **kw) # allowed range of r + + self._phi_interpolant = np.arange(0, 2 * np.pi, intrp_step) + self._ninterpolant = len(self._phi_interpolant) + self._q_interpolant = qarr = np.linspace(0, 1, self._ninterpolant) + + # ------- + # build CDF + + zetas = zeta_of_r(rgrid) # (R,) + xs = x_of_theta(tgrid) # (T,) + + lR, lT, _ = len(rgrid), len(tgrid), len(pgrid) + + Phis = pgrid[None, None, :, None] # ({R}, {T}, P, {L}) + + # get Rm, Sm. We have defaults from above. + if Rm is None: + print("WTF?") + Rm, Sm = phiRSms(potential, rgrid, tgrid, grid=True, warn=False) # (R, T, L) + elif (Rm.shape != Sm.shape) or (Rm.shape != (lR, lT, self._lmax)): + # check the user-passed values are the right shape + raise ValueError(f"Rm, Sm must be shape ({lR}, {lT}, {self._lmax})") + + # l = 0 : spherical symmetry + term0 = Phis[..., 0] / (2 * np.pi) # (1, 1, P) + # l = 1+ : non-symmetry + with warnings.catch_warnings(): # ignore true_divide RuntimeWarnings + warnings.simplefilter("ignore") + factor = 1 / Rm[:, :, :1] # R0 (R, T, 1) # can be inf + + ms = np.arange(1, self._lmax)[None, None, None, :] # ({R}, {T}, {P}, L) + term1p = np.sum( + ( + (Rm[:, :, None, 1:] * np.sin(ms * Phis)) + + (Sm[:, :, None, 1:] * (1 - np.cos(ms * Phis))) + ) + / (2 * np.pi * ms), + axis=-1, + ) + + cdfs = term0 + np.nan_to_num(factor * term1p) # (R, T, P) + # 'factor' can be inf and term1p 0 => inf * 0 = nan -> 0 + + # interpolate + # currently assumes a regular grid + self._spl_cdf = RegularGridInterpolator((zetas, xs, pgrid), cdfs) + + # ------- + # ppf + # might need cdf strategy to enforce "reality" + cdfstrategy = default_cdf_strategy.get() + + # start by supersampling + Zetas, Xs, Phis = np.meshgrid(zetas, xs, self._phi_interpolant, indexing="ij") + _cdfs = self._spl_cdf((Zetas.ravel(), Xs.ravel(), Phis.ravel())).reshape( + lR, + lT, + len(self._phi_interpolant), + ) + # build reverse spline + ppfs = np.empty((lR, lT, self._ninterpolant), dtype=np.float64) + for (i, j) in itertools.product(*map(range, ppfs.shape[:2])): + try: + spl = splrep(_cdfs[i, j, :], self._phi_interpolant, s=0) + except ValueError: # CDF is non-real + _cdf = cdfstrategy.apply(_cdfs[i, j, :], index=(i, j)) + spl = splrep(_cdf, self._phi_interpolant, s=0) + + ppfs[i, j, :] = splev(qarr, spl, ext=0) + # interpolate + self._spl_ppf = RegularGridInterpolator( + (zetas, xs, qarr), + ppfs, + bounds_error=False, + ) + + def _cdf( + self, + phi: ArrayLike, + *args: Any, + zeta: ArrayLike, + x: ArrayLike, + ) -> NDArrayF: + cdf: NDArrayF = self._spl_cdf((zeta, x, phi)) + return cdf + + def cdf( + self, + phi: ArrayLike, + r: ArrayLike, + theta: ArrayLike, + ) -> NDArrayF: + # TODO! make sure r, theta in right domain + cdf = self._cdf( + phi, + zeta=zeta_of_r(r), + x=x_of_theta(u.Quantity(theta, u.rad)), + ) + return cdf + + def _ppf( + self, + q: ArrayLike, + *args: Any, + r: ArrayLike, + theta: NDArrayF, + **kw: Any, + ) -> NDArrayF: + ppf: NDArrayF = self._spl_ppf((zeta_of_r(r), x_of_theta(theta), q)) + return ppf + + def _rvs( + self, + r: ArrayLike, + theta: NDArrayF, + *args: Any, + random_state: np.random.RandomState, + size: Optional[int] = None, + ) -> NDArrayF: + # Use inverse cdf algorithm for RV generation. + U = random_state.uniform(size=size) + Y = self._ppf(U, *args, r=r, theta=theta) + return Y + + def rvs( # type: ignore + self, + r: Union[np.floating, ArrayLike], + theta: Union[np.floating, ArrayLike], + *, + size: Optional[int] = None, + random_state: RandomLike = None, + ) -> NDArrayF: + """Random variate sampler. + + Parameters + ---------- + r, theta : array-like[float] + size : int or None (optional, keyword-only) + Size of random variates to generate. + random_state : int, `~numpy.random.RandomState`, or None (optional, keyword-only) + If seed is None (or numpy.random), the `numpy.random.RandomState` + singleton is used. If seed is an int, a new RandomState instance is + used, seeded with seed. If seed is already a Generator or + RandomState instance then that instance is used. + + Returns + ------- + ndarray[float] + Shape 'size'. + """ + return super().rvs(r, theta, size=size, random_state=random_state) diff --git a/sample_scf/interpolated/rvs_inclination.py b/sample_scf/interpolated/rvs_inclination.py new file mode 100644 index 0000000..e06ab23 --- /dev/null +++ b/sample_scf/interpolated/rvs_inclination.py @@ -0,0 +1,211 @@ +# -*- coding: utf-8 -*- + +"""**DOCSTRING**. + +Description. + +""" + +############################################################################## +# IMPORTS + +from __future__ import annotations + +# BUILT-IN +import itertools +import warnings +from typing import Any, Optional, Union, cast + +# THIRD PARTY +import astropy.units as u +import numpy as np +from galpy.potential import SCFPotential +from numpy.typing import ArrayLike +from scipy.interpolate import RectBivariateSpline, splev, splrep + +# LOCAL +from sample_scf._typing import NDArrayF, RandomLike +from sample_scf.base import rv_potential +from sample_scf.utils import difPls, phiRSms, thetaQls, x_of_theta, zeta_of_r + +__all__ = ["theta_distribution"] + + +############################################################################## +# CODE +############################################################################## + + +class theta_distribution(rv_potential): + """ + Sample inclination coordinate from an SCF potential. + + Parameters + ---------- + pot : `~galpy.potential.SCFPotential` + rgrid, tgrid : ndarray + **kw + Passed to `scipy.stats.rv_continuous` + "a", "b" are set to [-pi/2, pi/2] + """ + + def __init__( + self, + potential: SCFPotential, + rgrid: NDArrayF, + tgrid: NDArrayF, + intrp_step: float = 0.01, + **kw: Any, + ) -> None: + kw["a"], kw["b"] = -np.pi / 2, np.pi / 2 + Qls: NDArrayF = kw.pop("Qls", None) + super().__init__(potential, **kw) # allowed range of theta + + self._theta_interpolant = np.arange(-np.pi / 2, np.pi / 2, intrp_step) + self._x_interpolant = x_of_theta(self._theta_interpolant) + self._q_interpolant = np.linspace(0, 1, len(self._theta_interpolant)) + + self._lrange = np.arange(0, self._lmax + 1) + + # ------- + # build CDF in shells + # TODO: clean up shape stuff + + zetas = zeta_of_r(rgrid) # (R,) + xs = x_of_theta(tgrid) # (T,) + + Qls = Qls if Qls is not None else thetaQls(potential, rgrid) + # check it's the right shape (R, Lmax) + if Qls.shape != (len(rgrid), self._lmax): + raise ValueError(f"Qls must be shape ({len(rgrid)}, {self._lmax})") + + # l = 0 : spherical symmetry + term0 = cast(NDArrayF, 0.5 * (xs + 1.0)) # (T,) + # l = 1+ : non-symmetry + factor = 1.0 / (2.0 * Qls[:, 0]) # (R,) + term1p = np.sum( + (Qls[None, :, 1:] * difPls(xs, self._lmax - 1).T[:, None, :]).T, + axis=0, + ) + + cdfs = term0[None, :] + np.nan_to_num(factor[:, None] * term1p) # (R, T) + + # ------- + # interpolate + # currently assumes a regular grid + + self._spl_cdf = RectBivariateSpline( + zetas, + xs, + cdfs, + bbox=[-1, 1, -1, 1], # [zetamin, zetamax, xmin, xmax] + kx=kw.get("kx", 3), + ky=kw.get("ky", 3), + s=kw.get("s", 0), + ) + + # ppf, one per r + # TODO! see if can use this to avoid resplining + _cdfs = self._spl_cdf(zetas, self._x_interpolant) + spls = [ # work through the rs + splrep(_cdfs[i, :], self._theta_interpolant, s=0) for i in range(_cdfs.shape[0]) + ] + ppfs = np.array([splev(self._q_interpolant, spl, ext=0) for spl in spls]) + self._spl_ppf = RectBivariateSpline( + zetas, + self._q_interpolant, + ppfs, + bbox=[-1, 1, 0, 1], # [zetamin, zetamax, xmin, xmax] + kx=kw.get("kx", 3), + ky=kw.get("ky", 3), + s=kw.get("s", 0), + ) + + def _cdf(self, x: ArrayLike, *args: Any, zeta: ArrayLike, **kw: Any) -> NDArrayF: + cdf: NDArrayF = self._spl_cdf(zeta, x, grid=False) + return cdf + + def cdf(self, theta: ArrayLike, r: ArrayLike) -> NDArrayF: + """Cumulative Distribution Function. + + Parameters + ---------- + theta : array-like or Quantity-like + r : array-like or Quantity-like + + Returns + ------- + cdf : ndarray[float] + """ + # TODO! make sure r, theta in right domain + cdf = self._cdf(x_of_theta(u.Quantity(theta, u.rad)), zeta=zeta_of_r(r)) + return cdf + + def _ppf( + self, + q: ArrayLike, + *, + r: ArrayLike, + **kw: Any, + ) -> NDArrayF: + """Percent-point function. + + Parameters + ---------- + q : float or (N,) array-like[float] + r : float or (N,) array-like[float] + + Returns + ------- + float or (N,) array-like[float] + Same shape as 'r', 'q'. + """ + ppf: NDArrayF = self._spl_ppf(zeta_of_r(r), q, grid=False) + return ppf + + def _rvs( + self, + r: ArrayLike, + *, + random_state: Union[np.random.RandomState, np.random.Generator], + size: Optional[int] = None, + ) -> NDArrayF: + """Random variate sampling. + + Parameters + ---------- + r : float or (N,) array-like[float] + size : int (optional, keyword-only) + random_state : int or None (optional, keyword-only) + + Returns + ------- + float or array-like[float] + Shape 'size'. + """ + # Use inverse cdf algorithm for RV generation. + U = random_state.uniform(size=size) + Y = self._ppf(U, r=r, grid=False) + return Y + + def rvs( # type: ignore + self, + r: ArrayLike, + *, + size: Optional[int] = None, + random_state: RandomLike = None, + ) -> NDArrayF: + """Random variate sampling. + + Parameters + ---------- + r : float or (N,) array-like[float] + size : int or None (optional, keyword-only) + random_state : int or None (optional, keyword-only) + + Returns + ------- + float or array-like[float] + Shape 'size'. + """ + return super().rvs(r, size=size, random_state=random_state) diff --git a/sample_scf/interpolated/rvs_radial.py b/sample_scf/interpolated/rvs_radial.py new file mode 100644 index 0000000..539678c --- /dev/null +++ b/sample_scf/interpolated/rvs_radial.py @@ -0,0 +1,92 @@ +# -*- coding: utf-8 -*- + +"""**DOCSTRING**. + +Description. + +""" + +############################################################################## +# IMPORTS + +from __future__ import annotations + +# BUILT-IN +from typing import Any + +# THIRD PARTY +import numpy as np +from galpy.potential import SCFPotential +from numpy.typing import ArrayLike +from scipy.interpolate import InterpolatedUnivariateSpline + +# LOCAL +from sample_scf._typing import NDArrayF +from sample_scf.base import r_distribution_base +from sample_scf.utils import r_of_zeta, zeta_of_r + +__all__ = ["r_distribution"] + + +############################################################################## +# CODE +############################################################################## + + +class r_distribution(r_distribution_base): + """Sample radial coordinate from an SCF potential. + + The potential must have a convergent mass function. + + Parameters + ---------- + potential : `galpy.potential.SCFPotential` + rgrid : ndarray + **kw + Passed to `scipy.stats.rv_continuous` + "a", "b" are set to [0, inf] + """ + + def __init__(self, potential: SCFPotential, rgrid: NDArrayF, **kw: Any) -> None: + kw["a"], kw["b"] = 0, np.nanmax(rgrid) # allowed range of r + super().__init__(potential, **kw) + + mgrid = np.array([potential._mass(x) for x in rgrid]) # :( + # manual fixes for endpoints and normalization + ind = np.where(np.isnan(mgrid))[0] + mgrid[ind[rgrid[ind] == 0]] = 0 + mgrid = (mgrid - np.nanmin(mgrid)) / (np.nanmax(mgrid) - np.nanmin(mgrid)) # rescale + infind = ind[rgrid[ind] == np.inf].squeeze() + mgrid[infind] = 1 + if mgrid[infind - 1] == 1: # munge the rescaling TODO! do better + mgrid[infind - 1] -= min(1e-8, np.diff(mgrid[slice(infind - 2, infind)]) / 2) + + # work in zeta, not r, since it is more numerically stable + zeta = zeta_of_r(rgrid) + # make splines for fast calculation + self._spl_cdf = InterpolatedUnivariateSpline( + zeta, + mgrid, + ext="raise", + bbox=[-1, 1], + ) + self._spl_ppf = InterpolatedUnivariateSpline( + mgrid, + zeta, + ext="raise", + bbox=[0, 1], + ) + + # TODO! make sure + # # store endpoint values to ensure CDF normalized to [0, 1] + # self._mi = self._spl_cdf(min(zeta)) + # self._mf = self._spl_cdf(max(zeta)) + + def _cdf(self, r: ArrayLike, *args: Any, **kw: Any) -> NDArrayF: + cdf: NDArrayF = self._spl_cdf(zeta_of_r(r)) + # (self._scfmass(zeta) - self._mi) / (self._mf - self._mi) + # TODO! is this normalization even necessary? + return cdf + + def _ppf(self, q: ArrayLike, *args: Any, **kw: Any) -> NDArrayF: + return r_of_zeta(self._spl_ppf(q)) diff --git a/sample_scf/tests/test_interpolated.py b/sample_scf/interpolated/tests/test_interpolated.py similarity index 78% rename from sample_scf/tests/test_interpolated.py rename to sample_scf/interpolated/tests/test_interpolated.py index f33ee10..7463477 100644 --- a/sample_scf/tests/test_interpolated.py +++ b/sample_scf/interpolated/tests/test_interpolated.py @@ -15,9 +15,8 @@ from numpy.testing import assert_allclose # LOCAL -from .common import SCFPhiSamplerTestBase, SCFRSamplerTestBase, SCFThetaSamplerTestBase -from .test_base import SCFSamplerTestBase -from .test_base import Test_RVPotential as RVPotentialTest +from .common import phi_distributionTestBase, r_distributionTestBase, theta_distributionTestBase +from .test_base import RVPotentialTest, SCFSamplerTestBase from sample_scf import conftest, interpolated from sample_scf.utils import phiRSms, r_of_zeta, thetaQls, x_of_theta, zeta_of_r @@ -43,6 +42,7 @@ def setup_class(self): self.cls = interpolated.SCFSampler self.cls_args = (rgrid, tgrid, pgrid) self.cls_kwargs = {} + self.cls_pot_kw = {} # TODO! make sure these are right! self.expected_rvs = { @@ -55,8 +55,6 @@ def setup_class(self): ), } - # /def - # =============================================================== # Method Tests @@ -73,8 +71,6 @@ def test_cdf(self, sampler, r, theta, phi, expected): """Test :meth:`sample_scf.base.SCFSamplerBase.cdf`.""" assert np.allclose(sampler.cdf(r, theta, phi), expected, atol=1e-16) - # /def - # =============================================================== # Plot Tests @@ -82,22 +78,16 @@ def test_cdf(self, sampler, r, theta, phi, expected): def test_interp_cdf_plot(self): assert False - # /def - @pytest.mark.skip("TODO!") def test_interp_sampling_plot(self): assert False - # /def - - -# /class ############################################################################## class InterpRVPotentialTest(RVPotentialTest): - def test___init__(self, sampler): + def test_init(self, sampler): """Test initialization.""" potential = sampler._potential @@ -129,20 +119,17 @@ def test___init__(self, sampler): with pytest.raises(TypeError, match="SCFPotential"): self.cls(None, *self.cls_args) - # /def - - -# /class - # ---------------------------------------------------------------------------- -class Test_SCFRSampler(SCFRSamplerTestBase, InterpRVPotentialTest): - """Test :class:`sample_scf.sample_interp.SCFRSampler`""" +class Test_r_distribution(r_distributionTestBase, InterpRVPotentialTest): + """Test :class:`sample_scf.sample_interp.r_distribution`""" def setup_class(self): - self.cls = interpolated.SCFRSampler + super().setup_class(self) + + self.cls = interpolated.r_distribution self.cls_args = (rgrid,) self.cls_kwargs = {} self.cls_pot_kw = {} @@ -150,46 +137,34 @@ def setup_class(self): self.cdf_time_scale = 6e-4 # milliseconds self.rvs_time_scale = 2e-4 # milliseconds - self.theory = dict( - hernquist=conftest.hernquist_df, - ) - - # /def - # =============================================================== # Method Tests - def test___init__(self, sampler): + def test_init(self, sampler): """Test initialization.""" - super().test___init__(sampler) + super().test_init(sampler) # TODO! test mgrid endpoints, cdf, and ppf - # /def - # TODO! use hypothesis @pytest.mark.parametrize("r", np.random.default_rng(0).uniform(0, 1e4, 10)) def test__cdf(self, sampler, r): - """Test :meth:`sample_scf.interpolated.SCFRSampler._cdf`.""" + """Test :meth:`sample_scf.interpolated.r_distribution._cdf`.""" super().test__cdf(sampler, r) # expected assert_allclose(sampler._cdf(r), sampler._spl_cdf(zeta_of_r(r))) - # /def - # TODO! use hypothesis @pytest.mark.parametrize("q", np.random.default_rng(0).uniform(0, 1, 10)) def test__ppf(self, sampler, q): - """Test :meth:`sample_scf.interpolated.SCFRSampler._ppf`.""" + """Test :meth:`sample_scf.interpolated.r_distribution._ppf`.""" # expected assert_allclose(sampler._ppf(q), r_of_zeta(sampler._spl_ppf(q))) # args and kwargs don't matter assert_allclose(sampler._ppf(q), sampler._ppf(q, 10, test=14)) - # /def - @pytest.mark.parametrize( "size, random, expected", [ @@ -200,11 +175,9 @@ def test__ppf(self, sampler, q): ], ) def test_rvs(self, sampler, size, random, expected): - """Test :meth:`sample_scf.interpolated.SCFRSampler.rvs`.""" + """Test :meth:`sample_scf.interpolated.r_distribution.rvs`.""" super().test_rvs(sampler, size, random, expected) - # /def - # =============================================================== # Image Tests @@ -251,80 +224,67 @@ def test_interp_r_cdf_plot(self, sampler): fig.tight_layout() return fig - # /def - @pytest.mark.mpl_image_compare( baseline_dir="baseline_images", # hash_library="baseline_images/path_to_file.json", ) - def test_interp_r_sampling_plot(self, request, sampler): + def test_interp_r_sampling_plot(self, sampler): """Test sampling.""" - # fiqure out theory sampler - options = request.fixturenames[0] - if "hernquist" in options: - kind = "hernquist" - else: - raise ValueError - with NumpyRNGContext(0): # control the random numbers sample = sampler.rvs(size=int(1e6)) sample = sample[sample < 1e4] - theory = self.theory[kind].sample(n=int(1e6)).r() - theory = theory[theory < 1e4] + theory = self.theory[sampler._potential].sample(n=int(1e6)).r() + theory = theory[theory < 1e4 * u.kpc] fig = plt.figure(figsize=(10, 3)) ax = fig.add_subplot(121, title="SCF vs theory sampling", xlabel="r", ylabel="frequency") - _, bins, *_ = ax.hist(sample, bins=30, log=True, alpha=0.5, label="SCF sample") + _, bins, *_ = ax.hist( + sample, bins=30, log=True, alpha=0.5, label="SCF sample", c="tab:blue" + ) # Comparing to expected ax.hist( - theory, + theory.to_value(u.kpc), bins=bins, log=True, - alpha=0.5, - label="Hernquist theoretical", + edgecolor="black", + linewidth=1.2, + fc=(1, 0, 0, 0.0), + label="Theoretical", ) ax.legend() fig.tight_layout() return fig - # /def - - -# /class # ---------------------------------------------------------------------------- -class Test_SCFThetaSampler(SCFThetaSamplerTestBase, InterpRVPotentialTest): - """Test :class:`sample_scf.interpolated.SCFThetaSampler`.""" +class Test_theta_distribution(theta_distributionTestBase, InterpRVPotentialTest): + """Test :class:`sample_scf.interpolated.theta_distribution`.""" def setup_class(self): super().setup_class(self) - self.cls = interpolated.SCFThetaSampler + self.cls = interpolated.theta_distribution self.cls_args = (rgrid, tgrid) self.cdf_time_scale = 3e-4 self.rvs_time_scale = 6e-4 - # /def - # =============================================================== # Method Tests - def test___init__(self, sampler): + def test_init(self, sampler): """Test initialization.""" - super().test___init__(sampler) + super().test_init(sampler) # a shape mismatch Qls = thetaQls(sampler._potential, rgrid[1:-1]) with pytest.raises(ValueError, match="Qls must be shape"): sampler.__class__(sampler._potential, rgrid, tgrid, Qls=Qls) - # /def - # TODO! use hypothesis @pytest.mark.parametrize( "x, zeta", @@ -336,23 +296,19 @@ def test___init__(self, sampler): ], ) def test__cdf(self, sampler, x, zeta): - """Test :meth:`sample_scf.interpolated.SCFThetaSampler._cdf`.""" + """Test :meth:`sample_scf.interpolated.theta_distribution._cdf`.""" # expected assert_allclose(sampler._cdf(x, zeta=zeta), sampler._spl_cdf(zeta, x, grid=False)) # args and kwargs don't matter assert_allclose(sampler._cdf(x, zeta=zeta), sampler._cdf(x, 10, zeta=zeta, test=14)) - # /def - @pytest.mark.parametrize("zeta", np.random.default_rng(0).uniform(-1, 1, 10)) def test__cdf_edge(self, sampler, zeta): - """Test :meth:`sample_scf.interpolated.SCFRSampler._cdf`.""" + """Test :meth:`sample_scf.interpolated.r_distribution._cdf`.""" assert np.isclose(sampler._cdf(-1, zeta=zeta), 0.0, atol=1e-16) assert np.isclose(sampler._cdf(1, zeta=zeta), 1.0, atol=1e-16) - # /def - @pytest.mark.parametrize( "theta, r", [ @@ -363,35 +319,27 @@ def test__cdf_edge(self, sampler, zeta): ], ) def test_cdf(self, sampler, theta, r): - """Test :meth:`sample_scf.interpolated.SCFThetaSampler.cdf`.""" + """Test :meth:`sample_scf.interpolated.theta_distribution.cdf`.""" assert_allclose( sampler.cdf(theta, r), sampler._spl_cdf(zeta_of_r(r), x_of_theta(u.Quantity(theta, u.rad)), grid=False), ) - # /def - @pytest.mark.skip("TODO!") def test__ppf(self): - """Test :meth:`sample_scf.interpolated.SCFThetaSampler._ppf`.""" + """Test :meth:`sample_scf.interpolated.theta_distribution._ppf`.""" assert False - # /def - @pytest.mark.skip("TODO!") def test__rvs(self): - """Test :meth:`sample_scf.interpolated.SCFThetaSampler._rvs`.""" + """Test :meth:`sample_scf.interpolated.theta_distribution._rvs`.""" assert False - # /def - @pytest.mark.skip("TODO!") def test_rvs(self): - """Test :meth:`sample_scf.interpolated.SCFThetaSampler.rvs`.""" + """Test :meth:`sample_scf.interpolated.theta_distribution.rvs`.""" assert False - # /def - # =============================================================== # Image Tests @@ -436,26 +384,18 @@ def test_interp_theta_cdf_plot(self, sampler): fig.tight_layout() return fig - # /def - @pytest.mark.mpl_image_compare( baseline_dir="baseline_images", # hash_library="baseline_images/path_to_file.json", ) - def test_interp_theta_sampling_plot(self, request, sampler): + def test_interp_theta_sampling_plot(self, sampler): """Test sampling.""" - # fiqure out theory sampler - options = request.fixturenames[0] - if "hernquist" in options: - kind = "hernquist" - else: - raise ValueError - with NumpyRNGContext(0): # control the random numbers sample = sampler.rvs(size=int(1e6), r=10) sample = sample[sample < 1e4] - theory = self.theory[kind].sample(n=int(1e6)).theta() - np.pi / 2 + theory = self.theory[sampler._potential].sample(n=int(1e6)).theta() + theory -= np.pi / 2 * u.rad # adjust range back fig = plt.figure(figsize=(10, 3)) ax = fig.add_subplot( @@ -464,91 +404,77 @@ def test_interp_theta_sampling_plot(self, request, sampler): xlabel=r"$\theta$", ylabel="frequency", ) - _, bins, *_ = ax.hist(sample, bins=30, log=True, alpha=0.5, label="SCF sample") + _, bins, *_ = ax.hist( + sample, bins=30, log=True, label="SCF sample", color="tab:blue", alpha=0.5 + ) # Comparing to expected ax.hist( - theory, + theory.to_value(u.rad), bins=bins, log=True, - alpha=0.5, - label="Hernquist theoretical", + edgecolor="black", + linewidth=1.2, + fc=(1, 0, 0, 0.0), + label="Theoretical", ) ax.legend() fig.tight_layout() return fig - # /def - - -# /class # ---------------------------------------------------------------------------- -class Test_SCFPhiSampler(SCFPhiSamplerTestBase, InterpRVPotentialTest): - """Test :class:`sample_scf.interpolated.SCFPhiSampler`.""" +class Test_phi_distribution(phi_distributionTestBase, InterpRVPotentialTest): + """Test :class:`sample_scf.interpolated.phi_distribution`.""" def setup_class(self): super().setup_class(self) - self.cls = interpolated.SCFPhiSampler + self.cls = interpolated.phi_distribution self.cls_args = (rgrid, tgrid, pgrid) self.cdf_time_scale = 12e-4 self.rvs_time_scale = 12e-4 - # /def - # =============================================================== # Method Tests - def test___init__(self, sampler): - """Test :meth:`sample_scf.interpolated.SCFPhiSampler._cdf`.""" - # super().test___init__(sampler) # doesn't work TODO! + def test_init(self, sampler): + """Test :meth:`sample_scf.interpolated.phi_distribution._cdf`.""" + # super().test_init(sampler) # doesn't work TODO! # a shape mismatch - RSms = phiRSms(sampler._potential, rgrid[1:-1], tgrid[1:-1]) + RSms = phiRSms(sampler._potential, rgrid[1:-1], tgrid[1:-1], warn=False) with pytest.raises(ValueError, match="Rm, Sm must be shape"): sampler.__class__(sampler._potential, rgrid, tgrid, pgrid, RSms=RSms) - # /def - @pytest.mark.skip("TODO!") def test__cdf(self): - """Test :meth:`sample_scf.interpolated.SCFPhiSampler._cdf`.""" + """Test :meth:`sample_scf.interpolated.phi_distribution._cdf`.""" assert False - # /def - @pytest.mark.skip("TODO!") def test_cdf(self): - """Test :meth:`sample_scf.interpolated.SCFPhiSampler.cdf`.""" + """Test :meth:`sample_scf.interpolated.phi_distribution.cdf`.""" assert False - # /def - @pytest.mark.skip("TODO!") def test__ppf(self): - """Test :meth:`sample_scf.interpolated.SCFPhiSampler._ppf`.""" + """Test :meth:`sample_scf.interpolated.phi_distribution._ppf`.""" assert False - # /def - @pytest.mark.skip("TODO!") def test__rvs(self): - """Test :meth:`sample_scf.interpolated.SCFPhiSampler._rvs`.""" + """Test :meth:`sample_scf.interpolated.phi_distribution._rvs`.""" assert False - # /def - @pytest.mark.skip("TODO!") def test_rvs(self): - """Test :meth:`sample_scf.interpolated.SCFPhiSampler.rvs`.""" + """Test :meth:`sample_scf.interpolated.phi_distribution.rvs`.""" assert False - # /def - # =============================================================== # Image Tests @@ -578,26 +504,17 @@ def test_interp_phi_cdf_plot(self, sampler): fig.tight_layout() return fig - # /def - @pytest.mark.mpl_image_compare( baseline_dir="baseline_images", # hash_library="baseline_images/path_to_file.json", ) - def test_interp_phi_sampling_plot(self, request, sampler): + def test_interp_phi_sampling_plot(self, sampler): """Test sampling.""" - # fiqure out theory sampler - options = request.fixturenames[0] - if "hernquist" in options: - kind = "hernquist" - else: - raise ValueError - with NumpyRNGContext(0): # control the random numbers sample = sampler.rvs(size=int(1e6), r=10, theta=np.pi / 6) sample = sample[sample < 1e4] - theory = self.theory[kind].sample(n=int(1e6)).phi() + theory = self.theory[sampler._potential].sample(n=int(1e6)).phi() fig = plt.figure(figsize=(10, 3)) ax = fig.add_subplot( @@ -606,24 +523,20 @@ def test_interp_phi_sampling_plot(self, request, sampler): xlabel=r"$\phi$", ylabel="frequency", ) - _, bins, *_ = ax.hist(sample, bins=30, log=True, alpha=0.5, label="SCF sample") + _, bins, *_ = ax.hist( + sample, bins=30, log=True, alpha=0.5, c="tab:blue", label="SCF sample" + ) # Comparing to expected ax.hist( - theory, + theory.to_value(u.rad), bins=bins, log=True, - alpha=0.5, - label="Hernquist theoretical", + edgecolor="black", + linewidth=1.2, + fc=(1, 0, 0, 0.0), + label="Theoretical", ) ax.legend() fig.tight_layout() return fig - - # /def - - -# /class - -############################################################################## -# END diff --git a/sample_scf/tests/common.py b/sample_scf/tests/common.py index 9cba6ae..49a2ff1 100644 --- a/sample_scf/tests/common.py +++ b/sample_scf/tests/common.py @@ -15,7 +15,7 @@ from numpy.testing import assert_allclose # LOCAL -from .test_base import Test_RVPotential as RVPotentialTest +from .test_base import RVPotentialTest from sample_scf import conftest ############################################################################## @@ -23,134 +23,41 @@ ############################################################################## -class SCFRSamplerTestBase(RVPotentialTest): +class r_distributionTestBase(RVPotentialTest): def test__cdf(self, sampler, r): - """Test :meth:`sample_scf.interpolated.SCFRSampler._cdf`.""" + """Test :meth:`sample_scf.interpolated.r_distribution._cdf`.""" # args and kwargs don't matter assert_allclose(sampler._cdf(r), sampler._cdf(r, 10, test=14)) - # /def - def test__cdf_edge(self, sampler): - """Test :meth:`sample_scf.interpolated.SCFRSampler._cdf`.""" + """Test :meth:`sample_scf.interpolated.r_distribution._cdf`.""" assert np.isclose(sampler._cdf(0.0), 0.0, 1e-20) assert np.isclose(sampler._cdf(np.inf), 1.0, 1e-20) - # /def - - -# /class - -class SCFThetaSamplerTestBase(RVPotentialTest): +class theta_distributionTestBase(RVPotentialTest): def setup_class(self): - self.cls = None - self.cls_args = () - self.cls_kwargs = {} - self.cls_pot_kw = {} + super().setup_class(self) - self.cdf_args = () + self.cls = None self.cdf_kwargs = {"r": 10} - - self.rvs_args = () self.rvs_kwargs = {"r": 10} + # time-scale tests + self.cdf_time_arr = lambda self, size: np.linspace(-np.pi / 2, np.pi / 2, size) self.cdf_time_scale = 0 self.rvs_time_scale = 0 - self.theory = dict( - hernquist=conftest.hernquist_df, - ) - - # /def - - # =============================================================== - # Method Tests - - # =============================================================== - # Time Scaling Tests - - @pytest.mark.parametrize("size", [1, 10, 100, 1000, 10000]) - def test_cdf_time_scaling(self, sampler, size): - """Test that the time scales as X * size""" - x = np.linspace(-np.pi / 2, np.pi / 2, size) - tic = time.perf_counter() - sampler.cdf(x, *self.cdf_args, **self.cdf_kwargs) - toc = time.perf_counter() - - assert (toc - tic) < self.cdf_time_scale * size # linear scaling - - # /def - - @pytest.mark.parametrize("size", [1, 10, 100, 1000, 10000]) - def test_rvs_time_scaling(self, sampler, size): - """Test that the time scales as X * size""" - tic = time.perf_counter() - sampler.rvs(size=size, *self.cdf_args, **self.cdf_kwargs) - toc = time.perf_counter() - - assert (toc - tic) < self.rvs_time_scale * size # linear scaling - - # /def - - -# /class - -class SCFPhiSamplerTestBase(RVPotentialTest): +class phi_distributionTestBase(RVPotentialTest): def setup_class(self): - self.cls = None - self.cls_args = () - self.cls_kwargs = {} - self.cls_pot_kw = {} + super().setup_class(self) - self.cdf_args = () + self.cls = None self.cdf_kwargs = {"r": 10, "theta": np.pi / 6} - - self.rvs_args = () self.rvs_kwargs = {"r": 10, "theta": np.pi / 6} + # time-scale tests + self.cdf_time_arr = lambda self, size: np.linspace(0, 2 * np.pi, size) self.cdf_time_scale = 0 self.rvs_time_scale = 0 - - self.theory = dict( - hernquist=conftest.hernquist_df, - ) - - # /def - - # =============================================================== - # Method Tests - - # =============================================================== - # Time Scaling Tests - - @pytest.mark.parametrize("size", [1, 10, 100, 1000, 10000]) - def test_cdf_time_scaling(self, sampler, size): - """Test that the time scales as X * size""" - x = np.linspace(0, 2 * np.pi, size) - tic = time.perf_counter() - sampler.cdf(x, *self.cdf_args, **self.cdf_kwargs) - toc = time.perf_counter() - - assert (toc - tic) < self.cdf_time_scale * size # linear scaling - - # /def - - @pytest.mark.parametrize("size", [1, 10, 100, 1000, 10000]) - def test_rvs_time_scaling(self, sampler, size): - """Test that the time scales as X * size""" - tic = time.perf_counter() - sampler.rvs(size=size, *self.cdf_args, **self.cdf_kwargs) - toc = time.perf_counter() - - assert (toc - tic) < self.rvs_time_scale * size # linear scaling - - # /def - - -# /class - - -############################################################################## -# END diff --git a/sample_scf/tests/test_base.py b/sample_scf/tests/test_base.py index 396b1a8..e2224e0 100644 --- a/sample_scf/tests/test_base.py +++ b/sample_scf/tests/test_base.py @@ -7,6 +7,8 @@ # IMPORTS # BUILT-IN +import abc +import inspect import time # THIRD PARTY @@ -15,10 +17,12 @@ import numpy as np import pytest from astropy.utils.misc import NumpyRNGContext +from galpy.potential import KeplerPotential from numpy.testing import assert_allclose +from scipy.stats import rv_continuous # LOCAL -from sample_scf import base +from sample_scf import base, conftest ############################################################################## # TESTS @@ -31,8 +35,6 @@ class rvtestsampler(base.rv_potential): def _cdf(self, x, *args, **kwargs): return x - # /def - cdf = _cdf def _rvs(self, *args, size=None, random_state=None): @@ -41,33 +43,31 @@ def _rvs(self, *args, size=None, random_state=None): return np.atleast_1d(random_state.uniform(size=size)) - # /def - - -# /class - -class Test_RVPotential: +class Test_RVPotential(metaclass=abc.ABCMeta): """Test `sample_scf.base.rv_potential`.""" def setup_class(self): + # sampler initialization self.cls = rvtestsampler self.cls_args = () self.cls_kwargs = {} self.cls_pot_kw = {} + # (kw)args into cdf() self.cdf_args = () self.cdf_kwargs = {} + # (kw)args into rvs() self.rvs_args = () self.rvs_kwargs = {} + # time-scale tests + self.cdf_time_arr = lambda self, size: np.linspace(0, 1e4, size) self.cdf_time_scale = 4e-6 self.rvs_time_scale = 1e-4 - # /def - - @pytest.fixture(autouse=True, scope="class") + @pytest.fixture() def sampler(self, potentials): """Set up r, theta, or phi sampler.""" kw = {**self.cls_kwargs, **self.cls_pot_kw.get(potentials, {})} @@ -75,16 +75,41 @@ def sampler(self, potentials): return sampler - # /def - # =============================================================== # Method Tests + def test_init_signature(self, sampler): + """Test signature is compatible with `scipy.stats.rv_continuous`.""" + sig = inspect.signature(sampler.__init__) + params = sig.parameters + + scipyps = inspect.signature(rv_continuous.__init__).parameters + + assert params["momtype"].default == scipyps["momtype"].default + assert params["a"].default == scipyps["a"].default + assert params["b"].default == scipyps["b"].default + assert params["xtol"].default == scipyps["xtol"].default + assert params["badvalue"].default == scipyps["badvalue"].default + assert params["name"].default == scipyps["name"].default + assert params["longname"].default == scipyps["longname"].default + assert params["shapes"].default == scipyps["shapes"].default + assert params["extradoc"].default == scipyps["extradoc"].default + assert params["seed"].default == scipyps["seed"].default + + def test_init(self, sampler): + """Test initialization.""" + # check it has the expected attributes + assert hasattr(sampler, "_potential") + assert hasattr(sampler, "_nmax") + assert hasattr(sampler, "_lmax") + + # bad value + with pytest.raises(TypeError, match=""): + sampler.__class__(KeplerPotential(), *self.cls_args, **self.cls_kwargs) + def test_cdf(self, sampler): """Test :meth:`sample_scf.base.rv_potential.cdf`.""" - assert sampler.cdf(0.0) == 0.0 - - # /def + assert sampler.cdf(0.0, *self.cdf_args, **self.cdf_kwargs) == 0.0 @pytest.mark.parametrize( "size, random, expected", @@ -104,25 +129,19 @@ def test_rvs(self, sampler, size, random, expected): with NumpyRNGContext(4): assert_allclose(sampler.rvs(size=size, random_state=random), expected, atol=1e-16) - # /def - # =============================================================== # Time Scaling Tests - # TODO! generalize for subclasses @pytest.mark.parametrize("size", [1, 10, 100, 1000, 10000]) def test_cdf_time_scaling(self, sampler, size): """Test that the time scales as X * size""" - x = np.linspace(0, 1e4, size) + x = self.cdf_time_arr(size) tic = time.perf_counter() sampler.cdf(x, *self.cdf_args, **self.cdf_kwargs) toc = time.perf_counter() assert (toc - tic) < self.cdf_time_scale * size # linear scaling - # /def - - # TODO! generalize for subclasses @pytest.mark.parametrize("size", [1, 10, 100, 1000, 10000]) def test_rvs_time_scaling(self, sampler, size): """Test that the time scales as X * size""" @@ -132,10 +151,22 @@ def test_rvs_time_scaling(self, sampler, size): assert (toc - tic) < self.rvs_time_scale * size # linear scaling - # /def +class RVPotentialTest(Test_RVPotential): + """Test rv_potential subclasses.""" -# /class + def setup_class(self): + super().setup_class(self) + + # self.cls_pot_kw = conftest.cls_pot_kw + self.theory = conftest.theory + + def test_init_signature(self, sampler): + """Test signature is compatible with `scipy.stats.rv_continuous`.""" + sig = inspect.signature(sampler.__init__) + params = sig.parameters + + assert "potential" in params ############################################################################## @@ -145,9 +176,11 @@ class Test_SCFSamplerBase: """Test :class:`sample_scf.base.SCFSamplerBase`.""" def setup_class(self): + # sampler initialization self.cls = base.SCFSamplerBase self.cls_args = () self.cls_kwargs = {} + self.cls_pot_kw = {} self.expected_rvs = { 0: dict(r=0.548813503927, theta=1.021982822867 * u.rad, phi=0.548813503927 * u.rad), @@ -159,42 +192,35 @@ def setup_class(self): ), } - # /def - - @pytest.fixture(autouse=True, scope="class") + @pytest.fixture() def sampler(self, potentials): """Set up r, theta, & phi sampler.""" sampler = self.cls(potentials, *self.cls_args, **self.cls_kwargs) - sampler._rsampler = rvtestsampler(potentials) - sampler._thetasampler = rvtestsampler(potentials) - sampler._phisampler = rvtestsampler(potentials) + sampler._r_distribution = rvtestsampler(potentials) + sampler._theta_distribution = rvtestsampler(potentials) + sampler._phi_distribution = rvtestsampler(potentials) return sampler - # /def - # =============================================================== # Method Tests - def test_rsampler(self, sampler): + def test_init(self, sampler, potentials): + assert sampler._potential is potentials + + def test_r_distribution_property(self, sampler): """Test :meth:`sample_scf.base.SCFSamplerBase.rsampler`.""" assert isinstance(sampler.rsampler, base.rv_potential) - # /def - - def test_thetasampler(self, sampler): + def test_theta_distribution_property(self, sampler): """Test :meth:`sample_scf.base.SCFSamplerBase.thetasampler`.""" assert isinstance(sampler.thetasampler, base.rv_potential) - # /def - - def test_phisampler(self, sampler): + def test_phi_distribution_property(self, sampler): """Test :meth:`sample_scf.base.SCFSamplerBase.phisampler`.""" assert isinstance(sampler.phisampler, base.rv_potential) - # /def - @pytest.mark.parametrize( "r, theta, phi, expected", [ @@ -205,68 +231,42 @@ def test_phisampler(self, sampler): ) def test_cdf(self, sampler, r, theta, phi, expected): """Test :meth:`sample_scf.base.SCFSamplerBase.cdf`.""" - assert np.allclose(sampler.cdf(r, theta, phi), expected, atol=1e-16) + cdf = sampler.cdf(r, theta, phi) + assert np.allclose(cdf, expected, atol=1e-16) - # /def + # also test shape + assert tuple(np.atleast_1d(np.squeeze((*np.shape(r), 3)))) == cdf.shape @pytest.mark.parametrize( - "id, size, random", + "id, size, random, vectorized", [ - (0, None, 0), - (1, 1, 0), - (2, 4, 4), + (0, None, 0, True), + (0, None, 0, False), + (1, 1, 0, True), + (1, 1, 0, False), + (2, 4, 4, True), + (2, 4, 4, False), ], ) - def test_rvs(self, sampler, id, size, random): + def test_rvs(self, sampler, id, size, random, vectorized): """Test :meth:`sample_scf.base.SCFSamplerBase.rvs`.""" - samples = sampler.rvs(size=size, random_state=random) + samples = sampler.rvs(size=size, random_state=random, vectorized=vectorized) sce = coord.PhysicsSphericalRepresentation(**self.expected_rvs[id]) assert_allclose(samples.r, sce.r, atol=1e-16) assert_allclose(samples.theta.value, sce.theta.value, atol=1e-16) assert_allclose(samples.phi.value, sce.phi.value, atol=1e-16) - # /def - - # =============================================================== - # Time Scaling Tests - - # =============================================================== - # Image tests - - -# /class - -class SCFSamplerTestBase(Test_SCFSamplerBase): +class SCFSamplerTestBase(Test_SCFSamplerBase, metaclass=abc.ABCMeta): + @abc.abstractmethod def setup_class(self): + pass - self.cls_pot_kw = {} - - # self.expected_rvs = { - # 0: dict(r=0.548813503927, theta=1.021982822867 * u.rad, phi=0.548813503927 * u.rad), - # 1: dict(r=0.548813503927, theta=1.021982822867 * u.rad, phi=0.548813503927 * u.rad), - # 2: dict( - # r=[0.9670298390136, 0.5472322491757, 0.9726843599648, 0.7148159936743], - # theta=[0.603766487781, 1.023564077619, 0.598111966830, 0.855980333120] * u.rad, - # phi=[0.9670298390136, 0.547232249175, 0.9726843599648, 0.7148159936743] * u.rad, - # ), - # } - - # /def - - @pytest.fixture(autouse=True, scope="class") + @pytest.fixture() def sampler(self, potentials): """Set up r, theta, phi sampler.""" kw = {**self.cls_kwargs, **self.cls_pot_kw.get(potentials, {})} sampler = self.cls(potentials, *self.cls_args, **kw) return sampler - - # /def - - -# /class - -############################################################################## -# END diff --git a/sample_scf/tests/test_conftest.py b/sample_scf/tests/test_conftest.py new file mode 100644 index 0000000..e5940b6 --- /dev/null +++ b/sample_scf/tests/test_conftest.py @@ -0,0 +1,120 @@ +# -*- coding: utf-8 -*- + +"""Testing :mod:`~sample_scf.conftest`. + +Even the test source should be tested. +In particular, the potential fixtures need confirmation that the SCF form +matches the theoretical, within tolerances. +""" + +__all__ = [ + "Test_ClassName", + "test_function", +] + + +############################################################################## +# IMPORTS + +# BUILT-IN +import abc + +# THIRD PARTY +import numpy as np +import pytest + +# LOCAL +from sample_scf import conftest + +############################################################################## +# PARAMETERS + + +############################################################################## +# TESTS +############################################################################## + + +class PytestPotential(metaclass=abc.ABCMeta): + """Test a Pytest Potential.""" + + @classmethod + @abc.abstractmethod + def setup_class(self): + """Setup fixtures for testing.""" + self.R = np.linspace(0.0, 3.0, num=1001) + self.atol = 1e-6 + self.restrict_ind = np.ones(1001, dtype=bool) + + @pytest.fixture(scope="class") + @abc.abstractmethod + def scf_potential(self): + """The `galpy.potential.SCFPotential` from a `pytest.fixture`.""" + return + + def compare_to_theory(self, theory, scf, atol=1e-6): + # test that where theory is finite they match and where it's infinite, + # the scf is NaN + fnt = ~np.isinf(theory) + ind = self.restrict_ind & fnt + + assert np.allclose(theory[ind], scf[ind], atol=atol) + assert np.all(np.isnan(scf[~fnt])) + + # =============================================================== + # sanity checks + + def test_df(self): + assert self.df._pot is self.theory + + # =============================================================== + + def test_density_along_Rz_equality(self, scf_potential): + theory = self.theory.dens(self.R, self.R) + scf = scf_potential.dens(self.R, self.R) + self.compare_to_theory(theory, scf, atol=self.atol) + + @pytest.mark.parametrize("z", [0, 10, 15]) + def test_density_at_z(self, scf_potential, z): + theory = self.theory.dens(self.R, z) + scf = scf_potential.dens(self.R, z) + self.compare_to_theory(theory, scf, atol=self.atol) + + +# ------------------------------------------------------------------- + + +class Test_hernquist_scf_potential(PytestPotential): + @classmethod + def setup_class(self): + """Setup fixtures for testing.""" + super().setup_class() + + self.theory = conftest.hernquist_potential + self.df = conftest.hernquist_df + + @pytest.fixture(scope="class") + def scf_potential(self, hernquist_scf_potential): + """The `galpy.potential.SCFPotential` from a `pytest.fixture`.""" + return hernquist_scf_potential + + +# ------------------------------------------------------------------- + + +class Test_nfw_scf_potential(PytestPotential): + @classmethod + def setup_class(self): + """Setup fixtures for testing.""" + super().setup_class() + + self.theory = conftest.nfw_potential + self.df = conftest.nfw_df + + self.atol = 1e-2 + self.restrict_ind[:18] = False # skip some of the inner ones + + @pytest.fixture(scope="class") + def scf_potential(self, nfw_scf_potential): + """The `galpy.potential.SCFPotential` from a `pytest.fixture`.""" + return nfw_scf_potential diff --git a/sample_scf/tests/test_core.py b/sample_scf/tests/test_core.py index dbd83a8..07db811 100644 --- a/sample_scf/tests/test_core.py +++ b/sample_scf/tests/test_core.py @@ -8,41 +8,64 @@ # THIRD PARTY import astropy.units as u +import numpy as np +import pytest # LOCAL -from .test_base import Test_SCFSamplerBase as SCFSamplerBaseTests +from .test_base import SCFSamplerTestBase from .test_interpolated import pgrid, rgrid, tgrid -from sample_scf import core +from sample_scf import conftest, core ############################################################################## # TESTS ############################################################################## -class Test_SCFSampler(SCFSamplerBaseTests): +class Test_SCFSampler(SCFSamplerTestBase): """Test :class:`sample_scf.core.SCFSample`.""" + @pytest.fixture() + def sampler(self, potentials): + """Set up r, theta, phi sampler.""" + kw = {**self.cls_kwargs, **self.cls_pot_kw.get(potentials, {})} + sampler = self.cls(potentials, *self.cls_args, **kw) + + return sampler + def setup_class(self): super().setup_class(self) self.cls = core.SCFSampler self.cls_args = ("interp",) # TODO! iterate over this self.cls_kwargs = dict(rgrid=rgrid, thetagrid=tgrid, phigrid=pgrid) + self.cls_pot_kw = {} + # TODO! make sure these are right! self.expected_rvs = { - 0: dict(r=0.548813503927, theta=1.021982822867 * u.rad, phi=0.548813503927 * u.rad), - 1: dict(r=0.548813503927, theta=1.021982822867 * u.rad, phi=0.548813503927 * u.rad), + 0: dict(r=2.8473287899985, theta=1.473013568997 * u.rad, phi=3.4482969442579 * u.rad), + 1: dict(r=2.8473287899985, theta=1.473013568997 * u.rad, phi=3.4482969442579 * u.rad), 2: dict( - r=[0.9670298390136, 0.5472322491757, 0.9726843599648, 0.7148159936743], - theta=[0.603766487781, 1.023564077619, 0.598111966830, 0.855980333120] * u.rad, - phi=[0.9670298390136, 0.547232249175, 0.9726843599648, 0.7148159936743] * u.rad, + r=[55.79997672576021, 2.831600636133138, 66.85343958872159, 5.435971037191061], + theta=[0.3651795356642, 1.476190768304, 0.3320725154563, 1.126711132070] * u.rad, + phi=[6.076027676095, 3.438361627636, 6.11155607905, 4.491321348792] * u.rad, ), } - # /def + # =============================================================== + # Method Tests + @pytest.mark.parametrize( + "r, theta, phi, expected", + [ + (0, 0, 0, [0, 0.5, 0]), + (1, 0, 0, [0.2505, 0.5, 0]), + ([0, 1], [0, 0], [0, 0], [[0, 0.5, 0], [0.2505, 0.5, 0]]), + ], + ) + def test_cdf(self, sampler, r, theta, phi, expected): + """Test :meth:`sample_scf.base.SCFSamplerBase.cdf`.""" + cdf = sampler.cdf(r, theta, phi) + assert np.allclose(cdf, expected, atol=1e-16) -# /class - -############################################################################## -# END + # also test shape + assert tuple(np.atleast_1d(np.squeeze((*np.shape(r), 3)))) == cdf.shape diff --git a/sample_scf/tests/test_init.py b/sample_scf/tests/test_init.py index 51460d6..30e598f 100644 --- a/sample_scf/tests/test_init.py +++ b/sample_scf/tests/test_init.py @@ -2,17 +2,16 @@ """Some basic tests.""" -__all__ = [ - "test_expected_imports", -] - - ############################################################################## # IMPORTS # BUILT-IN import inspect +# LOCAL +import sample_scf +from sample_scf import core, exact, interpolated + ############################################################################## # TESTS ############################################################################## @@ -20,25 +19,11 @@ def test_expected_imports(): """Test can import expected modules and objects.""" - # LOCAL - import sample_scf - from sample_scf import core, exact, interpolated - assert inspect.ismodule(sample_scf) assert inspect.ismodule(core) assert inspect.ismodule(exact) assert inspect.ismodule(interpolated) assert sample_scf.SCFSampler is core.SCFSampler - assert sample_scf.SCFSamplerExact is exact.SCFSampler - assert sample_scf.SCFSamplerInterp is interpolated.SCFSampler - - -# /def - - -# ------------------------------------------------------------------- - - -############################################################################## -# END + assert sample_scf.ExactSCFSampler is exact.SCFSampler + assert sample_scf.InterpolatedSCFSampler is interpolated.SCFSampler diff --git a/sample_scf/tests/test_utils.py b/sample_scf/tests/test_utils.py index c8b01d7..46bb607 100644 --- a/sample_scf/tests/test_utils.py +++ b/sample_scf/tests/test_utils.py @@ -46,8 +46,6 @@ def test_scalar_input(self, r, expected, warns): with pytest.warns(warns) if warns is not False else contextlib.nullcontext(): assert_allclose(zeta_of_r(r), expected) - # /def - @pytest.mark.parametrize( "r, expected", [ @@ -60,18 +58,11 @@ def test_array_input(self, r, expected): with pytest.warns(RuntimeWarning): assert_allclose(zeta_of_r(r), expected) - # /def - @pytest.mark.parametrize("r", [0, 1, np.inf, [0, 1, np.inf]]) def test_roundtrip(self, r): """Test zeta and r round trip. Note that Quantities don't round trip.""" assert_allclose(r_of_zeta(zeta_of_r(r)), r) - # /def - - -# /class - # ------------------------------------------------------------------- @@ -99,8 +90,6 @@ def test_scalar_input(self, zeta, expected, warns): with pytest.warns(warns) if warns is not False else contextlib.nullcontext(): assert_allclose(r_of_zeta(zeta), expected) - # /def - @pytest.mark.parametrize( "zeta, expected, warns", [ @@ -112,8 +101,6 @@ def test_array_input(self, zeta, expected, warns): with pytest.warns(warns) if warns is not False else contextlib.nullcontext(): assert_allclose(r_of_zeta(zeta), expected) - # /def - @pytest.mark.parametrize( "zeta, expected, unit", [ @@ -126,18 +113,11 @@ def test_unit_input(self, zeta, expected, unit): """Test when input units.""" assert_allclose(r_of_zeta(zeta, unit=unit), expected) - # /def - @pytest.mark.parametrize("zeta", [-1, 0, 1, [-1, 0, 1]]) def test_roundtrip(self, zeta): """Test zeta and r round trip. Note that Quantities don't round trip.""" assert_allclose(zeta_of_r(r_of_zeta(zeta)), zeta) - # /def - - -# /class - # ------------------------------------------------------------------- @@ -162,17 +142,11 @@ class Test_x_of_theta: def test_x_of_theta(self, theta, expected): assert_allclose(x_of_theta(theta), expected, atol=1e-16) - # /def - @pytest.mark.parametrize("theta", [-np.pi / 2, 0, np.pi / 2, [-np.pi / 2, 0, np.pi / 2]]) def test_roundtrip(self, theta): """Test theta and x round trip. Note that Quantities don't round trip.""" assert_allclose(theta_of_x(x_of_theta(theta << u.rad)), theta) - # /def - - -# /class # ------------------------------------------------------------------- @@ -192,8 +166,6 @@ class Test_theta_of_x: def test_theta_of_x(self, x, expected): assert_allclose(theta_of_x(x), expected) - # /def - @pytest.mark.parametrize( "x, expected, unit", [ @@ -206,15 +178,11 @@ def test_unit_input(self, x, expected, unit): """Test when input units.""" assert_allclose(theta_of_x(x, unit=unit), expected) - # /def - @pytest.mark.parametrize("x", [-1, 0, 1, [-1, 0, 1]]) def test_roundtrip(self, x): """Test x and theta round trip. Note that Quantities don't round trip.""" assert_allclose(x_of_theta(theta_of_x(x)), x, atol=1e-16) - # /def - # ------------------------------------------------------------------- @@ -234,16 +202,10 @@ def test_hernquist(self, hernquist_scf_potential, r, expected): assert np.isclose(Qls[0], expected) assert_allclose(Qls[1:], 0) - # /def - @pytest.mark.skip("TODO!") def test_nfw(self, nfw_scf_potential): assert False - # /def - - -# /class # ------------------------------------------------------------------- @@ -276,7 +238,7 @@ class Test_phiRSms: ], ) def test_phiRSms_hernquist(self, hernquist_scf_potential, r, theta, expected): - Rm, Sm = phiRSms(hernquist_scf_potential, r, theta) + Rm, Sm = phiRSms(hernquist_scf_potential, r, theta, warn=False) assert Rm.shape == Sm.shape assert Rm.shape == (1, 1, 6) assert_allclose(Rm[0, 0, 1:], expected[0], atol=1e-16) @@ -285,11 +247,3 @@ def test_phiRSms_hernquist(self, hernquist_scf_potential, r, theta, expected): if theta == 0 and r != 0: assert Rm[0, 0, 0] != 0 assert Sm[0, 0, 0] == 0 - - # /def - - -# /class - -############################################################################## -# END diff --git a/sample_scf/utils.py b/sample_scf/utils.py index 10b361d..5834261 100644 --- a/sample_scf/utils.py +++ b/sample_scf/utils.py @@ -1,10 +1,6 @@ # -*- coding: utf-8 -*- -"""**DOCSTRING**. - -Description. - -""" +"""Utility functions.""" ############################################################################## # IMPORTS @@ -13,8 +9,9 @@ # BUILT-IN import functools -import typing as T import warnings +from contextlib import nullcontext +from typing import Optional, Tuple, Union # THIRD PARTY import astropy.units as u @@ -22,10 +19,11 @@ import numpy.typing as npt from galpy.potential import SCFPotential from numpy import arange, arccos, array, atleast_1d, cos, divide, nan_to_num, pi, sqrt, stack, sum +from numpy.typing import ArrayLike from scipy.special import legendre, lpmn # LOCAL -from ._typing import NDArray64 +from ._typing import NDArrayF __all__ = [ "zeta_of_r", @@ -52,8 +50,9 @@ _difPls = (Pls[2:] - Pls[:-2]) / (2 * lrange[1:-1] + 1) -def difPls(x: T.Union[float, NDArray64], lmax: int) -> NDArray64: +def difPls(x: Union[float, NDArrayF], lmax: int) -> NDArrayF: # TODO? speed up + # TODO! the lmax = 1 case return array([dPl(x) for dPl in _difPls[:lmax]]) @@ -62,7 +61,7 @@ def difPls(x: T.Union[float, NDArray64], lmax: int) -> NDArray64: ############################################################################## -def zeta_of_r(r: T.Union[u.Quantity, NDArray64]) -> NDArray64: +def zeta_of_r(r: Union[u.Quantity, NDArrayF]) -> NDArrayF: r""":math:`\zeta = \frac{r - 1}{r + 1}` Map the half-infinite domain [0, infinity) -> [-1, 1] @@ -77,18 +76,15 @@ def zeta_of_r(r: T.Union[u.Quantity, NDArray64]) -> NDArray64: zeta : ndarray With shape (len(r),) """ - ra: NDArray64 = r.value if isinstance(r, u.Quantity) else np.asanyarray(r) - zeta: NDArray64 = nan_to_num(divide(ra - 1, ra + 1), nan=1) + ra: NDArrayF = np.array(r, dtype=np.float64, copy=False) + zeta: NDArrayF = nan_to_num(divide(ra - 1, ra + 1), nan=1) return zeta -# /def - - def r_of_zeta( - zeta: npt.ArrayLike, - unit: T.Optional[u.UnitBase] = None, -) -> T.Union[u.Quantity, NDArray64]: + zeta: ArrayLike, + unit: Optional[u.UnitBase] = None, +) -> Union[u.Quantity, NDArrayF]: r""":math:`r = \frac{1 + \zeta}{1 - \zeta}` Map back to the half-infinite domain [0, infinity) <- [-1, 1] @@ -107,20 +103,17 @@ def r_of_zeta( r = atleast_1d(divide(1 + z, 1 - z)) r[r < 0] = 0 # correct small errors - rq: T.Union[NDArray64, u.Quantity] + rq: Union[NDArrayF, u.Quantity] rq = r << unit if unit is not None else r return rq -# /def - - # ------------------------------------------------------------------- @functools.singledispatch -def x_of_theta(theta: npt.ArrayLike) -> NDArray64: +def x_of_theta(theta: ArrayLike) -> NDArrayF: r""":math:`x = \cos{\theta}`. Parameters @@ -133,12 +126,12 @@ def x_of_theta(theta: npt.ArrayLike) -> NDArray64: x : ndarray[float] :math:`x \in [-1, 1]` """ - x: NDArray64 = cos(pi / 2 - np.asanyarray(theta)) + x: NDArrayF = cos(pi / 2 - np.asanyarray(theta)) return x @x_of_theta.register -def _(theta: u.Quantity) -> NDArray64: +def _(theta: u.Quantity) -> NDArrayF: r""":math:`x = \cos{\theta}`. Parameters @@ -149,17 +142,14 @@ def _(theta: u.Quantity) -> NDArray64: ------- x : float or ndarray """ - x: NDArray64 = cos(pi / 2 - theta.to_value(u.rad)) + x: NDArrayF = cos(pi / 2 - theta.to_value(u.rad)) return x -# /def - - def theta_of_x( - x: npt.ArrayLike, - unit: T.Optional[u.UnitBase] = None, -) -> T.Union[NDArray64, u.Quantity]: + x: ArrayLike, + unit: Optional[u.UnitBase] = None, +) -> Union[NDArrayF, u.Quantity]: r""":math:`\theta = \cos^{-1}{x}`. Parameters @@ -171,9 +161,9 @@ def theta_of_x( ------- theta : float or ndarray """ - th: NDArray64 = pi / 2 - arccos(x) + th: NDArrayF = pi / 2 - arccos(x) - theta: T.Union[NDArray64, u.Quantity] + theta: Union[NDArrayF, u.Quantity] if unit is not None: theta = u.Quantity(th, u.rad).to(unit) else: @@ -182,13 +172,10 @@ def theta_of_x( return theta -# /def - - # ------------------------------------------------------------------- -def thetaQls(pot: SCFPotential, r: T.Union[float, NDArray64]) -> NDArray64: +def thetaQls(pot: SCFPotential, r: Union[float, NDArrayF]) -> NDArrayF: r""" Radial sums for inclination weighting factors. The weighting factors measure perturbations from spherical symmetry. @@ -205,6 +192,9 @@ def thetaQls(pot: SCFPotential, r: T.Union[float, NDArray64]) -> NDArray64: Ql : ndarray """ + # with warnings.catch_warnings(): # TODO! diagnose RuntimeWarning + # warnings.filterwarnings("ignore", category=RuntimeWarning, message="(^invalid value)") + # compute the r-dependent coefficient matrix $\tilde{\rho}$ # (R, N, L) nmax, lmax = pot._Acos.shape[:2] rs = atleast_1d(r) # need r to be array. @@ -218,22 +208,21 @@ def thetaQls(pot: SCFPotential, r: T.Union[float, NDArray64]) -> NDArray64: Qls = nan_to_num(sum(pot._Acos[None, :, :, 0] * rhoTilde, axis=1), nan=1) # (R, N, L) # remove extra dimensions, e.g. scalar 'r' - Ql: NDArray64 = Qls.squeeze() - return Ql + Ql: NDArrayF = Qls.squeeze() + return Ql -# /def # ------------------------------------------------------------------- def _pnts_phiRSms( - rhoTilde: NDArray64, - Acos: NDArray64, - Asin: NDArray64, - r: npt.ArrayLike, - theta: npt.ArrayLike, -) -> T.Tuple[NDArray64, NDArray64]: + rhoTilde: NDArrayF, + Acos: NDArrayF, + Asin: NDArrayF, + r: ArrayLike, + theta: ArrayLike, +) -> Tuple[NDArrayF, NDArrayF]: """Radial and inclination sums for azimuthal weighting factors. Parameters @@ -247,9 +236,15 @@ def _pnts_phiRSms( ------- Rm, Sm : (R, T, L) ndarray Azimuthal weighting factors. + + Warns + ----- + RuntimeWarning + For invalid values (inf addition -> Nan). + For overflow encountered related to inf and 0 division. """ # need r and theta to be arrays. Maintains units. - tgrid: NDArray64 = atleast_1d(theta) + tgrid: NDArrayF = atleast_1d(theta) # transform to correct shape for vectorized computation x = x_of_theta(tgrid) # (R/T,) @@ -260,12 +255,9 @@ def _pnts_phiRSms( RhoT = rhoTilde[:, :, :, None] # (R/T, N, L, {L}) # legendre polynomials - with warnings.catch_warnings(): # there's a RuntimeWarning to ignore - warnings.simplefilter("ignore") - lps = lpmn_vec(lmax - 1, lmax - 1, x)[0] # drop deriv + lps = lpmn_vec(lmax - 1, lmax - 1, x)[0] # drop deriv - PP = np.stack(lps, axis=0).astype(float)[:, None, :, :] - # (R/T, {N}, L, L) + PP = np.stack(lps, axis=0).astype(float)[:, None, :, :] # (R/T, {N}, L, L) # full R & S matrices RSnlm = RhoT * sqrt(1 - Xs ** 2) * PP # (R/T, N, L, L) @@ -286,16 +278,13 @@ def _pnts_phiRSms( return Rm, Sm -# /def - - def _grid_phiRSms( - rhoTilde: NDArray64, - Acos: NDArray64, - Asin: NDArray64, - r: npt.ArrayLike, - theta: npt.ArrayLike, -) -> T.Tuple[NDArray64, NDArray64]: + rhoTilde: NDArrayF, + Acos: NDArrayF, + Asin: NDArrayF, + r: ArrayLike, + theta: ArrayLike, +) -> Tuple[NDArrayF, NDArrayF]: """Radial and inclination sums for azimuthal weighting factors. Parameters @@ -309,36 +298,50 @@ def _grid_phiRSms( ------- Rm, Sm : (R, T, L) ndarray Azimuthal weighting factors. + + Warns + ----- + RuntimeWarning + For invalid values (inf addition -> Nan). + For overflow encountered related to inf and 0 division. """ # need r and theta to be arrays. Maintains units. - tgrid: NDArray64 = atleast_1d(theta) + tgrid: NDArrayF = atleast_1d(theta) + rgrid: NDArrayF = atleast_1d(r) # transform to correct shape for vectorized computation x = x_of_theta(tgrid) # (T,) Xs = x[None, :, None, None, None] # ({R}, X, {N}, {L}, {L}) - # compute the r-dependent coefficient matrix $\tilde{\rho}$ + # format the r-dependent coefficient matrix $\tilde{\rho}$ nmax, lmax = Acos.shape[:2] RhoT = rhoTilde[:, None, :, :, None] # (R, {X}, N, L, {L}) - # legendre polynomials - with warnings.catch_warnings(): # there's a RuntimeWarning to ignore - warnings.simplefilter("ignore") - lps = lpmn_vec(lmax - 1, lmax - 1, x)[0] # drop deriv + # legendre polynomials # raises RuntimeWarnings + lps = lpmn_vec(lmax - 1, lmax - 1, x)[0] # drop deriv PP = np.stack(lps, axis=0).astype(float)[None, :, None, :, :] # ({R}, X, {N}, L, L) - # full R & S matrices + # full R & S matrices # raises RuntimeWarnings RSnlm = RhoT * sqrt(1 - Xs ** 2) * PP # (R, X, N, L, L) + # for r=0, rhoT can be +/- inf. If added / multiplied by 0 this will be NaN + # we can safely set this to 0 if rhoT's infinities cancel + i0 = rgrid == 0 + if not np.sum(np.nan_to_num(RhoT[i0, 0, :, 0, 0], posinf=1, neginf=-1)) == 0: + # note: this if statement works even if ind0 is all False + warnings.warn("RhoT have non-cancelling infinities at r==0") + else: + RSnlm[i0, 0, :, 0, :] = np.nan_to_num(RSnlm[i0, 0, :, 0, :], copy=False) - # n-sum # (R, X, L, L) + # n-sum # (R, X, L, L) # raises RuntimeWarnings Rlm = sum(Acos[None, None, :, :, :] * RSnlm, axis=2) Slm = sum(Asin[None, None, :, :, :] * RSnlm, axis=2) # fix adding +/- inf -> NaN. happens when r=0. - idx = np.all(np.isnan(Rlm[:, 0, 0, :]), axis=-1) - Rlm[idx, 0, 0, :] = nan_to_num(Rlm[idx, 0, 0, :]) - Slm[idx, 0, 0, :] = nan_to_num(Slm[idx, 0, 0, :]) + # the check for cancelling infinities is done above. + # the [X]=0 case is handled above as well. + Rlm[i0, 1:, 0, :] = nan_to_num(Rlm[i0, 1:, 0, :], copy=False) + Slm[i0, 1:, 0, :] = nan_to_num(Slm[i0, 1:, 0, :], copy=False) # m-sum # (R, X, L) sumidx = range(Rlm.shape[2]) @@ -348,15 +351,13 @@ def _grid_phiRSms( return Rm, Sm -# /def - - def phiRSms( pot: SCFPotential, - r: npt.ArrayLike, - theta: npt.ArrayLike, + r: ArrayLike, + theta: ArrayLike, grid: bool = True, -) -> T.Tuple[NDArray64, NDArray64]: + warn: bool = True, +) -> Tuple[NDArrayF, NDArrayF]: r"""Radial and inclination sums for azimuthal weighting factors. .. math:: @@ -379,8 +380,8 @@ def phiRSms( Azimuthal weighting factors. Shape (len(r), len(theta), L). """ # need r and theta to be arrays. The extra dimensions will be 'squeeze'd. - rgrid = atleast_1d(r) - tgrid = atleast_1d(theta) + rgrid: npt.NDArray = atleast_1d(r) + tgrid: npt.NDArray = atleast_1d(theta) # compute the r-dependent coefficient matrix $\tilde{\rho}$ # (R, N, L) nmax: int @@ -394,14 +395,16 @@ def phiRSms( ) # pass to actual calculator, which takes the matrices and r, theta grids. - if grid: - Rm, Sm = _grid_phiRSms(rhoTilde, pot._Acos, pot._Asin, rgrid, tgrid) - else: - Rm, Sm = _pnts_phiRSms(rhoTilde, pot._Acos, pot._Asin, rgrid, tgrid) - return Rm, Sm - + with warnings.catch_warnings() if not warn else nullcontext(): + if not warn: + warnings.filterwarnings( + "ignore", + category=RuntimeWarning, + message="(^invalid value)|(^overflow encountered)", + ) + if grid: + Rm, Sm = _grid_phiRSms(rhoTilde, pot._Acos, pot._Asin, rgrid, tgrid) + else: + Rm, Sm = _pnts_phiRSms(rhoTilde, pot._Acos, pot._Asin, rgrid, tgrid) -# /def - -############################################################################## -# END + return Rm, Sm diff --git a/setup.py b/setup.py index 88b86e2..17ad75e 100755 --- a/setup.py +++ b/setup.py @@ -13,6 +13,8 @@ from extension_helpers import get_extensions from setuptools import setup +# from mypyc.build import mypycify + # First provide helpful messages if contributors try and run legacy commands # for tests or docs. @@ -77,10 +79,23 @@ version = '{version}' """.lstrip() +# # TODO! model after https://github.com/python/mypy/blob/master/setup.py +# mypyc_targets = [ +# os.path.join("sample_scf", x) +# for x in ("__init__.py", "base.py", "core.py", "utils.py", "interpolated.py", +# "exact.py") +# ] +# # The targets come out of file system apis in an unspecified +# # order. Sort them so that the mypyc output is deterministic. +# mypyc_targets.sort() + setup( use_scm_version={ "write_to": os.path.join("sample_scf", "version.py"), "write_to_template": VERSION_TEMPLATE, }, ext_modules=get_extensions(), + # name="sample_scf", + # packages=["sample_scf"], + # ext_modules=mypycify(["--disallow-untyped-defs", *mypyc_targets]), ) From 07463763b8cf27b4ad4daf594290057ef6aec254 Mon Sep 17 00:00:00 2001 From: nstarman Date: Thu, 7 Apr 2022 16:29:10 -0400 Subject: [PATCH 29/31] fix for inclination sample Signed-off-by: nstarman --- docs/conf.py | 2 +- docs/index.rst | 14 + pyproject.toml | 2 +- sample_scf/__init__.py | 8 +- sample_scf/_astropy_init.py | 2 +- sample_scf/_old/cdf_strategy.py | 139 +++++ sample_scf/_typing.py | 5 +- sample_scf/base.py | 331 ----------- sample_scf/base_multivariate.py | 228 ++++++++ sample_scf/base_univariate.py | 511 +++++++++++++++++ sample_scf/cdf_strategy.py | 116 ---- sample_scf/conftest.py | 54 +- sample_scf/core.py | 31 +- sample_scf/exact/__init__.py | 15 + .../exact/{rvs_azimuth.py => azimuth.py} | 25 +- sample_scf/exact/core.py | 17 +- sample_scf/exact/inclination.py | 212 +++++++ sample_scf/exact/{rvs_radial.py => radial.py} | 20 +- sample_scf/exact/rvs_inclination.py | 159 ------ sample_scf/exact/tests/__init__.py | 5 + sample_scf/exact/tests/test_core.py | 135 +++++ sample_scf/exact/tests/test_exact.py | 9 +- sample_scf/exact/tests/test_utils.py | 84 +++ sample_scf/interpolated/__init__.py | 7 + .../{rvs_azimuth.py => azimuth.py} | 119 ++-- sample_scf/interpolated/core.py | 106 ++-- .../{rvs_inclination.py => inclination.py} | 129 +++-- sample_scf/interpolated/radial.py | 106 ++++ sample_scf/interpolated/rvs_radial.py | 92 --- sample_scf/interpolated/tests/__init__.py | 5 + .../interpolated/tests/test_interpolated.py | 33 +- sample_scf/representation.py | 497 +++++++++++++++++ sample_scf/tests/base.py | 144 +++++ .../baseline_images/test_phi_cdf_plot.png | Bin 0 -> 28168 bytes .../test_phi_sampling_plot.png | Bin 0 -> 22866 bytes .../tests/baseline_images/test_r_cdf_plot.png | Bin 0 -> 32278 bytes .../baseline_images/test_r_sampling_plot.png | Bin 0 -> 26961 bytes .../baseline_images/test_theta_cdf_plot.png | Bin 0 -> 27748 bytes .../test_theta_sampling_plot.png | Bin 0 -> 24430 bytes sample_scf/tests/common.py | 63 --- sample_scf/tests/data/__init__.py | 14 + .../tests/data/data_Test_rv_potential.py | 14 + sample_scf/tests/data/nfw.npz | Bin 0 -> 10502 bytes sample_scf/tests/{ => data}/scf_coeffs.npz | Bin sample_scf/tests/data/scf_nfw_coeffs.npz | Bin 0 -> 184822 bytes sample_scf/tests/data/scf_tnfw_coeffs.npz | Bin 0 -> 184822 bytes sample_scf/tests/test_base.py | 272 --------- sample_scf/tests/test_base_multivariate.py | 214 +++++++ sample_scf/tests/test_base_univariate.py | 254 +++++++++ sample_scf/tests/test_conftest.py | 230 ++++---- sample_scf/tests/test_core.py | 50 +- sample_scf/tests/test_init.py | 15 +- sample_scf/tests/test_representation.py | 524 ++++++++++++++++++ sample_scf/tests/test_utils.py | 249 --------- sample_scf/utils.py | 410 -------------- setup.py | 2 +- 56 files changed, 3541 insertions(+), 2132 deletions(-) create mode 100644 docs/index.rst create mode 100644 sample_scf/_old/cdf_strategy.py delete mode 100644 sample_scf/base.py create mode 100644 sample_scf/base_multivariate.py create mode 100644 sample_scf/base_univariate.py delete mode 100644 sample_scf/cdf_strategy.py rename sample_scf/exact/{rvs_azimuth.py => azimuth.py} (90%) create mode 100644 sample_scf/exact/inclination.py rename sample_scf/exact/{rvs_radial.py => radial.py} (79%) delete mode 100644 sample_scf/exact/rvs_inclination.py create mode 100644 sample_scf/exact/tests/__init__.py create mode 100644 sample_scf/exact/tests/test_core.py create mode 100644 sample_scf/exact/tests/test_utils.py rename sample_scf/interpolated/{rvs_azimuth.py => azimuth.py} (63%) rename sample_scf/interpolated/{rvs_inclination.py => inclination.py} (55%) create mode 100644 sample_scf/interpolated/radial.py delete mode 100644 sample_scf/interpolated/rvs_radial.py create mode 100644 sample_scf/interpolated/tests/__init__.py create mode 100644 sample_scf/representation.py create mode 100644 sample_scf/tests/base.py create mode 100644 sample_scf/tests/baseline_images/test_phi_cdf_plot.png create mode 100644 sample_scf/tests/baseline_images/test_phi_sampling_plot.png create mode 100644 sample_scf/tests/baseline_images/test_r_cdf_plot.png create mode 100644 sample_scf/tests/baseline_images/test_r_sampling_plot.png create mode 100644 sample_scf/tests/baseline_images/test_theta_cdf_plot.png create mode 100644 sample_scf/tests/baseline_images/test_theta_sampling_plot.png delete mode 100644 sample_scf/tests/common.py create mode 100644 sample_scf/tests/data/__init__.py create mode 100644 sample_scf/tests/data/data_Test_rv_potential.py create mode 100644 sample_scf/tests/data/nfw.npz rename sample_scf/tests/{ => data}/scf_coeffs.npz (100%) create mode 100644 sample_scf/tests/data/scf_nfw_coeffs.npz create mode 100644 sample_scf/tests/data/scf_tnfw_coeffs.npz delete mode 100644 sample_scf/tests/test_base.py create mode 100644 sample_scf/tests/test_base_multivariate.py create mode 100644 sample_scf/tests/test_base_univariate.py create mode 100644 sample_scf/tests/test_representation.py delete mode 100644 sample_scf/tests/test_utils.py delete mode 100644 sample_scf/utils.py diff --git a/docs/conf.py b/docs/conf.py index 301cdd9..3b3be34 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -25,7 +25,7 @@ # Thus, any C-extensions that are needed to build the documentation will *not* # be accessible, and the documentation will not build correctly. -# BUILT-IN +# STDLIB import datetime import os import sys diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 0000000..03db57d --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,14 @@ +Documentation +============= + +This is the documentation for sampleSCF. + +.. toctree:: + :maxdepth: 2 + + sample_scf/index.rst + +.. note:: The layout of this directory is simply a suggestion. To follow + traditional practice, do *not* edit this page, but instead place + all documentation for the package inside ``sample_scf/``. + You can follow this practice or choose your own layout. diff --git a/pyproject.toml b/pyproject.toml index f88b8ff..f2ebaa8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,7 +18,7 @@ sections = ["FUTURE", "STDLIB", "THIRDPARTY", "FIRSTPARTY", "LOCALFOLDER"] known_third_party = ["astropy", "extension_helpers", "galpy", "matplotlib", "numpy", "pytest", "scipy", "setuptools"] known_localfolder = "sample_scf" -import_heading_stdlib = "BUILT-IN" +import_heading_stdlib = "STDLIB" import_heading_thirdparty = "THIRD PARTY" import_heading_firstparty = "FIRST PARTY" import_heading_localfolder = "LOCAL" diff --git a/sample_scf/__init__.py b/sample_scf/__init__.py index 9bb0db0..ea0d866 100644 --- a/sample_scf/__init__.py +++ b/sample_scf/__init__.py @@ -6,5 +6,11 @@ from sample_scf.core import SCFSampler from sample_scf.exact import ExactSCFSampler from sample_scf.interpolated import InterpolatedSCFSampler +from sample_scf.representation import FiniteSphericalRepresentation -__all__ = ["SCFSampler", "ExactSCFSampler", "InterpolatedSCFSampler"] +__all__ = [ + "SCFSampler", + "ExactSCFSampler", + "InterpolatedSCFSampler", + "FiniteSphericalRepresentation", +] diff --git a/sample_scf/_astropy_init.py b/sample_scf/_astropy_init.py index d47f554..65e6525 100644 --- a/sample_scf/_astropy_init.py +++ b/sample_scf/_astropy_init.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Licensed under a 3-clause BSD style license - see LICENSE.rst -# BUILT-IN +# STDLIB import os __all__ = ["__version__", "test"] diff --git a/sample_scf/_old/cdf_strategy.py b/sample_scf/_old/cdf_strategy.py new file mode 100644 index 0000000..df463de --- /dev/null +++ b/sample_scf/_old/cdf_strategy.py @@ -0,0 +1,139 @@ +# -*- coding: utf-8 -*- + +""" +Deal with non-monotonic CDFs. +The problem can arise if the PDF (density field) ever dips negative because of +an incorrect solution to the SCF coefficients. E.g. when solving for the +coefficients from an analytic density profile. + +""" + +# __all__ = [] + + +############################################################################## +# IMPORTS + +from __future__ import annotations + +# STDLIB +import inspect +from abc import ABCMeta, abstractmethod +from typing import Any, Dict, Type, Union, cast + +# THIRD PARTY +import numpy as np +from astropy.utils.state import ScienceState + +# LOCAL +from sample_scf._typing import NDArrayF + +__all__ = ["get_strategy", "CDFStrategy", "NoCDFStrategy", "LinearInterpolateCDFStrategy"] + + +############################################################################## +# PARAMETERS + +CDF_STRATEGIES: Dict[Union[str, None], Type[CDFStrategy]] = {} + + +StrategyLike = Union[str, None, "CDFStrategy"] +"""Type variable describing.""" + +############################################################################## +# CODE +############################################################################## + + +def get_strategy(key: StrategyLike, /) -> CDFStrategy: + item: CDFStrategy + if isinstance(key, CDFStrategy): + item = key + elif key in CDF_STRATEGIES: + item = CDF_STRATEGIES[key]() + else: + raise ValueError + + return item + + +# ============================================================================ + + +class CDFStrategy(metaclass=ABCMeta): + def __init_subclass__(cls, key: str, **kwargs: Any) -> None: + super().__init_subclass__() + + CDF_STRATEGIES[key] = cls + + @classmethod + @abstractmethod + def apply(cls, cdf: NDArrayF, **kw: Any) -> NDArrayF: + """Apply CDF strategy. + + .. warning:: + operates in-place on numpy arrays + + Parameters + ---------- + cdf : array[float] + **kw : Any + Not used. + + Returns + ------- + cdf : array[float] + Modified in-place. + """ + + +class NoCDFStrategy(CDFStrategy, key=None): + @classmethod + def apply(cls, cdf: NDArrayF, **kw: Any) -> NDArrayF: + """ + + .. warning:: + operates in-place on numpy arrays + + """ + # find where cdf breaks monotonicity + notreal = np.where(np.diff(cdf) <= 0)[0] + 1 + # raise error if any breaks + if np.any(notreal): + msg = "cdf contains unreal elements " + msg += f"at index {kw['index']}" if "index" in kw else "" + raise ValueError(msg) + + +class LinearInterpolateCDFStrategy(CDFStrategy, key="linear"): + @classmethod + def apply(cls, cdf: NDArrayF, **kw: Any) -> NDArrayF: + """Apply linear interpolation. + + .. warning:: + + operates in-place on numpy arrays + + Parameters + ---------- + cdf : array[float] + **kw : Any + Not used. + + Returns + ------- + cdf : array[float] + Modified in-place. + """ + # Find where cdf breaks monotonicity, and the startpoint of each break. + notreal = np.where(np.diff(cdf) <= 0)[0] + 1 + breaks = np.where(np.diff(notreal) > 1)[0] + 1 + startnotreal = np.concatenate((notreal[:1], notreal[breaks])) + + # Loop over each start. Can't vectorize because they depend on each other. + for i in startnotreal: + i0 = i - 1 # before it dips negative + i1 = i0 + np.argmax(cdf[i0:] - cdf[i0] > 0) # start of net positive + cdf[i0 : i1 + 1] = np.linspace(cdf[i0], cdf[i1], num=i1 - i0 + 1, endpoint=True) + + return cdf diff --git a/sample_scf/_typing.py b/sample_scf/_typing.py index 077d45f..3c30243 100644 --- a/sample_scf/_typing.py +++ b/sample_scf/_typing.py @@ -3,7 +3,7 @@ """Custom typing.""" -# BUILT-IN +# STDLIB from typing import Union # THIRD PARTY @@ -13,3 +13,6 @@ RandomGenerator = Union[np.random.RandomState, np.random.Generator] RandomLike = Union[None, int, RandomGenerator] NDArrayF = NDArray[np.floating] + +# float array-like +FArrayLike = Union[float, NDArrayF] diff --git a/sample_scf/base.py b/sample_scf/base.py deleted file mode 100644 index 9287ae9..0000000 --- a/sample_scf/base.py +++ /dev/null @@ -1,331 +0,0 @@ -# -*- coding: utf-8 -*- - -"""Base class for sampling from an SCF Potential.""" - -############################################################################## -# IMPORTS - -from __future__ import annotations - -# BUILT-IN -from abc import ABCMeta -from typing import Optional, Tuple, Union - -# THIRD PARTY -import astropy.units as u -import numpy as np -from astropy.coordinates import PhysicsSphericalRepresentation -from astropy.utils.misc import NumpyRNGContext -from galpy.potential import SCFPotential -from numpy.typing import ArrayLike -from scipy._lib._util import check_random_state -from scipy.stats import rv_continuous - -# LOCAL -from sample_scf._typing import NDArrayF, RandomGenerator, RandomLike - -__all__ = [] - - -############################################################################## -# CODE -############################################################################## - - -class rv_potential(rv_continuous, metaclass=ABCMeta): - """ - Modified :class:`scipy.stats.rv_continuous` to use custom ``rvs`` methods. - Made by stripping down the original scipy implementation. - See :class:`scipy.stats.rv_continuous` for details. - - Parameters - ---------- - `rv_continuous` is a base class to construct specific distribution classes - and instances for continuous random variables. It cannot be used - directly as a distribution. - - Parameters - ---------- - potential : `galpy.potential.SCFPotential` - The potential from which to sample. - momtype : int, optional - The type of generic moment calculation to use: 0 for pdf, 1 (default) - for ppf. - a : float, optional - Lower bound of the support of the distribution, default is minus - infinity. - b : float, optional - Upper bound of the support of the distribution, default is plus - infinity. - xtol : float, optional - The tolerance for fixed point calculation for generic ppf. - badvalue : float, optional - The value in a result arrays that indicates a value that for which - some argument restriction is violated, default is np.nan. - name : str, optional - The name of the instance. This string is used to construct the default - example for distributions. - longname : str, optional - This string is used as part of the first line of the docstring returned - when a subclass has no docstring of its own. Note: `longname` exists - for backwards compatibility, do not use for new subclasses. - shapes : str, optional - The shape of the distribution. For example ``"m, n"`` for a - distribution that takes two integers as the two shape arguments for all - its methods. If not provided, shape parameters will be inferred from - the signature of the private methods, ``_pdf`` and ``_cdf`` of the - instance. - extradoc : str, optional, deprecated - This string is used as the last part of the docstring returned when a - subclass has no docstring of its own. Note: `extradoc` exists for - backwards compatibility, do not use for new subclasses. - seed : {None, int, `numpy.random.Generator`, - `numpy.random.RandomState`}, optional - - If `seed` is None (or `np.random`), the `numpy.random.RandomState` - singleton is used. - If `seed` is an int, a new ``RandomState`` instance is used, - seeded with `seed`. - If `seed` is already a ``Generator`` or ``RandomState`` instance then - that instance is used. - """ - - _random_state: RandomGenerator - _potential: SCFPotential - _nmax: int - _lmax: int - - def __init__( - self, - potential: SCFPotential, - momtype: int = 1, - a: Optional[float] = None, - b: Optional[float] = None, - xtol: float = 1e-14, - badvalue: Optional[float] = None, - name: Optional[str] = None, - longname: Optional[str] = None, - shapes: Optional[Tuple[int, ...]] = None, - extradoc: Optional[str] = None, - seed: Optional[int] = None, - ): - super().__init__( - momtype=momtype, - a=a, - b=b, - xtol=xtol, - badvalue=badvalue, - name=name, - longname=longname, - shapes=shapes, - extradoc=extradoc, - seed=seed, - ) - - if not isinstance(potential, SCFPotential): - raise TypeError( - f"potential must be , not {type(potential)}", - ) - self._potential = potential - self._nmax, self._lmax = potential._Acos.shape[:2] - - @property - def potential(self) -> SCFPotential: - """The potential from which to sample""" - return self._potential - - @property - def nmax(self) -> int: - return self._nmax - - @property - def lmax(self) -> int: - return self._lmax - - def rvs( - self, - *args: Union[np.floating, ArrayLike], - size: Optional[int] = None, - random_state: RandomLike = None, - ) -> NDArrayF: - """Random variate sampler. - - Parameters - ---------- - *args - size : int or None (optional, keyword-only) - Size of random variates to generate. - random_state : int, `~numpy.random.RandomState`, or None (optional, keyword-only) - If seed is None (or numpy.random), the `numpy.random.RandomState` - singleton is used. If seed is an int, a new RandomState instance is - used, seeded with seed. If seed is already a Generator or - RandomState instance then that instance is used. - - Returns - ------- - ndarray[float] - Shape 'size'. - """ - # copied from `scipy` - # extra gymnastics needed for a custom random_state - rndm: RandomGenerator - if random_state is not None: - random_state_saved = self._random_state - rndm = check_random_state(random_state) - else: - rndm = self._random_state - - # go directly to `_rvs` - vals: NDArrayF = self._rvs(*args, size=size, random_state=rndm) - - # copied from `scipy` - # do not forget to restore the _random_state - if random_state is not None: - self._random_state = random_state_saved - - return vals.squeeze() - - -# ------------------------------------------------------------------- - - -class r_distribution_base(rv_potential): - """Sample radial coordinate from an SCF potential. - - The potential must have a convergent mass function. - - Parameters - ---------- - potential : `galpy.potential.SCFPotential` - """ - - pass - - -############################################################################## - - -class SCFSamplerBase(metaclass=ABCMeta): - """Sample SCF in spherical coordinates. - - The coordinate system is: - - r : [0, infinity) - - theta : [-pi/2, pi/2] (positive at the North pole) - - phi : [0, 2pi) - - Parameters - ---------- - pot : `galpy.potential.SCFPotential` - """ - - _potential: SCFPotential - _r_distribution: rv_potential - _theta_distribution: rv_potential - _phi_distribution: rv_potential - - def __init__(self, potential: SCFPotential): - potential.turn_physical_on() - self._potential = potential - - # child classes set up the samplers - - # ----------------------------------------------------- - - @property - def potential(self) -> SCFPotential: - """The SCF Potential instance.""" - return self._potential - - @property - def rsampler(self) -> rv_potential: - """Radial coordinate sampler.""" - return self._r_distribution - - @property - def thetasampler(self) -> rv_potential: - """Inclination coordinate sampler.""" - return self._theta_distribution - - @property - def phisampler(self) -> rv_potential: - """Azimuthal coordinate sampler.""" - return self._phi_distribution - - # ----------------------------------------------------- - - def cdf( - self, - r: ArrayLike, - theta: ArrayLike, - phi: ArrayLike, - ) -> NDArrayF: - """ - Cumulative Distribution Functions in r, theta(r), phi(r, theta) - - Parameters - ---------- - r : (N,) array-like ['kpc'] - theta : (N,) array-like ['angle'] - phi : (N,) array-like ['angle'] - - Returns - ------- - (N, 3) ndarray - """ - # coordinates # TODO! deprecate whan galpy can do ints - r = np.asanyarray(r, dtype=float) - theta = np.asanyarray(theta, dtype=float) - phi = np.asanyarray(phi, dtype=float) - - R: NDArrayF = self.rsampler.cdf(r) - Theta: NDArrayF = self.thetasampler.cdf(theta, r=r) - Phi: NDArrayF = self.phisampler.cdf(phi, r=r, theta=theta) - return np.c_[R, Theta, Phi].squeeze() - - def rvs( - self, - *, - size: Optional[int] = None, - random_state: RandomLike = None, - vectorized=True, - ) -> PhysicsSphericalRepresentation: - """Sample random variates. - - Parameters - ---------- - size : int or None (optional, keyword-only) - Defining number of random variates. - random_state : int, `~numpy.random.RandomState`, or None (optional, keyword-only) - If seed is None (or numpy.random), the `numpy.random.RandomState` - singleton is used. If seed is an int, a new RandomState instance is - used, seeded with seed. If seed is already a Generator or - RandomState instance then that instance is used. - - Returns - ------- - `~astropy.coordinates.PhysicsSphericalRepresentation` - """ - # TODO! fix that thetasampler is off by pi/2 - - rs = self.rsampler.rvs(size=size, random_state=random_state) - - if vectorized: - thetas = np.pi / 2 - self.thetasampler.rvs(rs, size=size, random_state=random_state) - phis = self.phisampler.rvs(rs, thetas, size=size, random_state=random_state) - - else: # TODO! speed up - # sample from theta and phi. Note that each needs to be in a separate - # NumpyRNGContext to ensure that the results match the vectorized - # option, above. - kw = dict(size=1, random_state=None) - - with NumpyRNGContext(random_state): - rsd = np.atleast_1d(rs) - thetas = np.pi / 2 - np.array([self.thetasampler.rvs(r, **kw) for r in rsd]) - - with NumpyRNGContext(random_state): - thd = np.atleast_1d(thetas) - phis = np.array([self.phisampler.rvs(r, th, **kw) for r, th in zip(rsd, thd)]) - - crd = PhysicsSphericalRepresentation(r=rs, theta=thetas << u.rad, phi=phis << u.rad) - return crd diff --git a/sample_scf/base_multivariate.py b/sample_scf/base_multivariate.py new file mode 100644 index 0000000..9de8f60 --- /dev/null +++ b/sample_scf/base_multivariate.py @@ -0,0 +1,228 @@ +# -*- coding: utf-8 -*- + +"""Base class for sampling from an SCF Potential.""" + +############################################################################## +# IMPORTS + +from __future__ import annotations + +# STDLIB +from abc import ABCMeta +from typing import Any, List, Optional, Tuple, Type, TypeVar + +# THIRD PARTY +import astropy.units as u +import numpy as np +from astropy.coordinates import BaseRepresentation, PhysicsSphericalRepresentation +from astropy.utils.misc import NumpyRNGContext +from galpy.potential import SCFPotential + +# LOCAL +from sample_scf._typing import NDArrayF, RandomGenerator, RandomLike +from .base_univariate import theta_distribution_base, r_distribution_base, phi_distribution_base, rv_potential + +__all__: List[str] = ["SCFSamplerBase"] + +############################################################################## +# PARAMETERS + +RT = TypeVar("RT", bound=BaseRepresentation) + +############################################################################## +# CODE +############################################################################## + + +class SCFSamplerBase(metaclass=ABCMeta): + """Sample SCF in spherical coordinates. + + The coordinate system is: + - r : [0, infinity) + - theta : [-pi/2, pi/2] (positive at the North pole) + - phi : [0, 2pi) + + Parameters + ---------- + pot : `galpy.potential.SCFPotential` + """ + + _potential: SCFPotential + _r_distribution: r_distribution_base + _theta_distribution: theta_distribution_base + _phi_distribution: phi_distribution_base + + def __init__(self, potential: SCFPotential, **kwargs: Any) -> None: + if not isinstance(potential, SCFPotential): + msg = f"potential must be , not {type(potential)}" + raise TypeError(msg) + + potential.turn_physical_on() + self._potential = potential + + # child classes set up the samplers + # _r_distribution + # _theta_distribution + # _phi_distribution + + # ----------------------------------------------------- + + @property + def potential(self) -> SCFPotential: + """The SCF Potential instance.""" + return self._potential + + @property + def r_distribution(self) -> r_distribution_base: + """Radial coordinate sampler.""" + return self._r_distribution + + @property + def theta_distribution(self) -> theta_distribution_base: + """Inclination coordinate sampler.""" + return self._theta_distribution + + @property + def phi_distribution(self) -> phi_distribution_base: + """Azimuthal coordinate sampler.""" + return self._phi_distribution + + @property + def radial_scale_factor(self) -> Quantity: + """Scale factor to convert dimensionful radii to a dimensionless form.""" + return self._r_distribution._radial_scale_factor + + @property + def nmax(self) -> int: + return self._r_distribution._nmax + + @property + def lmax(self) -> int: + return self._r_distribution._lmax + + # ----------------------------------------------------- + + def calculate_rhoTilde(self, radii: Quantity) -> NDArrayF: + """ + + Parameters + ---------- + radii : (R,) Quantity['length', float] + + returns + ------- + (R, N, L) ndarray[float] + """ + return rv_potential.calculate_rhoTilde(self, radii) + + def calculate_Qls(self, r: Quantity, rhoTilde=None) -> NDArrayF: + r""" + Radial sums for inclination weighting factors. + The weighting factors measure perturbations from spherical symmetry. + + :math:`Q_l(r) = \sum_{n=0}^{n_{\max}}A_{nl} \tilde{\rho}_{nl0}(r)` + + Parameters + ---------- + r : (R,) Quantity['kpc', float] + Radii. Scalar or 1D array. + + Returns + ------- + Ql : (R, L) array[float] + """ + return theta_distribution_base.calculate_Qls(self, r, rhoTilde=rhoTilde) + + def calculate_Scs( + self, + r: Quantity, + theta: Quantity, + *, + grid: bool = True, + warn: bool = True, + ) -> Tuple[NDArrayF, NDArrayF]: + r"""Radial and inclination sums for azimuthal weighting factors. + + Parameters + ---------- + pot : :class:`galpy.potential.SCFPotential` + Has coefficient matrices Acos and Asin with shape (N, L, L). + r : float or (R,) ndarray[float] + theta : float or (T,) ndarray[float] + grid : bool, optional keyword-only + warn : bool, optional keyword-only + + Returns + ------- + Rm, Sm : (R, T, L) ndarray[float] + Azimuthal weighting factors. + """ + return phi_distribution_base.calculate_Scs(self, r, theta, grid=grid, warn=warn) + + # ----------------------------------------------------- + + def cdf(self, r: Quantity, theta: Quantity, phi: Quantity) -> NDArrayF: + """Cumulative distribution Functions in r, theta(r), phi(r, theta). + + Parameters + ---------- + r : (N,) Quantity ['length'] + theta : (N,) Quantity ['angle'] + phi : (N,) Quantity ['angle'] + + Returns + ------- + (N, 3) ndarray + """ + R: NDArrayF = self.r_distribution.cdf(r) + Theta: NDArrayF = self.theta_distribution.cdf(theta, r=r) + Phi: NDArrayF = self.phi_distribution.cdf(phi, r=r, theta=theta) + + c: NDArrayF = np.c_[R, Theta, Phi].squeeze() + return c + + def rvs( + self, + *, + size: Optional[int] = None, + random_state: RandomLike = None, + # vectorized: bool = True, + representation_type: Type[RT] = PhysicsSphericalRepresentation, + ) -> RT: + """Sample random variates. + + Parameters + ---------- + size : int or None (optional, keyword-only) + Defining number of random variates. + random_state : int, `~numpy.random.RandomState`, or None (optional, keyword-only) + If seed is None (or numpy.random), the `numpy.random.RandomState` + singleton is used. If seed is an int, a new RandomState instance is + used, seeded with seed. If seed is already a Generator or + RandomState instance then that instance is used. + + Returns + ------- + `~astropy.coordinates.PhysicsSphericalRepresentation` + """ + rs: Quantity + thetas: Quantity + phis: Quantity + + rs = self.r_distribution.rvs(size=size, random_state=random_state) + thetas = self.theta_distribution.rvs(rs, size=size, random_state=random_state) + phis = self.phi_distribution.rvs(rs, thetas, size=size, random_state=random_state) + + crd: RT + crd = PhysicsSphericalRepresentation(r=rs, theta=thetas, phi=phis) + crd = crd.represent_as(representation_type) + + return crd + + def __repr__(self) -> str: + s: str = super().__repr__() + s += f"\n r_distribution: {self.r_distribution!r}" + s += f"\n theta_distribution: {self.theta_distribution!r}" + s += f"\n phi_distribution: {self.phi_distribution!r}" + + return s diff --git a/sample_scf/base_univariate.py b/sample_scf/base_univariate.py new file mode 100644 index 0000000..757dc93 --- /dev/null +++ b/sample_scf/base_univariate.py @@ -0,0 +1,511 @@ +# -*- coding: utf-8 -*- + +"""Base class for sampling from an SCF Potential.""" + +############################################################################## +# IMPORTS + +from __future__ import annotations + +# STDLIB +import warnings +from abc import ABCMeta +from typing import Any, List, Optional, Tuple, Type, TypeVar, Union + +# THIRD PARTY +import astropy.units as u +import numpy as np +from galpy.potential import SCFPotential +from numpy import atleast_1d, arange, inf, pi, zeros, tril_indices, nan_to_num, array, isinf, sum +from numpy.typing import ArrayLike +from scipy._lib._util import check_random_state +from scipy.stats import rv_continuous +from scipy.special import lpmv + +# LOCAL +from sample_scf._typing import NDArrayF, RandomGenerator, RandomLike +from sample_scf.representation import x_of_theta + +__all__: List[str] = [] # nothing is publicly scoped + +############################################################################## +# CODE +############################################################################## + + +class rv_potential(rv_continuous, metaclass=ABCMeta): + """ + Modified :class:`scipy.stats.rv_continuous` to use custom ``rvs`` methods. + Made by stripping down the original scipy implementation. + See :class:`scipy.stats.rv_continuous` for details. + + Parameters + ---------- + `rv_continuous` is a base class to construct specific distribution classes + and instances for continuous random variables. It cannot be used + directly as a distribution. + + Parameters + ---------- + potential : `galpy.potential.SCFPotential` + The potential from which to sample. + momtype : int, optional keyword-only + The type of generic moment calculation to use: 0 for pdf, 1 (default) + for ppf. + a : float, optional keyword-only + Lower bound of the support of the distribution, default is minus + infinity. + b : float, optional keyword-only + Upper bound of the support of the distribution, default is plus + infinity. + xtol : float, optional keyword-only + The tolerance for fixed point calculation for generic ppf. + badvalue : float, optional keyword-only + The value in a result arrays that indicates a value that for which + some argument restriction is violated, default is np.nan. + name : str, optional keyword-only + The name of the instance. This string is used to construct the default + example for distributions. + longname : str, optional keyword-only + This string is used as part of the first line of the docstring returned + when a subclass has no docstring of its own. Note: `longname` exists + for backwards compatibility, do not use for new subclasses. + shapes : str, optional keyword-only + The shape of the distribution. For example ``"m, n"`` for a + distribution that takes two integers as the two shape arguments for all + its methods. If not provided, shape parameters will be inferred from + the signature of the private methods, ``_pdf`` and ``_cdf`` of the + instance. + extradoc : str, optional keyword-only, deprecated + This string is used as the last part of the docstring returned when a + subclass has no docstring of its own. Note: `extradoc` exists for + backwards compatibility, do not use for new subclasses. + seed : {None, int, `numpy.random.Generator`, + `numpy.random.RandomState`}, optional keyword-only + + If `seed` is None (or `np.random`), the `numpy.random.RandomState` + singleton is used. + If `seed` is an int, a new ``RandomState`` instance is used, + seeded with `seed`. + If `seed` is already a ``Generator`` or ``RandomState`` instance then + that instance is used. + """ + + _random_state: RandomGenerator + _potential: SCFPotential + _nmax: int + _lmax: int + _radial_scale_factor: Quantity + + def __init__( + self, + potential: SCFPotential, + *, + momtype: int = 1, + a: Optional[float] = None, + b: Optional[float] = None, + xtol: float = 1e-14, + badvalue: Optional[float] = None, + name: Optional[str] = None, + longname: Optional[str] = None, + shapes: Optional[Tuple[int, ...]] = None, + extradoc: Optional[str] = None, + seed: Optional[int] = None, + ) -> None: + super().__init__( + momtype=momtype, + a=a, + b=b, + xtol=xtol, + badvalue=badvalue, + name=name, + longname=longname, + shapes=shapes, + extradoc=extradoc, + seed=seed, + ) + + if not isinstance(potential, SCFPotential): + msg = f"potential must be , not {type(potential)}" + raise TypeError(msg) + + self._potential = potential + self._nmax = potential._Acos.shape[0] - 1 # 0 inclusive + self._lmax = potential._Acos.shape[1] - 1 # 0 inclusive + self._radial_scale_factor = (potential._a * potential._ro) << u.kpc + + @property + def potential(self) -> SCFPotential: + """The potential from which to sample""" + return self._potential + + @property + def radial_scale_factor(self) -> Quantity: + """Scale factor to convert dimensionful radii to a dimensionless form.""" + return self._radial_scale_factor + + @property + def nmax(self) -> int: + return self._nmax + + @property + def lmax(self) -> int: + return self._lmax + + def calculate_rhoTilde(self, radii: Quantity) -> NDArrayF: + """Compute the r-dependent coefficient matrix. + + Parameters + ---------- + radii : (R,) Quantity['length', float] + + returns + ------- + (R, N, L) ndarray[float] + """ + # compute the r-dependent coefficient matrix $\tilde{\rho}$ + nmaxp1, lmaxp1 = self._potential._Acos.shape[:2] + gprs = np.atleast_1d(radii.to_value(u.kpc)) / self._potential._ro + rhoT = array([self._potential._rhoTilde(r, N=nmaxp1, L=lmaxp1) for r in gprs]) # (R, N, L) + # this matrix can have incorrect NaN values when radii=0, inf + # and needs to be corrected + ind = (radii == 0) | isinf(radii) + rhoT[ind] = nan_to_num(rhoT[ind], copy=False, posinf=inf, neginf=-inf) + + return rhoT + + # --------------------------------------------------------------- + + def rvs( + self, + *args: Union[np.floating, ArrayLike], + size: Optional[int] = None, + random_state: RandomLike = None, + **kwargs, + ) -> NDArrayF: + """Random variate sampler. + + Parameters + ---------- + *args + size : int or None (optional, keyword-only) + Size of random variates to generate. + random_state : int, `~numpy.random.RandomState`, or None (optional, keyword-only) + If seed is None (or numpy.random), the `numpy.random.RandomState` + singleton is used. If seed is an int, a new RandomState instance is + used, seeded with seed. If seed is already a Generator or + RandomState instance then that instance is used. + **kwargs + + Returns + ------- + ndarray[float] + Shape 'size'. + """ + # copied from `scipy` + # extra gymnastics needed for a custom random_state + rndm: RandomGenerator + if random_state is not None: + random_state_saved = self._random_state + rndm = check_random_state(random_state) + else: + rndm = self._random_state + + # go directly to `_rvs` + vals: NDArrayF = self._rvs(*args, size=size, random_state=rndm, **kwargs) + + # copied from `scipy` + # do not forget to restore the _random_state + if random_state is not None: + self._random_state = random_state_saved + + return vals.squeeze() # TODO? should it squeeze? + + +# ------------------------------------------------------------------- + + +class r_distribution_base(rv_potential): + """Sample radial coordinate from an SCF potential. + + The potential must have a convergent mass function. + + Parameters + ---------- + potential : `galpy.potential.SCFPotential` + """ + + +class theta_distribution_base(rv_potential): + """Sample inclination coordinate from an SCF potential. + + The potential must have a convergent mass function. + + Parameters + ---------- + potential : `galpy.potential.SCFPotential` + """ + + def __init__(self, potential: SCFPotential, **kwargs) -> None: + kwargs["a"], kwargs["b"] = 1, -1 # allowed range of x + super().__init__(potential, **kwargs) + + self._lrange = arange(0, self._lmax + 1) # lmax inclusive + + def rvs( + self, + *args: Union[np.floating, ArrayLike], + size: Optional[int] = None, + random_state: RandomLike = None, + # return_thetas: bool = True, + ) -> NDArrayF: + return super().rvs( + *args, + size=size, + random_state=random_state, + # return_thetas=return_thetas + ) + + # --------------------------------------------------------------- + + def calculate_Qls(self, r: Quantity, rhoTilde: Optional[NDArrayF]=None) -> NDArrayF: + r""" + Compute the radial sums for inclination weighting factors. + The weighting factors measure perturbations from spherical symmetry. + The sin component disappears in the integral. + + :math:`Q_l(r) = \sum_{n=0}^{n_{\max}}A_{nl} \tilde{\rho}_{nl0}(r)` + + Parameters + ---------- + r : (R,) Quantity['kpc', float] + Radii. Scalar or 1D array. + rhoTilde : (R, N, L) array[float] + + Returns + ------- + Ql : (R, L) array[float] + """ + Acos = self.potential._Acos # (N, L, M) + rhoT = self.calculate_rhoTilde(r) if rhoTilde is None else rhoTilde + + # inclination weighting factors + Qls: NDArrayF = sum(Acos[None, :, :, 0] * rhoT, axis=1) # (R, L) + # this matrix can have incorrect NaN values when radii=0 because + # rhoTilde will have +/- infs which when summed produce a NaN. + # at r=0 this can be changed to 0. # TODO! double confirm math + ind0 = (r == 0) + if not sum(nan_to_num(rhoT[ind0, :, 0], posinf=1, neginf=-1)) == 0: + # note: this if statement works even if ind0 is all False + warnings.warn("Qls have non-cancelling infinities at r==0") + else: + Qls[ind0] = nan_to_num(Qls[ind0], copy=False) # TODO! Nan-> 0 or 1? + + return Qls + + +class phi_distribution_base(rv_potential): + """Sample inclination coordinate from an SCF potential. + + The potential must have a convergent mass function. + + Parameters + ---------- + potential : `galpy.potential.SCFPotential` + """ + + def __init__(self, potential: SCFPotential, **kwargs: Any) -> None: + kwargs["a"], kwargs["b"] = 0, 2 * pi + super().__init__(potential, **kwargs) + + self._lrange = arange(0, self._lmax + 1) + + # --------------------------------------------------------------- + + @staticmethod + def _pnts_Scs( + r: NDArrayF, + rhoTilde: NDArrayF, + Acos: NDArrayF, + Asin: NDArrayF, + theta: NDArrayF, + ) -> Tuple[NDArrayF, NDArrayF]: + """Radial and inclination sums for azimuthal weighting factors. + + Parameters + ---------- + rhoTilde: (R, N, L) ndarray + Acos, Asin : (N, L, L) ndarray + theta : (T,) ndarray[float] + + Returns + ------- + Scm, Ssm : (R, T, L) ndarray + Azimuthal weighting factors. + Cosine and Sine, respectively. + + Warns + ----- + RuntimeWarning + For invalid values (inf addition -> Nan). + For overflow encountered related to inf and 0 division. + """ + T: int = len(theta) + N = Acos.shape[0] - 1 + L = M = Acos.shape[1] - 1 + + # The r-dependent coefficient matrix $\tilde{\rho}$ + RhoT = rhoTilde[..., None] # (R/T, N, L, {M}) + + # need r and theta to be arrays. Maintains units. + x: NDArrayF = x_of_theta(theta) # (T,) + xs = x[:, None, None, None] # (R/T, {N}, {L}, {M}) + + # legendre polynomials + ls, ms = tril_indices(L + 1) # index set I_(L, M) + + lps = zeros((T, L + 1, M + 1)) # (R/T, L, M) + lps[:, ls, ms] = lpmv(ls[None, :], ms[None, :], xs[:, 0, 0, 0]) + Plm = lps[:, None, :, :] # (R/T, {N}, L, M) + + # full S matrices (R/T, N, L, M) # TODO! where's Nlm + # n-sum # (R/T, N, L, M) -> (R, T, L, M) + Sclm = np.sum(Acos[None, :, :, :] * RhoT * Plm, axis=-3) + Sslm = np.sum(Asin[None, :, :, :] * RhoT * Plm, axis=-3) + + # # fix adding +/- inf -> NaN. happens when r=0. + # idx = np.all(np.isnan(Rlm[:, 0, :]), axis=-1) + # Rlm[idx, 0, :] = nan_to_num(Rlm[idx, 0, :]) + # Slm[idx, 0, :] = nan_to_num(Slm[idx, 0, :]) + + # l'-sum # FIXME! confirm correct som + Scm = np.sum(Sclm, axis=-2) + Ssm = np.sum(Sslm, axis=-2) + + return Scm, Ssm + + @staticmethod + def _grid_Scs( + r: NDArrayF, + rhoTilde: NDArrayF, + Acos: NDArrayF, + Asin: NDArrayF, + theta: NDArrayF, + ) -> Tuple[NDArrayF, NDArrayF]: + """Radial and inclination sums for azimuthal weighting factors. + + Parameters + ---------- + rhoTilde: (R, N, L) ndarray + Acos, Asin : (N, L, L) ndarray + theta : (T,) ndarray[float] + + Returns + ------- + Scm, Ssm : (R, T, L) ndarray + Azimuthal weighting factors. + Cosine and Sine, respectively. + + Warns + ----- + RuntimeWarning + For invalid values (inf addition -> Nan). + For overflow encountered related to inf and 0 division. + """ + T: int = len(theta) + N = Acos.shape[0] - 1 + L = M = Acos.shape[1] - 1 + + # The r-dependent coefficient matrix $\tilde{\rho}$ + RhoT = rhoTilde[:, None, :, :, None] # (R, {T}, N, L, {M}) + + # need r and theta to be arrays. Maintains units. + x: NDArrayF = x_of_theta(theta) # (T,) + xs = x[None, :, None, None, None] # ({R}, T, {N}, {L}, {M}) + + # legendre polynomials + ls, ms = tril_indices(L + 1) # index set I_(L, M) + + lps = zeros((T, L + 1, M + 1)) # (T, L, M) + lps[:, ls, ms] = lpmv(ls[None, ...], ms[None, ...], xs[0, :, 0, 0, 0, None]) + Plm = lps[None, :, None, :, :] # ({R}, T, {N}, L, M) + + # full S matrices (R, T, N, L, M) + # n-sum # (R, T, N, L, M) -> (R, T, L, M) + Sclm = np.sum(Acos[None, None, :, :, :] * RhoT * Plm, axis=-3) + Sslm = np.sum(Asin[None, None, :, :, :] * RhoT * Plm, axis=-3) + + # fix adding +/- inf -> NaN. happens when r=0. + idx = (r == 0) + Sclm[idx] = nan_to_num(Sclm[idx]) + Sslm[idx] = nan_to_num(Sslm[idx]) + + # l'-sum # FIXME! confirm correct som + Scm = np.sum(Sclm, axis=-2) + Ssm = np.sum(Sslm, axis=-2) + + return Scm, Ssm + + def calculate_Scs( + self, + r: Quantity, + theta: Quantity, + *, + grid: bool = True, + warn: bool = True, + ) -> Tuple[NDArrayF, NDArrayF]: + r"""Radial and inclination sums for azimuthal weighting factors. + + Parameters + ---------- + pot : :class:`galpy.potential.SCFPotential` + Has coefficient matrices Acos and Asin with shape (N, L, L). + r : float or (R,) ndarray[float] + theta : float or (T,) ndarray[float] + grid : bool, optional keyword-only + warn : bool, optional keyword-only + + Returns + ------- + Rm, Sm : (R, T, L) ndarray[float] + Azimuthal weighting factors. + """ + # need r and theta to be float arrays. + rdtype = np.result_type(float, np.result_type(r)) + radii: NDArrayF = atleast_1d(r) # (R,) + thetas: NDArrayF = atleast_1d(theta) << u.rad # (T,) + + if not grid and len(thetas) != len(radii): + raise ValueError + + # compute the r-dependent coefficient matrix $\tilde{\rho}$ # (R, N, L) + nmaxp1: int = self.potential._Acos.shape[0] + lmaxp1: int = self.potential._Acos.shape[1] + galpyrs = radii.to_value(u.kpc) / self.potential._ro + rhoTilde = nan_to_num( + array( + [self.potential._rhoTilde(r, N=nmaxp1, L=lmaxp1) for r in galpyrs] + ), # TODO! vectorize + nan=0, + posinf=inf, + neginf=-inf, + ) + + # pass to actual calculator, which takes the matrices and r, theta grids. + with warnings.catch_warnings() if not warn else nullcontext(): + if not warn: + warnings.filterwarnings( + "ignore", + category=RuntimeWarning, + message="(^invalid value)|(^overflow encountered)", + ) + func = self._grid_Scs if grid else self._pnts_Scs + Sc, Ss = func( + r, + rhoTilde, + Acos=self.potential._Acos, + Asin=self.potential._Asin, + theta=thetas, + ) + + return Sc, Ss diff --git a/sample_scf/cdf_strategy.py b/sample_scf/cdf_strategy.py deleted file mode 100644 index 49a024d..0000000 --- a/sample_scf/cdf_strategy.py +++ /dev/null @@ -1,116 +0,0 @@ -# -*- coding: utf-8 -*- - -""" -Deal with non-monotonic CDFs. -The problem can arise if the PDF (density field) ever dips negative because of -an incorrect solution to the SCF coefficients. E.g. when solving for the -coefficients from an analytic density profile. - -""" - -# __all__ = [ -# # functions -# "", -# # other -# "", -# ] - - -############################################################################## -# IMPORTS - -# BUILT-IN -import abc -import inspect - -# THIRD PARTY -import numpy as np -from astropy.utils.state import ScienceState - -############################################################################## -# PARAMETERS - -CDF_STRATEGIES = {} - -############################################################################## -# CODE -############################################################################## - - -class default_cdf_strategy(ScienceState): - - _value = "error" - _default_value = "error" - - @classmethod - def validate(cls, value): - if value is None: - value = self._default_value - - if isinstance(value, str): - if value not in CDF_STRATEGIES: - raise ValueError - return CDF_STRATEGIES[value] - elif inspect.isclass(value) and issubclass(value, CDFStrategy): - return value - else: - raise TypeError() - - -# ============================================================================= - - -class CDFStrategy: - def __init_subclass__(cls, key, **kwargs): - CDF_STRATEGIES[key] = cls - - @classmethod - @abc.abstractmethod - def apply(cls, cdf, **kw): - pass - - -# ------------------------------------------------------------------- - - -class Error(CDFStrategy, key="error"): - @classmethod - def apply(cls, cdf, **kw): - """ - - .. warning:: - operates in-place on numpy arrays - - """ - # find where cdf breaks monotonicity - notreal = np.where(np.diff(cdf) <= 0)[0] + 1 - # raise error if any breaks - if np.any(notreal): - msg = "cdf contains unreal elements " - msg += f"at index {kw['index']}" if "index" in kw else "" - raise ValueError(msg) - - -# ------------------------------------------------------------------- - - -class LinearInterpolate(CDFStrategy, key="linear"): - @classmethod - def apply(cls, cdf, **kw): - """ - - .. warning:: - operates in-place on numpy arrays - - """ - # find where cdf breaks monotonicity - # and the startpoint of each break. - notreal = np.where(np.diff(cdf) <= 0)[0] + 1 - startnotreal = np.concatenate((notreal[:1], notreal[np.where(np.diff(notreal) > 1)[0] + 1])) - - for i in startnotreal[:-1]: - i0 = i - 1 # before it dips negative - i1 = i0 + np.argmax(cdf[i0:] - cdf[i0] > 0) # start of net positive - cdf[i0 : i1 + 1] = np.linspace(cdf[i0], cdf[i1], num=i1 - i0 + 1, endpoint=True) - - return cdf diff --git a/sample_scf/conftest.py b/sample_scf/conftest.py index 9146335..34d33e9 100644 --- a/sample_scf/conftest.py +++ b/sample_scf/conftest.py @@ -10,7 +10,7 @@ """ -# BUILT-IN +# STDLIB import copy import os @@ -77,26 +77,26 @@ def pytest_configure(config): _hernquist_scf_potential.turn_physical_on() -# NFW -nfw_potential = NFWPotential(normalize=1) -nfw_potential.turn_physical_on() -nfw_df = isotropicNFWdf(nfw_potential, rmax=1e4) -# FIXME! load this up as a test data file -fpath = get_pkg_data_path("tests/data/nfw.npz", package="sample_scf") -try: - data = np.load(fpath) -except FileNotFoundError: - a_scf = 80 - Acos, Asin = scf_compute_coeffs_axi(nfw_potential.dens, N=40, L=30, a=a_scf) - np.savez(fpath, Acos=Acos, Asin=Asin, a_scf=a_scf) -else: - data = np.load(fpath, allow_pickle=True) - Acos = copy.deepcopy(data["Acos"]) - Asin = None - a_scf = data["a_scf"] - -_nfw_scf_potential = SCFPotential(Acos=Acos, Asin=None, a=a_scf, normalize=1.0) -_nfw_scf_potential.turn_physical_on() +# # NFW +# nfw_potential = NFWPotential(normalize=1) +# nfw_potential.turn_physical_on() +# nfw_df = isotropicNFWdf(nfw_potential, rmax=1e4) +# # FIXME! load this up as a test data file +# fpath = get_pkg_data_path("tests/data/nfw.npz", package="sample_scf") +# try: +# data = np.load(fpath) +# except FileNotFoundError: +# a_scf = 80 +# Acos, Asin = scf_compute_coeffs_axi(nfw_potential.dens, N=40, L=30, a=a_scf) +# np.savez(fpath, Acos=Acos, Asin=Asin, a_scf=a_scf) +# else: +# data = np.load(fpath, allow_pickle=True) +# Acos = copy.deepcopy(data["Acos"]) +# Asin = None +# a_scf = data["a_scf"] +# +# _nfw_scf_potential = SCFPotential(Acos=Acos, Asin=None, a=a_scf, normalize=1.0) +# _nfw_scf_potential.turn_physical_on() # Triaxial NFW @@ -108,11 +108,11 @@ def pytest_configure(config): # ------------------------ cls_pot_kw = { _hernquist_scf_potential: {"total_mass": 1.0}, - _nfw_scf_potential: {"total_mass": 1.0}, + # _nfw_scf_potential: {"total_mass": 1.0}, } theory = { _hernquist_scf_potential: hernquist_df, - _nfw_scf_potential: nfw_df, + # _nfw_scf_potential: nfw_df, } @@ -125,10 +125,10 @@ def hernquist_scf_potential(): return _hernquist_scf_potential -@pytest.fixture(scope="session") -def nfw_scf_potential(): - """Make a SCF of a triaxial NFW potential.""" - return _nfw_scf_potential +# @pytest.fixture(scope="session") +# def nfw_scf_potential(): +# """Make a SCF of a triaxial NFW potential.""" +# return _nfw_scf_potential @pytest.fixture( diff --git a/sample_scf/core.py b/sample_scf/core.py index 033917f..e1c409c 100644 --- a/sample_scf/core.py +++ b/sample_scf/core.py @@ -11,7 +11,7 @@ from __future__ import annotations -# BUILT-IN +# STDLIB from collections.abc import Mapping from typing import Any, Literal, Optional, Type, TypedDict, Union @@ -19,7 +19,8 @@ from galpy.potential import SCFPotential # LOCAL -from .base import SCFSamplerBase, rv_potential +from .base_multivariate import SCFSamplerBase +from .base_univariate import rv_potential from .exact import ExactSCFSampler from .interpolated import InterpolatedSCFSampler @@ -41,12 +42,12 @@ class MethodsMapping(TypedDict): ############################################################################## -class SCFSampler(SCFSamplerBase): # metaclass=SCFSamplerSwitch +class SCFSampler(SCFSamplerBase): """Sample SCF in spherical coordinates. The coordinate system is: - r : [0, infinity) - - theta : [-pi/2, pi/2] (positive at the North pole) + - theta : [0, pi] (0 at the North pole) - phi : [0, 2pi) Parameters @@ -58,19 +59,21 @@ class SCFSampler(SCFSamplerBase): # metaclass=SCFSamplerSwitch Passed to to the individual component sampler constructors. """ + _sampler: Optional[SCFSamplerBase] + def __init__( self, potential: SCFPotential, method: Union[Literal["interp", "exact"], MethodsMapping], **kwargs: Any, ) -> None: - super().__init__(potential) + super().__init__(potential, **kwargs) if isinstance(method, Mapping): # mix and match exact and interpolated sampler = None - rsampler = method["r"](potential, **kwargs) - thetasampler = method["theta"](potential, **kwargs) - phisampler = method["phi"](potential, **kwargs) + r_distribution = method["r"](potential, **kwargs) + theta_distribution = method["theta"](potential, **kwargs) + phi_distribution = method["phi"](potential, **kwargs) else: # either exact or interpolated sampler_cls: Type[SCFSamplerBase] @@ -82,11 +85,11 @@ def __init__( raise ValueError(f"method = {method} not in " + "{'interp', 'exact'}") sampler = sampler_cls(potential, **kwargs) - rsampler = sampler.rsampler - thetasampler = sampler.thetasampler - phisampler = sampler.phisampler + r_distribution = sampler.r_distribution + theta_distribution = sampler.theta_distribution + phi_distribution = sampler.phi_distribution self._sampler: Optional[SCFSamplerBase] = sampler - self._r_distribution = rsampler - self._theta_distribution = thetasampler - self._phi_distribution = phisampler + self._r_distribution = r_distribution + self._theta_distribution = theta_distribution + self._phi_distribution = phi_distribution diff --git a/sample_scf/exact/__init__.py b/sample_scf/exact/__init__.py index 6feec3e..f710950 100644 --- a/sample_scf/exact/__init__.py +++ b/sample_scf/exact/__init__.py @@ -3,3 +3,18 @@ # LOCAL from .core import ExactSCFSampler +from .radial import exact_r_distribution +from .inclination import exact_theta_fixed_distribution, exact_theta_distribution +from .azimuth import exact_phi_fixed_distribution, exact_phi_distribution + + +__all__ = [ + # multivariate + "ExactSCFSampler", + # univariate + "exact_r_distribution", + "exact_theta_fixed_distribution", + "exact_theta_distribution", + "exact_phi_fixed_distribution", + "exact_phi_distribution", +] diff --git a/sample_scf/exact/rvs_azimuth.py b/sample_scf/exact/azimuth.py similarity index 90% rename from sample_scf/exact/rvs_azimuth.py rename to sample_scf/exact/azimuth.py index 0ec6c05..4837821 100644 --- a/sample_scf/exact/rvs_azimuth.py +++ b/sample_scf/exact/azimuth.py @@ -7,7 +7,7 @@ from __future__ import annotations -# BUILT-IN +# STDLIB from typing import Any, Optional, cast # THIRD PARTY @@ -18,10 +18,9 @@ # LOCAL from sample_scf._typing import NDArrayF, RandomLike -from sample_scf.base import rv_potential -from sample_scf.utils import phiRSms +from sample_scf.base_univariate import phi_distribution_base -__all__ = ["phi_fixed_distribution", "phi_distribution"] +__all__ = ["exact_phi_fixed_distribution", "exact_phi_distribution"] ############################################################################## @@ -29,7 +28,7 @@ ############################################################################## -class phi_distribution_base(rv_potential): +class exact_phi_distribution_base(phi_distribution_base): """Sample Azimuthal Coordinate. Parameters @@ -47,8 +46,8 @@ def __init__(self, potential: SCFPotential, **kw: Any) -> None: self._lrange = np.arange(0, self._lmax + 1) # for compatibility - self._Rm: Optional[NDArrayF] = None - self._Sm: Optional[NDArrayF] = None + self._Sc: Optional[NDArrayF] = None + self._Ss: Optional[NDArrayF] = None def _cdf(self, phi: NDArrayF, *args: Any, **kw: Any) -> NDArrayF: r"""Cumulative Distribution Function. @@ -68,7 +67,7 @@ def _cdf(self, phi: NDArrayF, *args: Any, **kw: Any) -> NDArrayF: output. """ - Rm, Sm = kw.get("RSms", (self._Rm, self._Sm)) # (R/T, L) + Rm, Sm = kw.get("Scs", (self._Sc, self._Ss)) # (R/T, L) Phis: NDArrayF = np.atleast_1d(phi)[:, None] # (P, {L}) @@ -94,7 +93,7 @@ def _ppf_to_solve(self, phi: float, q: float, *args: Any) -> NDArrayF: return self._cdf(*(phi,) + args) - q -class phi_fixed_distribution(phi_distribution_base): +class exact_phi_fixed_distribution(exact_phi_distribution_base): """Sample Azimuthal Coordinate at fixed r, theta. Parameters @@ -110,7 +109,7 @@ def __init__(self, potential: SCFPotential, r: NDArrayF, theta: NDArrayF, **kw: # assign fixed r, theta self._r, self._theta = r, theta # and can compute the associated assymetry measures - self._Rm, self._Sm = phiRSms(potential, r, theta, grid=False, warn=False) + self._Sc, self._Ss = self.calculate_Scs(r, theta, grid=False, warn=False) def cdf(self, phi: NDArrayF, *args: Any, **kw: Any) -> NDArrayF: r"""Cumulative Distribution Function. @@ -132,7 +131,7 @@ def cdf(self, phi: NDArrayF, *args: Any, **kw: Any) -> NDArrayF: return self._cdf(phi, *args, **kw) -class phi_distribution(phi_distribution_base): +class exact_phi_distribution(exact_phi_distribution_base): def _cdf( self, phi: ArrayLike, @@ -165,8 +164,8 @@ def _cdf( ValueError If 'r' or 'theta' are None. """ - RSms = phiRSms(self._potential, cast(float, r), cast(float, theta), grid=False, warn=False) - cdf: NDArrayF = super()._cdf(phi, *args, RSms=RSms) + Scs = self.calculate_Scs(cast(float, r), cast(float, theta), grid=False, warn=False) + cdf: NDArrayF = super()._cdf(phi, *args, Scs=Scs) return cdf def cdf(self, phi: ArrayLike, *args: Any, r: float, theta: float) -> NDArrayF: diff --git a/sample_scf/exact/core.py b/sample_scf/exact/core.py index ac99922..ff59baa 100644 --- a/sample_scf/exact/core.py +++ b/sample_scf/exact/core.py @@ -7,21 +7,20 @@ from __future__ import annotations -# BUILT-IN +# STDLIB import abc -import typing as T -from typing import Any +from typing import Any, Optional # THIRD PARTY from astropy.coordinates import PhysicsSphericalRepresentation from galpy.potential import SCFPotential # LOCAL -from .rvs_azimuth import phi_distribution -from .rvs_inclination import theta_distribution -from .rvs_radial import r_distribution +from .azimuth import exact_phi_distribution +from .inclination import exact_theta_distribution +from .radial import exact_r_distribution from sample_scf._typing import NDArrayF, RandomLike -from sample_scf.base import SCFSamplerBase +from sample_scf.base_multivariate import SCFSamplerBase __all__ = ["SCFSampler"] @@ -53,8 +52,8 @@ def __init__(self, potential: SCFPotential, **kw: Any) -> None: # make samplers total_mass = kw.pop("total_mass", None) self._r_distribution = r_distribution(potential, total_mass=total_mass, **kw) - self._theta_distribution = theta_distribution(potential, **kw) # r=None - self._phi_distribution = phi_distribution(potential, **kw) # r=None, theta=None + self._theta_distribution = exact_theta_distribution(potential, **kw) # r=None + self._phi_distribution = exact_phi_distribution(potential, **kw) # r=None, theta=None def rvs( self, *, size: Optional[int] = None, random_state: RandomLike = None diff --git a/sample_scf/exact/inclination.py b/sample_scf/exact/inclination.py new file mode 100644 index 0000000..0ecee29 --- /dev/null +++ b/sample_scf/exact/inclination.py @@ -0,0 +1,212 @@ +# -*- coding: utf-8 -*- + +"""Exact sampling of inclination coordinate.""" + +############################################################################## +# IMPORTS + +from __future__ import annotations + +# STDLIB +import abc +from typing import Any, Optional, Union, cast + +# THIRD PARTY +import astropy.units as u +import numpy as np +from galpy.potential import SCFPotential +from numpy.polynomial.legendre import legval +from numpy.typing import ArrayLike + +# LOCAL +from sample_scf._typing import NDArrayF, RandomLike +from sample_scf.base_univariate import theta_distribution_base +from sample_scf.representation import x_of_theta, theta_of_x + +__all__ = ["exact_theta_fixed_distribution", "exact_theta_distribution"] + + +############################################################################## +# CODE +############################################################################## + + +class exact_theta_distribution_base(theta_distribution_base): + """Base class for sampling the inclination coordinate.""" + + def _cdf(self, x: NDArrayF, Qls: NDArrayF) -> NDArrayF: + """Cumulative Distribution Function. + + .. math:: + + F_{\theta}(\theta; r) = \frac{1 + \cos{\theta}}{2} + + \frac{1}{2 Q_0(r)}\sum_{\ell=1}^{L_{\max}}Q_{\ell}(r) + \frac{\sin(\theta) P_{\ell}^{1}(\cos{\theta})}{\ell(\ell+1)} + + Where + + Q_{\ell}(r) = \sum_{n=0}^{N_{\max}} N_{\ell 0} A_{n\ell 0}^{(\cos)} + \tilde{\rho}_{n\ell}(r) + + Parameters + ---------- + x : number or (T,) array[number] + :math:`x = \cos\theta`. Must be in the range [-1, 1] + Qls : (R, L) array[float] + Radially-dependent coefficients parameterizing the deviations from + a uniform distribution on the inclination angle. + + Returns + ------- + (R, T) array + """ + xs = np.atleast_1d(x) # (T,) + Qls = np.atleast_2d(Qls) # (R, L) + + # l = 0 + term0 = 0.5 * (1.0 - xs) # (T,) + # l = 1+ : non-symmetry + factor = 1.0 / (2.0 * Qls[:, 0]) # (R,) + + wQls = Qls[:, 1:] / (2 * self._lrange[None, 1:] + 1) # apply over (L,) dimension + wQls_lp1 = np.pad(wQls, [[0, 0], [2, 0]]) # pad start of (L,) dimension + + sumPlp1 = legval(xs, wQls_lp1.T, tensor=True) # (R, T) + sumPlm1 = legval(xs, wQls.T, tensor=True) # (R, T) + + cdf = term0 + np.nan_to_num((factor * (sumPlm1 - sumPlp1).T).T) # (R, T) + return cdf # TODO! get rid of sf function + +# @abc.abstractmethod +# def _cdf(self, x: NDArrayF, Qls: NDArrayF) -> NDArrayF: +# """Cumulative Distribution Function. +# +# .. math:: +# +# F_{\theta}(\theta; r) = \frac{1 + \cos{\theta}}{2} + +# \frac{1}{2 Q_0(r)}\sum_{\ell=1}^{L_{\max}}Q_{\ell}(r) +# \frac{\sin(\theta) P_{\ell}^{1}(\cos{\theta})}{\ell(\ell+1)} +# +# Where +# +# Q_{\ell}(r) = \sum_{n=0}^{N_{\max}} N_{\ell 0} A_{n\ell 0}^{(\cos)} +# \tilde{\rho}_{n\ell}(r) +# +# Parameters +# ---------- +# x : number or (T,) array[number] +# :math:`x = \cos\theta`. Must be in the range [-1, 1] +# Qls : (R, L) array[float] +# Radially-dependent coefficients parameterizing the deviations from +# a uniform distribution on the inclination angle. +# +# Returns +# ------- +# (R, T) array +# """ +# sf = self._sf(x, Qls) +# return 1.0 - sf + + def _rvs( + self, + *args: Union[np.floating, ArrayLike], + size: Optional[int] = None, + random_state: RandomLike = None, + # return_thetas: bool = True + ) -> NDArrayF: + xs = super()._rvs(*args, size=size, random_state=random_state) + # ths = theta_of_x(xs) if return_thetas else xs + ths = theta_of_x(xs) + return ths + + def _ppf_to_solve(self, x: float, q: float, *args: Any) -> NDArrayF: + ppf: NDArrayF = self._cdf(*(x,) + args) - q + return ppf + + +class exact_theta_fixed_distribution(exact_theta_distribution_base): + """ + Sample inclination coordinate from an SCF potential. + + Parameters + ---------- + pot : `~galpy.potential.SCFPotential` + r : Quantity or None, optional + If passed, these are the locations at which the theta CDF will be + evaluated. If None (default), then the r coordinate must be given + to the CDF and RVS functions. + **kw: + Not used. + """ + + def __init__(self, potential: SCFPotential, r: Quantity, **kw: Any) -> None: + super().__init__(potential) + + # points at which CDF is defined + self._r = r + self._Qlsatr = self.calculate_Qls(r) + + @property + def fixed_radius(self) -> Quantity: + return self._r + + def _cdf(self, x: ArrayLike, *args: Any) -> NDArrayF: + cdf: NDArrayF = super()._cdf(x, self._Qlsatr) + return cdf + + def cdf(self, theta: Quantity) -> NDArrayF: + """Cumulative distribution function of the given RV. + + Parameters + ---------- + theta : Quantity['angle'] + + Returns + ------- + cdf : ndarray + Cumulative distribution function evaluated at `theta` + """ + return self._cdf(x_of_theta(theta << u.rad)) + + def rvs(self, size: Optional[int] = None, random_state: RandomLike = None) -> NDArrayF: + pts = super().rvs(self._r, size=size, random_state=random_state) + return pts + + +class exact_theta_distribution(exact_theta_distribution_base): + """ + Sample inclination coordinate from an SCF potential. + + Parameters + ---------- + pot : `~galpy.potential.SCFPotential` + + """ + + def _cdf(self, x: NDArrayF, r: float) -> NDArrayF: + Qls = self.calculate_Qls(r) + cdf = super()._cdf(x, Qls) + return cdf + + def cdf(self, theta: Quantity, *args: Any, r: Quantity) -> NDArrayF: + """Cumulative distribution function of the given RV. + + Parameters + ---------- + theta : Quantity['angle'] + *args + Not used. + r : Quantity['length', float] (optional, keyword-only) + + Returns + ------- + cdf : ndarray + Cumulative distribution function evaluated at `theta` + """ + return self._cdf(x_of_theta(theta), *args, r=r) + + def rvs( + self, r: Quantity, *, size: Optional[int] = None, random_state: RandomLike = None + ) -> NDArrayF: + pts = super().rvs(r, size=size, random_state=random_state) + return pts diff --git a/sample_scf/exact/rvs_radial.py b/sample_scf/exact/radial.py similarity index 79% rename from sample_scf/exact/rvs_radial.py rename to sample_scf/exact/radial.py index f14b65c..5659dec 100644 --- a/sample_scf/exact/rvs_radial.py +++ b/sample_scf/exact/radial.py @@ -7,7 +7,7 @@ from __future__ import annotations -# BUILT-IN +# STDLIB import abc from typing import Any, Optional, Union, cast @@ -15,14 +15,16 @@ import astropy.units as u import numpy as np from astropy.coordinates import PhysicsSphericalRepresentation +from astropy.units import Quantity +from galpy.potential import SCFPotential from numpy.typing import ArrayLike # LOCAL from sample_scf._typing import NDArrayF, RandomLike -from sample_scf.base import SCFSamplerBase, rv_potential -from sample_scf.utils import difPls, phiRSms, theta_of_x, thetaQls, x_of_theta +from sample_scf.base_multivariate import SCFSamplerBase +from sample_scf.base_univariate import r_distribution_base -__all__ = ["r_distribution"] +__all__ = ["exact_r_distribution"] ############################################################################## @@ -30,7 +32,7 @@ ############################################################################## -class r_distribution(rv_potential): +class exact_r_distribution(r_distribution_base): """Sample radial coordinate from an SCF potential. Parameters @@ -42,7 +44,9 @@ class r_distribution(rv_potential): Not used. """ - def __init__(self, potential: SCFPotential, total_mass=None, **kw: Any) -> None: + def __init__( + self, potential: SCFPotential, total_mass: Optional[Quantity] = None, **kw: Any + ) -> None: # make sampler kw["a"], kw["b"] = 0, np.inf # allowed range of r super().__init__(potential, **kw) @@ -59,12 +63,12 @@ def __init__(self, potential: SCFPotential, total_mass=None, **kw: Any) -> None: # vectorize mass function, which is scalar self._vec_cdf = np.vectorize(self._potential._mass) - def _cdf(self, r: ArrayLike, *args: Any, **kw: Any) -> NDArrayF: + def _cdf(self, r: Quantity, *args: Any, **kw: Any) -> NDArrayF: """Cumulative Distribution Function. Parameters ---------- - r : array-like + r : Quantity ['length'] *args **kwargs diff --git a/sample_scf/exact/rvs_inclination.py b/sample_scf/exact/rvs_inclination.py deleted file mode 100644 index 27d5193..0000000 --- a/sample_scf/exact/rvs_inclination.py +++ /dev/null @@ -1,159 +0,0 @@ -# -*- coding: utf-8 -*- - -"""Exact sampling of inclination coordinate.""" - -############################################################################## -# IMPORTS - -from __future__ import annotations - -# BUILT-IN -import abc -from typing import Any, Optional, Union, cast - -# THIRD PARTY -import astropy.units as u -import numpy as np -from galpy.potential import SCFPotential -from numpy.typing import ArrayLike - -# LOCAL -from sample_scf._typing import NDArrayF, RandomLike -from sample_scf.base import rv_potential -from sample_scf.utils import difPls, theta_of_x, thetaQls, x_of_theta - -__all__ = ["theta_fixed_distribution", "theta_distribution"] - - -############################################################################## -# CODE -############################################################################## - - -class theta_distribution_base(rv_potential): - """Base class for sampling the inclination coordinate.""" - - def __init__(self, potential: SCFPotential, **kw: Any) -> None: - kw["a"], kw["b"] = -np.pi / 2, np.pi / 2 # allowed range of theta - super().__init__(potential, **kw) - self._lrange = np.arange(0, self._lmax + 1) # lmax inclusive - - @abc.abstractmethod - def _cdf(self, x: NDArrayF, Qls: NDArrayF) -> NDArrayF: - xs = np.atleast_1d(x) - Qls = np.atleast_2d(Qls) # ({R}, L) - - # l = 0 - term0 = 0.5 * (xs + 1.0) # (T,) - # l = 1+ : non-symmetry - factor = 1.0 / (2.0 * Qls[:, 0]) # (R,) - term1p = np.sum( - (Qls[:, 1:] * difPls(xs, self._lmax - 1).T).T, - axis=0, - ) - # difPls shape (L, T) -> (T, L) - # term1p shape (R/T, L) -> (L, R/T) -> sum -> (R/T,) - - cdf = term0 + np.nan_to_num(factor * term1p) # (R/T,) - return cdf - - def _rvs( - self, - *args: Union[np.floating, ArrayLike], - size: Optional[int] = None, - random_state: RandomLike = None, - ) -> NDArrayF: - xs = super()._rvs(*args, size=size, random_state=random_state) - rvs = theta_of_x(xs) - return rvs - - def _ppf_to_solve(self, x: float, q: float, *args: Any) -> NDArrayF: - ppf: NDArrayF = self._cdf(*(x,) + args) - q - return ppf - - -class theta_fixed_distribution(theta_distribution_base): - """ - Sample inclination coordinate from an SCF potential. - - Parameters - ---------- - pot : `~galpy.potential.SCFPotential` - r : float or None, optional - If passed, these are the locations at which the theta CDF will be - evaluated. If None (default), then the r coordinate must be given - to the CDF and RVS functions. - **kw: - Not used. - """ - - def __init__(self, potential: SCFPotential, r: float, **kw: Any) -> None: - super().__init__(potential) - - # points at which CDF is defined - self._r = r - self._Qlsatr = thetaQls(self._potential, r) - - def _cdf(self, x: ArrayLike, *args: Any) -> NDArrayF: - cdf = super()._cdf(x, self._Qlsatr) - return cdf - - def cdf(self, theta: ArrayLike) -> NDArrayF: - """ - Cumulative distribution function of the given RV. - - Parameters - ---------- - theta : quantity-like['angle'] - - Returns - ------- - cdf : ndarray - Cumulative distribution function evaluated at `theta` - - """ - return self._cdf(x_of_theta(u.Quantity(theta, u.rad).value)) - - -class theta_distribution(theta_distribution_base): - """ - Sample inclination coordinate from an SCF potential. - - Parameters - ---------- - pot : `~galpy.potential.SCFPotential` - - """ - - def _cdf(self, theta: NDArrayF, *args: Any, r: Optional[float] = None) -> NDArrayF: - Qls = thetaQls(self._potential, cast(float, r)) - cdf = super()._cdf(theta, Qls) - return cdf - - def cdf(self, theta: ArrayLike, *args: Any, r: float) -> NDArrayF: - """ - Cumulative distribution function of the given RV. - - Parameters - ---------- - theta : quantity-like['angle'] - *args - Not used. - r : array-like[float] (optional, keyword-only) - - Returns - ------- - cdf : ndarray - Cumulative distribution function evaluated at `theta` - - """ - return self._cdf(x_of_theta(u.Quantity(theta, u.rad).value), *args, r=r) - - def rvs( - self, r: ArrayLike, *, size: Optional[int] = None, random_state: RandomLike = None - ) -> NDArrayF: - # not thread safe! - getattr(self._cdf, "__kwdefaults__", {})["r"] = r - vals = super().rvs(size=size, random_state=random_state) - getattr(self._cdf, "__kwdefaults__", {})["r"] = None - return vals diff --git a/sample_scf/exact/tests/__init__.py b/sample_scf/exact/tests/__init__.py new file mode 100644 index 0000000..8807810 --- /dev/null +++ b/sample_scf/exact/tests/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- +# Licensed under a 3-clause BSD style license - see LICENSE.rst +""" +This module contains package tests. +""" diff --git a/sample_scf/exact/tests/test_core.py b/sample_scf/exact/tests/test_core.py new file mode 100644 index 0000000..e885f1b --- /dev/null +++ b/sample_scf/exact/tests/test_core.py @@ -0,0 +1,135 @@ +# -*- coding: utf-8 -*- + +"""Tests for :mod:`sample_scf.exact.core`.""" + + +############################################################################## +# IMPORTS + +# THIRD PARTY +import astropy.units as u +import matplotlib.pyplot as plt +import numpy as np +import pytest + +# LOCAL +from .test_base_multivariate import BaseTest_SCFSamplerBase, radii, thetas, phis +from sampler_scf.base_multivariate import SCFSamplerBase +from sample_scf import ExactSCFSampler +from sample_scf.exact import exact_r_distribution, exact_theta_distribution, exact_phi_distribution + +############################################################################## +# CODE +############################################################################## + + +class Test_ExactSCFSampler(BaseTest_SCFSamplerBase): + """Test :class:`sample_scf.exact.ExactSCFSampler`.""" + + @pytest.fixture(scope="class") + def rv_cls(self): + return ExactSCFSampler + + def setup_class(self): + # TODO! make sure these are right! + self.expected_rvs = { + 0: dict(r=2.85831468, theta=1.473013568997 * u.rad, phi=4.49366731864 * u.rad), + 1: dict(r=2.85831468, theta=1.473013568997 * u.rad, phi=4.49366731864 * u.rad), + 2: dict( + r=[59.156720319468995, 2.8424809956410684, 71.71466505619023, 5.471148006577435], + theta=[0.365179487932, 1.476190768288, 0.3320725403573, 1.126711132015] * u.rad, + phi=[4.383959499105, 1.3577303436664, 6.134113310024, 0.039145847961457] * u.rad, + ), + } + + # =============================================================== + # Method Tests + + def test_init_attrs(self, sampler): + super().test_init_attrs(sampler) + + hasattr(sampler, "_sampler") + assert sampler._sampler is None or isinstance(sampler._sampler, SCFSamplerBase) + + # TODO! make sure these are correct + @pytest.mark.parametrize( + "r, theta, phi, expected", + [ + (0.0, 0.0, 0.0, [0, 0.5, 0]), + (1.0, 0.0, 0.0, [0.25, 0.5, 0]), + # ([0.0, 1.0], [0.0, 0.0], [0.0, 0.0], [[0, 0.5, 0], [0.25, 0.5, 0]]), + ], + ) + def test_cdf(self, sampler, r, theta, phi, expected): + """Test :meth:`sample_scf.base_multivariate.SCFSamplerBase.cdf`.""" + super().test_cdf(sampler, r, theta, phi, expected) + + @pytest.mark.skip("TODO!") + def test_rvs(self, sampler): + """Test Random Variates Sampler.""" + + # =============================================================== + # Plot Tests + + def test_exact_cdf_plot(self, sampler): + """Plot cdf.""" + kw = dict(marker="o", ms=5, c="k", zorder=5, label="CDF") + cdf = sampler.cdf(rgrid, tgrid, pgrid) + + fig = plt.figure(figsize=(15, 3)) + + # r + ax = fig.add_subplot( + 131, + title=r"$m(\leq r) / m_{tot}$", + xlabel="r", + ylabel=r"$m(\leq r) / m_{tot}$", + ) + ax.semilogx(rgrid, cdf[:, 0], **kw) + + # theta + ax = fig.add_subplot( + 132, + title=r"CDF($\theta$)", + xlabel=r"$\theta$", + ylabel=r"CDF($\theta$)", + ) + ax.plot(tgrid, cdf[:, 1], **kw) + + # phi + ax = fig.add_subplot( + 133, + title=r"CDF($\phi$)", + xlabel=r"$\phi$", + ylabel=r"CDF($\phi$)", + ) + ax.plot(pgrid, cdf[:, 2], **kw) + + return fig + + def test_exact_sampling_plot(self, sampler): + """Plot sampling.""" + samples = sampler.rvs(size=int(1e3), random_state=3) + + fig = plt.figure(figsize=(15, 4)) + + ax = fig.add_subplot( + 131, + title=r"$m(\leq r) / m_{tot}$", + xlabel="r", + ylabel=r"$m(\leq r) / m_{tot}$", + ) + ax.hist(samples.r.value[samples.r < 5e3], log=True, bins=50, density=True) + + ax = fig.add_subplot( + 132, + title=r"CDF($\theta$)", + xlabel=r"$\theta$", + ylabel=r"CDF($\theta$)", + ) + ax.hist(samples.theta.value, bins=50, density=True) + + ax = fig.add_subplot(133, title=r"CDF($\phi$)", xlabel=r"$\phi$", ylabel=r"CDF($\phi$)") + ax.hist(samples.phi.value, bins=50) + + return fig diff --git a/sample_scf/exact/tests/test_exact.py b/sample_scf/exact/tests/test_exact.py index 704be4b..007fd19 100644 --- a/sample_scf/exact/tests/test_exact.py +++ b/sample_scf/exact/tests/test_exact.py @@ -18,7 +18,6 @@ from .common import phi_distributionTestBase, r_distributionTestBase, theta_distributionTestBase from .test_base import SCFSamplerTestBase from sample_scf import conftest, exact -from sample_scf.utils import difPls, r_of_zeta, thetaQls, x_of_theta ############################################################################## # PARAMETERS @@ -63,9 +62,9 @@ def test_init(self, potentials): kw = {**self.cls_kwargs, **self.cls_pot_kw.get(potentials, {})} instance = self.cls(potentials, *self.cls_args, **kw) - assert isinstance(instance.rsampler, exact.r_distribution) - assert isinstance(instance.thetasampler, exact.theta_distribution) - assert isinstance(instance.phisampler, exact.phi_distribution) + assert isinstance(instance.r_distribution, exact.r_distribution) + assert isinstance(instance.theta_distribution, exact.theta_distribution) + assert isinstance(instance.phi_distribution, exact.phi_distribution) def test_rvs(self, sampler): """Test Random Variates Sampler.""" @@ -80,7 +79,7 @@ def test_rvs(self, sampler): ], ) def test_cdf(self, sampler, r, theta, phi, expected): - """Test :meth:`sample_scf.base.SCFSamplerBase.cdf`.""" + """Test :meth:`sample_scf.base_multivariate.SCFSamplerBase.cdf`.""" assert np.allclose(sampler.cdf(r, theta, phi), expected, atol=1e-16) # =============================================================== diff --git a/sample_scf/exact/tests/test_utils.py b/sample_scf/exact/tests/test_utils.py new file mode 100644 index 0000000..d94a493 --- /dev/null +++ b/sample_scf/exact/tests/test_utils.py @@ -0,0 +1,84 @@ +# -*- coding: utf-8 -*- + +"""Testing :mod:`scample_scf.interpolated.utils`.""" + + +############################################################################## +# IMPORTS + +# STDLIB +import contextlib + +# THIRD PARTY +import astropy.units as u +import numpy as np +import pytest +from numpy.testing import assert_allclose + +# LOCAL +from sample_scf.interpolated.utils import + +############################################################################## +# TESTS +############################################################################## + + +class Test_Qls: + """Test `sample_scf.base_univariate.Qls`.""" + + # =============================================================== + # Usage Tests + + @pytest.mark.parametrize("r, expected", [(0, 1), (1, 0.01989437), (np.inf, 0)]) + def test_hernquist(self, hernquist_scf_potential, r, expected): + Qls = thetaQls(hernquist_scf_potential, r=r) + # shape should be L (see setup_class) + assert len(Qls) == 6 + # only 1st index is non-zero + assert np.isclose(Qls[0], expected) + assert_allclose(Qls[1:], 0) + + @pytest.mark.skip("TODO!") + def test_nfw(self, nfw_scf_potential): + assert False + + +# ------------------------------------------------------------------- + + +class Test_phiScs: + + # =============================================================== + # Tests + + # @pytest.mark.skip("TODO!") + @pytest.mark.parametrize( + "r, theta, expected", + [ + # show it doesn't depend on theta + (0, -np.pi / 2, (np.zeros(5), np.zeros(5))), + (0, 0, (np.zeros(5), np.zeros(5))), # special case when x=0 is 0 + (0, np.pi / 6, (np.zeros(5), np.zeros(5))), + (0, np.pi / 2, (np.zeros(5), np.zeros(5))), + # nor on r + (1, -np.pi / 2, (np.zeros(5), np.zeros(5))), + (10, -np.pi / 4, (np.zeros(5), np.zeros(5))), + (100, np.pi / 6, (np.zeros(5), np.zeros(5))), + (1000, np.pi / 2, (np.zeros(5), np.zeros(5))), + # Legendre[n=0, l=0, z=z] = 1 is a special case + (1, 0, (np.zeros(5), np.zeros(5))), + (10, 0, (np.zeros(5), np.zeros(5))), + (100, 0, (np.zeros(5), np.zeros(5))), + (1000, 0, (np.zeros(5), np.zeros(5))), + ], + ) + def test_phiScs_hernquist(self, hernquist_scf_potential, r, theta, expected): + Rm, Sm = phiScs(hernquist_scf_potential, r, theta, warn=False) + assert Rm.shape == Sm.shape + assert Rm.shape == (1, 1, 6) + assert_allclose(Rm[0, 0, 1:], expected[0], atol=1e-16) + assert_allclose(Sm[0, 0, 1:], expected[1], atol=1e-16) + + if theta == 0 and r != 0: + assert Rm[0, 0, 0] != 0 + assert Sm[0, 0, 0] == 0 diff --git a/sample_scf/interpolated/__init__.py b/sample_scf/interpolated/__init__.py index f3ada6e..b3874ef 100644 --- a/sample_scf/interpolated/__init__.py +++ b/sample_scf/interpolated/__init__.py @@ -3,3 +3,10 @@ # LOCAL from .core import InterpolatedSCFSampler + +__all__ = [ + "InterpolatedSCFSampler", + "interpolated_r_distribution", + "interpolated_theta_distribution", + "interpolated_phi_distribution", +] diff --git a/sample_scf/interpolated/rvs_azimuth.py b/sample_scf/interpolated/azimuth.py similarity index 63% rename from sample_scf/interpolated/rvs_azimuth.py rename to sample_scf/interpolated/azimuth.py index 43f84a0..976aa2c 100644 --- a/sample_scf/interpolated/rvs_azimuth.py +++ b/sample_scf/interpolated/azimuth.py @@ -11,7 +11,7 @@ from __future__ import annotations -# BUILT-IN +# STDLIB import itertools import warnings from typing import Any, Optional, Union, cast @@ -25,11 +25,10 @@ # LOCAL from sample_scf._typing import NDArrayF, RandomLike -from sample_scf.base import rv_potential -from sample_scf.cdf_strategy import default_cdf_strategy -from sample_scf.utils import phiRSms, x_of_theta, zeta_of_r +from sample_scf.base_univariate import phi_distribution_base +from sample_scf.representation import x_of_theta, zeta_of_r -__all__ = ["phi_distribution"] +__all__ = ["interpolated_phi_distribution"] ############################################################################## @@ -37,19 +36,15 @@ ############################################################################## -class phi_distribution(rv_potential): +class interpolated_phi_distribution(phi_distribution_base): """SCF phi sampler. - .. todo:: - - Make sure that stuff actually goes from 0 to 1. - Parameters ---------- potential : `galpy.potential.SCFPotential` - rgrid : ndarray[float] - tgrid : ndarray[float] - pgrid : ndarray[float] + radii : ndarray[float] + thetas : ndarray[float] + phis : ndarray[float] intrp_step : float, optional **kw Passed to `scipy.stats.rv_continuous` @@ -59,50 +54,53 @@ class phi_distribution(rv_potential): def __init__( self, potential: SCFPotential, - rgrid: NDArrayF, - tgrid: NDArrayF, - pgrid: NDArrayF, - intrp_step: float = 0.01, + radii: Quantity, + thetas: Quantity, + phis: Quantity, + nintrp: float = 1e3, **kw: Any, ) -> None: - kw["a"], kw["b"] = 0, 2 * np.pi - (Rm, Sm) = kw.pop("RSms", (None, None)) + (Sc, Ss) = kw.pop("Scs", (None, None)) super().__init__(potential, **kw) # allowed range of r - self._phi_interpolant = np.arange(0, 2 * np.pi, intrp_step) + self._phi_interpolant = np.linspace(0, 2 * np.pi, int(nintrp)) << u.rad self._ninterpolant = len(self._phi_interpolant) self._q_interpolant = qarr = np.linspace(0, 1, self._ninterpolant) # ------- # build CDF - zetas = zeta_of_r(rgrid) # (R,) - xs = x_of_theta(tgrid) # (T,) + zetas = zeta_of_r(radii) # (R,) - lR, lT, _ = len(rgrid), len(tgrid), len(pgrid) + xs_unsorted = x_of_theta(thetas << u.rad) # (T,) + xsort = np.argsort(xs_unsorted) + xs = xs_unsorted[xsort] + thetas = thetas[xsort] - Phis = pgrid[None, None, :, None] # ({R}, {T}, P, {L}) + lR, lT, _ = len(radii), len(thetas), len(phis) - # get Rm, Sm. We have defaults from above. - if Rm is None: + Phis = phis[None, None, :, None] # ({R}, {T}, P, {L}) + + # get Sc, Ss. We have defaults from above. + if Sc is None: print("WTF?") - Rm, Sm = phiRSms(potential, rgrid, tgrid, grid=True, warn=False) # (R, T, L) - elif (Rm.shape != Sm.shape) or (Rm.shape != (lR, lT, self._lmax)): + Sc, Ss = self.calculate_Scs(radii, thetas, grid=True, warn=False) # (R, T, L) + elif (Sc.shape != Ss.shape) or (Sc.shape != (lR, lT, self._lmax + 1)): # check the user-passed values are the right shape - raise ValueError(f"Rm, Sm must be shape ({lR}, {lT}, {self._lmax})") + raise ValueError(f"Sc, Ss must be shape ({lR}, {lT}, {self._lmax + 1})") # l = 0 : spherical symmetry term0 = Phis[..., 0] / (2 * np.pi) # (1, 1, P) # l = 1+ : non-symmetry with warnings.catch_warnings(): # ignore true_divide RuntimeWarnings warnings.simplefilter("ignore") - factor = 1 / Rm[:, :, :1] # R0 (R, T, 1) # can be inf + factor = 1 / Sc[:, :, :1] # R0 (R, T, 1) # can be inf - ms = np.arange(1, self._lmax)[None, None, None, :] # ({R}, {T}, {P}, L) + ms = np.arange(1, self._lmax + 1)[None, None, None, :] # ({R}, {T}, {P}, L) term1p = np.sum( ( - (Rm[:, :, None, 1:] * np.sin(ms * Phis)) - + (Sm[:, :, None, 1:] * (1 - np.cos(ms * Phis))) + (Sc[:, :, None, 1:] * np.sin(ms * Phis)) + + (Ss[:, :, None, 1:] * (1 - np.cos(ms * Phis))) ) / (2 * np.pi * ms), axis=-1, @@ -113,15 +111,15 @@ def __init__( # interpolate # currently assumes a regular grid - self._spl_cdf = RegularGridInterpolator((zetas, xs, pgrid), cdfs) + self._spl_cdf = RegularGridInterpolator((zetas, xs, phis), cdfs) # ------- # ppf # might need cdf strategy to enforce "reality" - cdfstrategy = default_cdf_strategy.get() + # cdfstrategy = get_strategy(cdf_strategy) # start by supersampling - Zetas, Xs, Phis = np.meshgrid(zetas, xs, self._phi_interpolant, indexing="ij") + Zetas, Xs, Phis = np.meshgrid(zetas, xs, self._phi_interpolant.value, indexing="ij") _cdfs = self._spl_cdf((Zetas.ravel(), Xs.ravel(), Phis.ravel())).reshape( lR, lT, @@ -133,8 +131,10 @@ def __init__( try: spl = splrep(_cdfs[i, j, :], self._phi_interpolant, s=0) except ValueError: # CDF is non-real - _cdf = cdfstrategy.apply(_cdfs[i, j, :], index=(i, j)) - spl = splrep(_cdf, self._phi_interpolant, s=0) + import pdb + + pdb.set_trace() + raise ppfs[i, j, :] = splev(qarr, spl, ext=0) # interpolate @@ -144,44 +144,28 @@ def __init__( bounds_error=False, ) - def _cdf( - self, - phi: ArrayLike, - *args: Any, - zeta: ArrayLike, - x: ArrayLike, - ) -> NDArrayF: + def _cdf(self, phi: ArrayLike, *args: Any, zeta: ArrayLike, x: ArrayLike) -> NDArrayF: cdf: NDArrayF = self._spl_cdf((zeta, x, phi)) return cdf - def cdf( - self, - phi: ArrayLike, - r: ArrayLike, - theta: ArrayLike, - ) -> NDArrayF: + def cdf(self, phi: Quantity, *, r: Quantity, theta: Quantity) -> NDArrayF: # TODO! make sure r, theta in right domain cdf = self._cdf( phi, - zeta=zeta_of_r(r), - x=x_of_theta(u.Quantity(theta, u.rad)), + zeta=zeta_of_r(r, self._radial_scale_factor), + x=x_of_theta(theta << u.rad), ) return cdf - def _ppf( - self, - q: ArrayLike, - *args: Any, - r: ArrayLike, - theta: NDArrayF, - **kw: Any, - ) -> NDArrayF: - ppf: NDArrayF = self._spl_ppf((zeta_of_r(r), x_of_theta(theta), q)) + def _ppf(self, q: ArrayLike, *args: Any, r: ArrayLike, theta: NDArrayF, **kw: Any) -> NDArrayF: + zeta = zeta_of_r(r, self._radial_scale_factor) + x = x_of_theta(theta << u.rad) + ppf: NDArrayF = self._spl_ppf(np.c_[zeta, x, q]) return ppf def _rvs( self, - r: ArrayLike, + r: NDArrayF, theta: NDArrayF, *args: Any, random_state: np.random.RandomState, @@ -194,8 +178,8 @@ def _rvs( def rvs( # type: ignore self, - r: Union[np.floating, ArrayLike], - theta: Union[np.floating, ArrayLike], + r: Quantity, + theta: Quantity, *, size: Optional[int] = None, random_state: RandomLike = None, @@ -204,7 +188,8 @@ def rvs( # type: ignore Parameters ---------- - r, theta : array-like[float] + r : Quantity['length', float] + theta : Quantity['angle', float] size : int or None (optional, keyword-only) Size of random variates to generate. random_state : int, `~numpy.random.RandomState`, or None (optional, keyword-only) @@ -218,4 +203,4 @@ def rvs( # type: ignore ndarray[float] Shape 'size'. """ - return super().rvs(r, theta, size=size, random_state=random_state) + return super().rvs(r, theta, size=size, random_state=random_state) << u.rad diff --git a/sample_scf/interpolated/core.py b/sample_scf/interpolated/core.py index 50f9077..8fa3a77 100644 --- a/sample_scf/interpolated/core.py +++ b/sample_scf/interpolated/core.py @@ -11,23 +11,22 @@ from __future__ import annotations -# BUILT-IN +# STDLIB import warnings from typing import Any # THIRD PARTY import astropy.units as u import numpy as np +from numpy import nan_to_num, inf, sum, isinf, array from galpy.potential import SCFPotential # LOCAL +from .azimuth import interpolated_phi_distribution +from .inclination import interpolated_theta_distribution +from .radial import interpolated_r_distribution from sample_scf._typing import NDArrayF -from sample_scf.base import SCFSamplerBase -from sample_scf.utils import _grid_phiRSms - -from .rvs_azimuth import phi_distribution -from .rvs_inclination import theta_distribution -from .rvs_radial import r_distribution +from sample_scf.base_multivariate import SCFSamplerBase __all__ = ["InterpolatedSCFSampler"] @@ -43,20 +42,20 @@ class InterpolatedSCFSampler(SCFSamplerBase): Parameters ---------- pot : `~galpy.potential.SCFPotential` - rgrid : array-like[float] + radii : array-like[float] The radial component of the interpolation grid. - thetagrid : array-like[float] + thetas : array-like[float] The inclination component of the interpolation grid. :math:`\theta \in [-\pi/2, \pi/2]`, from the South to North pole, so :math:`\theta = 0` is the equator. - phigrid : array-like[float] + phis : array-like[float] The azimuthal component of the interpolation grid. :math:`phi \in [0, 2\pi)`. **kw: - passed to :class:`~sample_scf.sample_interp.r_distribution`, - :class:`~sample_scf.sample_interp.theta_distribution`, - :class:`~sample_scf.sample_interp.phi_distribution` + passed to :class:`~sample_scf.interpolated.interpolated_r_distribution`, + :class:`~sample_scf.interpolated.interpolated_theta_distribution`, + :class:`~sample_scf.interpolated.interpolated_phi_distribution` Examples -------- @@ -74,11 +73,11 @@ class InterpolatedSCFSampler(SCFSamplerBase): Now we make the sampler, specifying the grid from which the interpolation will be built. - >>> rgrid = np.geomspace(1e-1, 1e3, 100) - >>> thetagrid = np.linspace(-np.pi / 2, np.pi / 2, 30) - >>> phigrid = np.linspace(0, 2 * np.pi, 30) + >>> radii = np.geomspace(1e-1, 1e3, 100) + >>> thetas = np.linspace(-np.pi / 2, np.pi / 2, 30) + >>> phis = np.linspace(0, 2 * np.pi, 30) - >>> sampler = SCFSampler(pot, rgrid=rgrid, thetagrid=thetagrid, phigrid=phigrid) + >>> sampler = SCFSampler(pot, radii=radii, thetas=thetas, phis=phis) Now we can evaluate the CDF @@ -97,72 +96,51 @@ class InterpolatedSCFSampler(SCFSamplerBase): """ def __init__( - self, - potential: SCFPotential, - rgrid: NDArrayF, - thetagrid: NDArrayF, - phigrid: NDArrayF, - **kw: Any, + self, potential: SCFPotential, radii: Quantity, thetas: Quantity, phis: Quantity, **kw: Any ) -> None: - super().__init__(potential) - - # compute the r-dependent coefficient matrix $\tilde{\rho}$ - nmax, lmax = potential._Acos.shape[:2] - rhoTilde = np.array([potential._rhoTilde(r, N=nmax, L=lmax) for r in rgrid]) # (R, N, L) - # this matrix can have incorrect NaN values when rgrid=0, inf - # and needs to be corrected - ind = (rgrid == 0) | (rgrid == np.inf) - rhoTilde[ind] = np.nan_to_num( - rhoTilde[ind], - copy=False, - nan=0, - posinf=np.inf, - neginf=-np.inf, - ) + super().__init__(potential, **kw) + # coefficients + Acos: np.ndarray = potential._Acos + Asin: np.ndarray = potential._Asin - # ---------- - # theta Qls - # radial sums over $\cos$ portion of the density function - # the $\sin$ part disappears in the integral. + rsort = np.argsort(radii) + radii = radii[rsort] + # Compute the r-dependent coefficient matrix. + rhoT = self.calculate_rhoTilde(radii) + + # Compute the radial sums for inclination weighting factors. Qls = kw.pop("Qls", None) if Qls is None: - Qls = np.sum(potential._Acos[None, :, :, 0] * rhoTilde, axis=1) # ({R}, L) - # this matrix can have incorrect NaN values when rgrid=0 because - # rhoTilde will have +/- infs which when summed produce a NaN. - # at r=0 this can be changed to 0. # TODO! double confirm math - ind0 = rgrid == 0 - if not np.sum(np.nan_to_num(rhoTilde[ind0, :, 0], posinf=1, neginf=-1)) == 0: - # note: this if statement works even if ind0 is all False - warnings.warn("Qls have non-cancelling infinities at r==0") - else: - Qls[ind0] = np.nan_to_num(Qls[ind0], copy=False) + Qls = self.calculate_Qls(radii, rhoTilde=rhoT) # ---------- # phi Rm, Sm # radial and inclination sums - RSms = kw.pop("RSms", None) - if RSms is None: + Scs = kw.pop("Scs", None) + if Scs is None: with warnings.catch_warnings(): warnings.filterwarnings( "ignore", category=RuntimeWarning, message="(^invalid value)|(^overflow encountered)", ) - RSms = _grid_phiRSms( - rhoTilde, - Acos=potential._Acos, - Asin=potential._Asin, - r=rgrid, - theta=thetagrid, + Scs = interpolated_phi_distribution._grid_Scs( + radii, rhoT, Acos=Acos, Asin=Asin, theta=thetas ) # ---------- # make samplers - self._r_distribution = r_distribution(potential, rgrid, **kw) - self._theta_distribution = theta_distribution(potential, rgrid, thetagrid, Qls=Qls, **kw) - self._phi_distribution = phi_distribution( - potential, rgrid, thetagrid, phigrid, RSms=RSms, **kw + self._r_distribution = interpolated_r_distribution(potential, radii, **kw) + self._theta_distribution = interpolated_theta_distribution( + potential, radii, thetas, Qls=Qls, **kw + ) + self._phi_distribution = interpolated_phi_distribution( + potential, radii, thetas, phis, Scs=Scs, **kw ) + + @property + def _Qls(self) -> NDArrayF: + return self._theta_distribution._Qls diff --git a/sample_scf/interpolated/rvs_inclination.py b/sample_scf/interpolated/inclination.py similarity index 55% rename from sample_scf/interpolated/rvs_inclination.py rename to sample_scf/interpolated/inclination.py index e06ab23..9922289 100644 --- a/sample_scf/interpolated/rvs_inclination.py +++ b/sample_scf/interpolated/inclination.py @@ -11,7 +11,7 @@ from __future__ import annotations -# BUILT-IN +# STDLIB import itertools import warnings from typing import Any, Optional, Union, cast @@ -25,10 +25,11 @@ # LOCAL from sample_scf._typing import NDArrayF, RandomLike -from sample_scf.base import rv_potential -from sample_scf.utils import difPls, phiRSms, thetaQls, x_of_theta, zeta_of_r +from sample_scf.base_univariate import theta_distribution_base +from sample_scf.exact.inclination import exact_theta_distribution_base +from sample_scf.representation import x_of_theta, zeta_of_r -__all__ = ["theta_distribution"] +__all__ = ["interpolated_theta_distribution"] ############################################################################## @@ -36,81 +37,81 @@ ############################################################################## -class theta_distribution(rv_potential): +class interpolated_theta_distribution(theta_distribution_base): """ Sample inclination coordinate from an SCF potential. Parameters ---------- pot : `~galpy.potential.SCFPotential` - rgrid, tgrid : ndarray + radii : (R,) |Quantity| + Must be in correct sort order + thetas : (T, ) ndarray ['radian'] or Quantity ['angle'] + intrp_step : float, optional + Interpolation step. **kw Passed to `scipy.stats.rv_continuous` - "a", "b" are set to [-pi/2, pi/2] + "a", "b" are set to [0, pi] """ def __init__( self, potential: SCFPotential, - rgrid: NDArrayF, - tgrid: NDArrayF, - intrp_step: float = 0.01, + radii: Quantity, + thetas: Quantity, + nintrp: float = 1e3, **kw: Any, ) -> None: - kw["a"], kw["b"] = -np.pi / 2, np.pi / 2 Qls: NDArrayF = kw.pop("Qls", None) super().__init__(potential, **kw) # allowed range of theta - self._theta_interpolant = np.arange(-np.pi / 2, np.pi / 2, intrp_step) + self._theta_interpolant = np.linspace(0, np.pi, num=int(nintrp)) << u.rad self._x_interpolant = x_of_theta(self._theta_interpolant) self._q_interpolant = np.linspace(0, 1, len(self._theta_interpolant)) - self._lrange = np.arange(0, self._lmax + 1) + zetas_unsorted = zeta_of_r(radii, scale_radius=self.radial_scale_factor) # (R,) + rsort = np.argsort(zetas_unsorted) + zetas = zetas_unsorted[rsort] # ------- # build CDF in shells - # TODO: clean up shape stuff - - zetas = zeta_of_r(rgrid) # (R,) - xs = x_of_theta(tgrid) # (T,) - - Qls = Qls if Qls is not None else thetaQls(potential, rgrid) - # check it's the right shape (R, Lmax) - if Qls.shape != (len(rgrid), self._lmax): - raise ValueError(f"Qls must be shape ({len(rgrid)}, {self._lmax})") - - # l = 0 : spherical symmetry - term0 = cast(NDArrayF, 0.5 * (xs + 1.0)) # (T,) - # l = 1+ : non-symmetry - factor = 1.0 / (2.0 * Qls[:, 0]) # (R,) - term1p = np.sum( - (Qls[None, :, 1:] * difPls(xs, self._lmax - 1).T[:, None, :]).T, - axis=0, - ) - cdfs = term0[None, :] + np.nan_to_num(factor[:, None] * term1p) # (R, T) + xs = np.sort(x_of_theta(thetas << u.rad)) # (T,) sorted for interpolation + Qls = Qls[rsort, :] if Qls is not None else self.calculate_Qls(radii) + # check it's the right shape (R, L) + if Qls.shape != (len(radii), self._lmax + 1): + raise ValueError(f"Qls must be shape ({len(radii)}, {self._lmax + 1})") + self._Qls: NDArrayF = Qls + + # calculate the CDFs exactly # TODO! cleanup + cdfs = exact_theta_distribution_base._cdf(self, xs, Qls) # (R, T) # ------- # interpolate # currently assumes a regular grid - self._spl_cdf = RectBivariateSpline( + self._spl_cdf = RectBivariateSpline( # (R, T) zetas, xs, - cdfs, - bbox=[-1, 1, -1, 1], # [zetamin, zetamax, xmin, xmax] - kx=kw.get("kx", 3), - ky=kw.get("ky", 3), + cdfs, # (R, T) is anti-theta ordered + bbox=[-1, 1, -1, 1], # [min(zeta), max(zeta), min(x), max(x)] + kx=kw.get("kx", 2), + ky=kw.get("ky", 2), s=kw.get("s", 0), ) - # ppf, one per r + self._zetas = zetas # FIXME! + # return + + # ppf, one per r, supersampled # TODO! see if can use this to avoid resplining - _cdfs = self._spl_cdf(zetas, self._x_interpolant) - spls = [ # work through the rs - splrep(_cdfs[i, :], self._theta_interpolant, s=0) for i in range(_cdfs.shape[0]) - ] + _cdfs = self._spl_cdf(zetas, self._x_interpolant[::-1], grid=True) + spls = ( # work through the (R, T) is anti-theta ordered + splrep(_cdfs[i, ::-1], self._theta_interpolant.value, s=0) + for i in range(_cdfs.shape[0]) + ) # TODO! as generator ppfs = np.array([splev(self._q_interpolant, spl, ext=0) for spl in spls]) + self._spl_ppf = RectBivariateSpline( zetas, self._q_interpolant, @@ -125,29 +126,24 @@ def _cdf(self, x: ArrayLike, *args: Any, zeta: ArrayLike, **kw: Any) -> NDArrayF cdf: NDArrayF = self._spl_cdf(zeta, x, grid=False) return cdf - def cdf(self, theta: ArrayLike, r: ArrayLike) -> NDArrayF: + def cdf(self, theta: Quantity, r: ArrayLike) -> NDArrayF: """Cumulative Distribution Function. Parameters ---------- - theta : array-like or Quantity-like - r : array-like or Quantity-like + theta : (T,) Quantity['angle'] + r : (R,) Quantity['length'] Returns ------- cdf : ndarray[float] """ - # TODO! make sure r, theta in right domain - cdf = self._cdf(x_of_theta(u.Quantity(theta, u.rad)), zeta=zeta_of_r(r)) + x = x_of_theta(theta << u.rad) + zeta = zeta_of_r(r, scale_radius=self.radial_scale_factor) + cdf = self._cdf(x, zeta=zeta) return cdf - def _ppf( - self, - q: ArrayLike, - *, - r: ArrayLike, - **kw: Any, - ) -> NDArrayF: + def _ppf(self, q: ArrayLike, *, r: ArrayLike, **kw: Any) -> NDArrayF: """Percent-point function. Parameters @@ -160,28 +156,29 @@ def _ppf( float or (N,) array-like[float] Same shape as 'r', 'q'. """ - ppf: NDArrayF = self._spl_ppf(zeta_of_r(r), q, grid=False) + zeta = zeta_of_r(r, scale_radius=self.radial_scale_factor) + ppf: NDArrayF = self._spl_ppf(zeta, q, grid=False) return ppf def _rvs( self, - r: ArrayLike, + r: Quantity, *, - random_state: Union[np.random.RandomState, np.random.Generator], size: Optional[int] = None, + random_state: Union[np.random.RandomState, np.random.Generator], + # return_thetas: bool = True, # TODO! ) -> NDArrayF: """Random variate sampling. Parameters ---------- - r : float or (N,) array-like[float] - size : int (optional, keyword-only) + r : (R,) Quantity['length', float] + size : int or None (optional, keyword-only) random_state : int or None (optional, keyword-only) Returns ------- - float or array-like[float] - Shape 'size'. + (size,) array-like[float] """ # Use inverse cdf algorithm for RV generation. U = random_state.uniform(size=size) @@ -190,22 +187,22 @@ def _rvs( def rvs( # type: ignore self, - r: ArrayLike, + r: Quantity, *, size: Optional[int] = None, random_state: RandomLike = None, - ) -> NDArrayF: + ) -> Quantity: """Random variate sampling. Parameters ---------- - r : float or (N,) array-like[float] + r : (R,) Quantity['length', float] size : int or None (optional, keyword-only) random_state : int or None (optional, keyword-only) Returns ------- - float or array-like[float] + (R, size) Quantity[float] Shape 'size'. """ - return super().rvs(r, size=size, random_state=random_state) + return super().rvs(r, size=size, random_state=random_state) << u.rad diff --git a/sample_scf/interpolated/radial.py b/sample_scf/interpolated/radial.py new file mode 100644 index 0000000..de06d8d --- /dev/null +++ b/sample_scf/interpolated/radial.py @@ -0,0 +1,106 @@ +# -*- coding: utf-8 -*- + +"""Radial sampling.""" + +############################################################################## +# IMPORTS + +from __future__ import annotations + +# STDLIB +from typing import Any + +# THIRD PARTY +import astropy.units as u +import numpy as np +from galpy.potential import SCFPotential +from numpy.typing import ArrayLike +from scipy.interpolate import InterpolatedUnivariateSpline + +# LOCAL +from sample_scf._typing import NDArrayF +from sample_scf.base_univariate import r_distribution_base +from sample_scf.representation import FiniteSphericalRepresentation, zeta_of_r, r_of_zeta + +__all__ = ["interpolated_r_distribution"] + + +############################################################################## +# CODE +############################################################################## + +def calculate_mass_cdf(potential: SCFPotential, radii: Quantity) -> NDArrayF: + + rgalpy = radii.to_value(u.kpc) / potential._ro # FIXME! wrong scaling + mgrid = np.array([potential._mass(x) for x in rgalpy]) # :( + # manual fixes for endpoints and normalization + ind = np.where(np.isnan(mgrid))[0] + mgrid[ind[radii[ind] == 0]] = 0 + mgrid = (mgrid - np.nanmin(mgrid)) / (np.nanmax(mgrid) - np.nanmin(mgrid)) # rescale + infind = ind[radii[ind] == np.inf].squeeze() + mgrid[infind] = 1 + if mgrid[infind - 1] == 1: # munge the rescaling TODO! do better + mgrid[infind - 1] -= min(1e-8, np.diff(mgrid[slice(infind - 2, infind)]) / 2) + + return mgrid + + +class interpolated_r_distribution(r_distribution_base): + """Sample radial coordinate from an SCF potential. + + The potential must have a convergent mass function. + + Parameters + ---------- + potential : `galpy.potential.SCFPotential` + radii : Quantity + Radii at which to interpolate. + **kw + Passed to `scipy.stats.rv_continuous` + "a", "b" are set to [0, inf] + """ + + _interp_in_zeta: bool + + def __init__( + self, potential: SCFPotential, radii: Quantity, **kw: Any) -> None: + kw["a"], kw["b"] = 0, np.nanmax(radii) # allowed range of r + super().__init__(potential, **kw) + + ### fraction of total mass grid ### + # work in zeta, not r, since it is more numerically stable + zetas_unsorted = zeta_of_r(radii, scale_radius=self.radial_scale_factor) # (R,) + rsort = np.argsort(zetas_unsorted) + zetas = zetas_unsorted[rsort] + + mgrid = calculate_mass_cdf(potential, radii[rsort]) + + ### splines ### + # make splines for fast calculation + self._spl_cdf = InterpolatedUnivariateSpline( + zetas, + mgrid, + ext="raise", + bbox=[-1, 1], + k=1, + ) + self._spl_ppf = InterpolatedUnivariateSpline( + mgrid, + zetas, + ext="raise", + bbox=[0, 1], + k=1, + ) + + def cdf(self, radii: Quantity): # TODO! + return self._cdf(zeta_of_r(radii, self.radial_scale_factor)) + + def _cdf(self, zeta: NDArrayF, *args: Any, **kw: Any) -> NDArrayF: + cdf: NDArrayF = self._spl_cdf(zeta) + # (self._scfmass(zeta) - self._mi) / (self._mf - self._mi) + # TODO! is this normalization even necessary? + return cdf + + def _ppf(self, q: ArrayLike, *args: Any, **kw: Any) -> NDArrayF: + zeta = self._spl_ppf(q) + return r_of_zeta(zeta, self.radial_scale_factor) # TODO! not convert in private function diff --git a/sample_scf/interpolated/rvs_radial.py b/sample_scf/interpolated/rvs_radial.py deleted file mode 100644 index 539678c..0000000 --- a/sample_scf/interpolated/rvs_radial.py +++ /dev/null @@ -1,92 +0,0 @@ -# -*- coding: utf-8 -*- - -"""**DOCSTRING**. - -Description. - -""" - -############################################################################## -# IMPORTS - -from __future__ import annotations - -# BUILT-IN -from typing import Any - -# THIRD PARTY -import numpy as np -from galpy.potential import SCFPotential -from numpy.typing import ArrayLike -from scipy.interpolate import InterpolatedUnivariateSpline - -# LOCAL -from sample_scf._typing import NDArrayF -from sample_scf.base import r_distribution_base -from sample_scf.utils import r_of_zeta, zeta_of_r - -__all__ = ["r_distribution"] - - -############################################################################## -# CODE -############################################################################## - - -class r_distribution(r_distribution_base): - """Sample radial coordinate from an SCF potential. - - The potential must have a convergent mass function. - - Parameters - ---------- - potential : `galpy.potential.SCFPotential` - rgrid : ndarray - **kw - Passed to `scipy.stats.rv_continuous` - "a", "b" are set to [0, inf] - """ - - def __init__(self, potential: SCFPotential, rgrid: NDArrayF, **kw: Any) -> None: - kw["a"], kw["b"] = 0, np.nanmax(rgrid) # allowed range of r - super().__init__(potential, **kw) - - mgrid = np.array([potential._mass(x) for x in rgrid]) # :( - # manual fixes for endpoints and normalization - ind = np.where(np.isnan(mgrid))[0] - mgrid[ind[rgrid[ind] == 0]] = 0 - mgrid = (mgrid - np.nanmin(mgrid)) / (np.nanmax(mgrid) - np.nanmin(mgrid)) # rescale - infind = ind[rgrid[ind] == np.inf].squeeze() - mgrid[infind] = 1 - if mgrid[infind - 1] == 1: # munge the rescaling TODO! do better - mgrid[infind - 1] -= min(1e-8, np.diff(mgrid[slice(infind - 2, infind)]) / 2) - - # work in zeta, not r, since it is more numerically stable - zeta = zeta_of_r(rgrid) - # make splines for fast calculation - self._spl_cdf = InterpolatedUnivariateSpline( - zeta, - mgrid, - ext="raise", - bbox=[-1, 1], - ) - self._spl_ppf = InterpolatedUnivariateSpline( - mgrid, - zeta, - ext="raise", - bbox=[0, 1], - ) - - # TODO! make sure - # # store endpoint values to ensure CDF normalized to [0, 1] - # self._mi = self._spl_cdf(min(zeta)) - # self._mf = self._spl_cdf(max(zeta)) - - def _cdf(self, r: ArrayLike, *args: Any, **kw: Any) -> NDArrayF: - cdf: NDArrayF = self._spl_cdf(zeta_of_r(r)) - # (self._scfmass(zeta) - self._mi) / (self._mf - self._mi) - # TODO! is this normalization even necessary? - return cdf - - def _ppf(self, q: ArrayLike, *args: Any, **kw: Any) -> NDArrayF: - return r_of_zeta(self._spl_ppf(q)) diff --git a/sample_scf/interpolated/tests/__init__.py b/sample_scf/interpolated/tests/__init__.py new file mode 100644 index 0000000..8807810 --- /dev/null +++ b/sample_scf/interpolated/tests/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- +# Licensed under a 3-clause BSD style license - see LICENSE.rst +""" +This module contains package tests. +""" diff --git a/sample_scf/interpolated/tests/test_interpolated.py b/sample_scf/interpolated/tests/test_interpolated.py index 7463477..66f9619 100644 --- a/sample_scf/interpolated/tests/test_interpolated.py +++ b/sample_scf/interpolated/tests/test_interpolated.py @@ -16,9 +16,12 @@ # LOCAL from .common import phi_distributionTestBase, r_distributionTestBase, theta_distributionTestBase -from .test_base import RVPotentialTest, SCFSamplerTestBase -from sample_scf import conftest, interpolated -from sample_scf.utils import phiRSms, r_of_zeta, thetaQls, x_of_theta, zeta_of_r +from .test_base import BaseTest_rv_potential, SCFSamplerTestBase +from sample_scf.representation import x_of_theta +from sample_scf.interpolated import InterpolatedSCFSampler +from sample_scf.interpolated.radial import r_distribution +from sample_scf.interpolated.inclination import theta_distribution +from sample_scf.interpolated.azimuth import phi_distribution ############################################################################## # PARAMETERS @@ -39,7 +42,7 @@ class Test_SCFSampler(SCFSamplerTestBase): def setup_class(self): super().setup_class(self) - self.cls = interpolated.SCFSampler + self.cls = InterpolatedSCFSampler self.cls_args = (rgrid, tgrid, pgrid) self.cls_kwargs = {} self.cls_pot_kw = {} @@ -68,7 +71,7 @@ def setup_class(self): ], ) def test_cdf(self, sampler, r, theta, phi, expected): - """Test :meth:`sample_scf.base.SCFSamplerBase.cdf`.""" + """Test :meth:`sample_scf.base_multivariate.SCFSamplerBase.cdf`.""" assert np.allclose(sampler.cdf(r, theta, phi), expected, atol=1e-16) # =============================================================== @@ -86,7 +89,7 @@ def test_interp_sampling_plot(self): ############################################################################## -class InterpRVPotentialTest(RVPotentialTest): +class InterpBaseTest_rv_potential(BaseTest_rv_potential): def test_init(self, sampler): """Test initialization.""" potential = sampler._potential @@ -123,13 +126,13 @@ def test_init(self, sampler): # ---------------------------------------------------------------------------- -class Test_r_distribution(r_distributionTestBase, InterpRVPotentialTest): +class Test_r_distribution(r_distributionTestBase, InterpBaseTest_rv_potential): """Test :class:`sample_scf.sample_interp.r_distribution`""" def setup_class(self): super().setup_class(self) - self.cls = interpolated.r_distribution + self.cls = r_distribution self.cls_args = (rgrid,) self.cls_kwargs = {} self.cls_pot_kw = {} @@ -261,13 +264,13 @@ def test_interp_r_sampling_plot(self, sampler): # ---------------------------------------------------------------------------- -class Test_theta_distribution(theta_distributionTestBase, InterpRVPotentialTest): +class Test_theta_distribution(theta_distributionTestBase, InterpBaseTest_rv_potential): """Test :class:`sample_scf.interpolated.theta_distribution`.""" def setup_class(self): super().setup_class(self) - self.cls = interpolated.theta_distribution + self.cls = theta_distribution self.cls_args = (rgrid, tgrid) self.cdf_time_scale = 3e-4 @@ -322,7 +325,7 @@ def test_cdf(self, sampler, theta, r): """Test :meth:`sample_scf.interpolated.theta_distribution.cdf`.""" assert_allclose( sampler.cdf(theta, r), - sampler._spl_cdf(zeta_of_r(r), x_of_theta(u.Quantity(theta, u.rad)), grid=False), + sampler._spl_cdf(FiniteSphericalRepresentation.calculate_zeta_of_r(r), x_of_theta(u.Quantity(theta, u.rad)), grid=False), ) @pytest.mark.skip("TODO!") @@ -426,13 +429,13 @@ def test_interp_theta_sampling_plot(self, sampler): # ---------------------------------------------------------------------------- -class Test_phi_distribution(phi_distributionTestBase, InterpRVPotentialTest): +class Test_phi_distribution(phi_distributionTestBase, InterpBaseTest_rv_potential): """Test :class:`sample_scf.interpolated.phi_distribution`.""" def setup_class(self): super().setup_class(self) - self.cls = interpolated.phi_distribution + self.cls = phi_distribution self.cls_args = (rgrid, tgrid, pgrid) self.cdf_time_scale = 12e-4 @@ -446,9 +449,9 @@ def test_init(self, sampler): # super().test_init(sampler) # doesn't work TODO! # a shape mismatch - RSms = phiRSms(sampler._potential, rgrid[1:-1], tgrid[1:-1], warn=False) + Scs = phiScs(sampler._potential, rgrid[1:-1], tgrid[1:-1], warn=False) with pytest.raises(ValueError, match="Rm, Sm must be shape"): - sampler.__class__(sampler._potential, rgrid, tgrid, pgrid, RSms=RSms) + sampler.__class__(sampler._potential, rgrid, tgrid, pgrid, Scs=Scs) @pytest.mark.skip("TODO!") def test__cdf(self): diff --git a/sample_scf/representation.py b/sample_scf/representation.py new file mode 100644 index 0000000..6509a86 --- /dev/null +++ b/sample_scf/representation.py @@ -0,0 +1,497 @@ +# -*- coding: utf-8 -*- + +"""Utility functions.""" + +############################################################################## +# IMPORTS + +from __future__ import annotations + +# STDLIB +import warnings +from inspect import isclass +from contextlib import nullcontext +from functools import singledispatch +from typing import Optional, Tuple, Union, overload + +# THIRD PARTY +import astropy.units as u +from erfa import ufunc as erfa_ufunc +from astropy.units import rad +import numpy as np +import numpy.typing as npt +from astropy.units import Quantity, UnitConversionError +from numpy import arccos, array, atleast_1d, cos, divide, nan_to_num +from numpy.typing import ArrayLike +from astropy.coordinates import ( + Angle, + BaseRepresentation, + PhysicsSphericalRepresentation, + SphericalRepresentation, + UnitSphericalRepresentation, + Distance, + CartesianRepresentation, +) + +# LOCAL +from ._typing import NDArrayF + +__all__ = ["FiniteSphericalRepresentation"] + +############################################################################## +# CODE +############################################################################## + +@singledispatch +def _zeta_of_r(r: Union[ArrayLike, Quantity], /, scale_radius: Union[NDArrayF, Quantity, None]=None) -> NDArrayF: + # Default implementation, unless there's a registered specific method. + # -------------- + # Checks: r must be non-negative, and the scale radius must be None or positive + if np.any(np.less(r, 0)): + raise ValueError("r must be >= 0") + elif scale_radius is not None: + if isinstance(scale_radius, Quantity): + if scale_radius.unit.physical_type == "dimensionless": + scale_radius = scale_radius.value + else: + raise TypeError("scale radius cannot be a Quantity") + elif scale_radius <= 0: + raise ValueError("scale_radius must be > 0") + + # Calculation + a: Quantity = scale_radius if scale_radius is not None else 1 + r_a: Quantity = np.divide(r, a) + zeta: NDArrayF = nan_to_num(divide(r_a - 1, r_a + 1), nan=1.0) + # TODO! fix with degeneracy in NaN when not due to division. + return zeta + +@overload +@_zeta_of_r.register +def zeta_of_r(r: Quantity, /, scale_radius=None) -> NDArrayF: + # Checks: r must be a non-negative length-type quantity, and the scale + # radius must be None or a positive length-type quantity. + if r.unit.physical_type != "length": + raise UnitConversionError("r must have units of length") + elif np.any(r < 0): + raise ValueError("r must be >= 0") + elif scale_radius is not None: + if not isinstance(scale_radius, Quantity): + raise TypeError("scale_radius must be a Quantity") + if scale_radius.unit.physical_type != "length": + raise UnitConversionError("scale_radius must have units of length") + elif scale_radius <= 0: + raise ValueError("scale_radius must be > 0") + + a: Quantity = scale_radius if scale_radius is not None else 1 * r.unit + r_a: Quantity = r / a + zeta: NDArrayF = nan_to_num(divide(r_a - 1, r_a + 1), nan=1.0) + # TODO! fix with degeneracy in NaN when not due to division. + return zeta.value + + +def zeta_of_r(r: Union[NDArrayF, Quantity], /, scale_radius: Optional[Quantity]=None) -> NDArrayF: + r""":math:`\zeta(r) = \frac{r/a - 1}{r/a + 1}`. + + Map the half-infinite domain [0, infinity) -> [-1, 1]. + + Parameters + ---------- + r : (R,) Quantity['length'], position-only + scale_radius : Quantity['length'] or None, optional + If None (default), taken to be 1 in the units of `r`. + + Returns + ------- + (R,) array[floating] + + Raises + ------ + TypeError + If `r` is a Quantity and scale radius is not a Quantity. + If `r` is not a Quantity and scale radius is a Quantity. + UnitConversionError + If `r` is a Quantity but does not have units of length. + If `r` is a Quantity and `scale_radius` is not None and does not have + units of length. + ValueError + If `r` is less than 0. + If `scale_radius` is not None and is less than or equal to 0. + """ + return _zeta_of_r(r, scale_radius=scale_radius) + + +zeta_of_r.__wrapped__ = _zeta_of_r # For easier access. + + +# ------------------------------------------------------------------- + + +def r_of_zeta(zeta: ndarray, /, scale_radius: Union[float, np.floating, Quantity, None] = None +) -> Union[NDArrayF, Quantity]: + r""":math:`r = \frac{1 + \zeta}{1 - \zeta}`. + + Map back to the half-infinite domain [0, infinity) <- [-1, 1]. + + Parameters + ---------- + zeta : (R,) array[floating] or (R,) Quantity['dimensionless'], position-only + scale_radius : Quantity['length'] or None, optional + + Returns + ------- + (R,) ndarray[float] or (R,) Quantity['length'] + A |Quantity| if scale_radius is not None, else a `numpy.ndarray`. + + Raises + ------ + UnitConversionError + If `scale_radius` is a |Quantity|, but does not have units of length. + ValueError + If `zeta` is not in [-1, 1]. + If `scale_radius` not in (0, `numpy.inf`). + + Warnings + -------- + RuntimeWarning + If zeta is 1 (r is `numpy.inf`). Don't worry, it's not a problem. + """ + if np.any(zeta < -1) or np.any(zeta > 1): + raise ValueError("zeta must be in [-1, 1].") + elif scale_radius <= 0 or not np.isfinite(scale_radius): + raise ValueError("scale_radius must be in (0, inf).") + elif isinstance(scale_radius, Quantity) and scale_radius.unit.physical_type != "length": + raise UnitConversionError("scale_radius must have units of length") + + r: NDArrayF = atleast_1d(divide(1 + zeta, 1 - zeta)) + r[r < 0] = 0 # correct small errors + rq: Union[NDArrayF, Quantity] + rq = r * scale_radius if scale_radius is not None else r + return rq + + +# ------------------------------------------------------------------- + + +def x_of_theta(theta: Union[ndarray, Quantity["angle"]]) -> NDArrayF: + r""":math:`x = \cos{\theta}`. + + Parameters + ---------- + theta : (T,) Quantity['angle'] or array['radian'] + + Returns + ------- + float or (T,) ndarray[floating] + """ + x: NDArrayF = cos(theta) + xval = x if not isinstance(x, Quantity) else x.value + return xval + + +# ------------------------------------------------------------------- + + +def theta_of_x(x: ArrayLike, unit=u.rad) -> Quantity: + r""":math:`\theta = \cos^{-1}{x}`. + + Parameters + ---------- + x : array-like + unit : unit-like['angular'], optional + Output units. + + Returns + ------- + theta : float or ndarray + """ + th: NDArrayF = arccos(x) << u.rad + theta = th << unit + return theta + + +########################################################################### + +class FiniteSphericalRepresentation(BaseRepresentation): + r""" + Representation of points in 3D spherical coordinates (using the physics + convention for azimuth and inclination from the pole) where the radius and + inclination are rescaled to be on [-1, 1]. + + .. math:: + + \zeta = \frac{1 - r / a}{1 + r/a} + x = \cos(\theta) + + .. todo:: + + Make the scale radius optional by not decomposing, so zeta = 1 [unit] / [scale unit] (ie. dimensionless **scaled**) + Unles the scale radius is passed, in which case decompose to dimensionless_unscaled + """ + + _phi: Quantity + _x: NDArrayF + _zeta: NDArrayF + _scale_radius: Union[NDArrayF, Quantity] + + attr_classes: Dict[str, Type[Quantity]] = {"phi": Angle, "x": Quantity, "zeta": Quantity} + + def __init__( + self, + phi: Quantity, + x: Union[NDArrayF, Quantity, None] = None, + zeta: Union[NDArrayF, Quantity, None] = None, + scale_radius: Optional[Quantity] = None, + differentials: Union[BaseDifferential, Dict[str, BaseDifferential]] = None, + copy: bool = True, + ): + # Adjustments if passing unitful quantities + if hasattr(x, "unit") and x.unit.physical_type == "angle": + x = x_of_theta(x) + if hasattr(zeta, "unit") and zeta.unit.physical_type == "length": + if scale_radius is None: + scale_radius = 1 * zeta.unit + zeta = zeta_of_r(zeta, scale_radius=scale_radius) + elif scale_radius is None: + raise ValueError("if zeta is not a length, a scale_radius must given") + + super().__init__(phi, x, zeta, copy=copy, differentials=differentials) + self._scale_radius = scale_radius + + # Wrap/validate phi/theta + # Note that _phi already holds our own copy if copy=True. + self._phi.wrap_at(360 * u.deg, inplace=True) + + if np.any(self._x < -1) or np.any(self._x > 1): + raise ValueError(f"inclination angle(s) must be within -1 <= angle <= 1, got {x}") + + if np.any(self._zeta < -1) or np.any(self._zeta > 1): + raise ValueError(f"distances must be within -1 <= zeta <= 1, got {zeta}") + + @property + def phi(self) -> Quantity: + """The azimuth of the point(s).""" + return self._phi + + @property + def x(self) -> Quantity: + """The elevation of the point(s).""" + return self._x + + @property + def zeta(self) -> Quantity: + """The distance from the origin to the point(s).""" + return self._zeta + + @property + def scale_radius(self) -> Union[NDArrayF, Quantity]: + return self._scale_radius + + # ----------------------------------------------------- + # corresponding PhysicsSpherical coordinates + + @property + def theta(self) -> Quantity: + """The elevation of the point(s).""" + return self.calculate_theta_of_x(self._x) + + @property + def r(self) -> Union[NDArrayF, Quantity]: + """The distance from the origin to the point(s).""" + return Distance(self.calculate_r_of_zeta(self._zeta), copy=False) + + # ----------------------------------------------------- + # conversion functions + + def calculate_zeta_of_r(self, r: Union[NDArrayF, Quantity], /) -> NDArrayF: + r""":math:`\zeta(r) = \frac{r/a - 1}{r/a + 1}`. + + Map the half-infinite domain [0, infinity) -> [-1, 1]. + + Parameters + ---------- + r : (R,) Quantity['length'], position-only + + Returns + ------- + (R,) array[floating] + + See Also + -------- + sample_scf.representation.zeta_of_r + """ + return zeta_of_r(r, scale_radius=self.scale_radius) + + def calculate_r_of_zeta(self, zeta: ndarray, /) -> Union[NDArrayF, Quantity]: + r""":math:`r = \frac{1 + \zeta}{1 - \zeta}`. + + Map back to the half-infinite domain [0, infinity) <- [-1, 1]. + + Parameters + ---------- + zeta : (R,) array[floating] or (R,) Quantity['dimensionless'], position-only + + Returns + ------- + (R,) ndarray[float] or (R,) Quantity['length'] + A |Quantity| if scale_radius is not None, else a `numpy.ndarray`. + + See Also + -------- + sample_scf.representation.r_of_zeta + """ + return r_of_zeta(zeta, scale_radius=self.scale_radius) + + def calculate_x_of_theta(self, theta: Quantity) -> NDArrayF: + r""":math:`x = \cos{\theta}`. + + Parameters + ---------- + theta : (T,) Quantity['angle'] or array['radian'] + + Returns + ------- + float or (T,) ndarray[floating] + """ + return x_of_theta(theta) + + def calculate_theta_of_x(self, x: ArrayLike) -> Quantity: + r""":math:`\theta = \cos^{-1}{x}`. + + Parameters + ---------- + x : array-like + unit : unit-like['angular'] or None, optional + Output units. + + Returns + ------- + theta : float or ndarray + """ + return theta_of_x(x) + + # ----------------------------------------------------- + + # def unit_vectors(self): + # sinphi, cosphi = np.sin(self.phi), np.cos(self.phi) + # sintheta, x = np.sin(self.theta), self.x + # return { + # "phi": CartesianRepresentation(-sinphi, cosphi, 0.0, copy=False), + # "theta": CartesianRepresentation(x * cosphi, x * sinphi, -sintheta, copy=False), + # "r": CartesianRepresentation(sintheta * cosphi, sintheta * sinphi, x, copy=False), + # } + + # TODO! + # def scale_factors(self): + # r = self.r / u.radian + # sintheta = np.sin(self.theta) + # l = np.broadcast_to(1.*u.one, self.shape, subok=True) + # return {'phi': r * sintheta, + # 'theta': r, + # 'r': l} + + def represent_as(self, other_class, differential_class=None): + # Take a short cut if the other class is a spherical representation + + if isclass(other_class): + if issubclass(other_class, PhysicsSphericalRepresentation): + diffs = self._re_represent_differentials(other_class, differential_class) + return other_class( + phi=self.phi, theta=self.theta, r=self.r, differentials=diffs, copy=False + ) + elif issubclass(other_class, SphericalRepresentation): + diffs = self._re_represent_differentials(other_class, differential_class) + return other_class( + lon=self.phi, + lat=90 * u.deg - self.theta, + distance=self.r, + differentials=diffs, + copy=False, + ) + elif issubclass(other_class, UnitSphericalRepresentation): + diffs = self._re_represent_differentials(other_class, differential_class) + return other_class( + lon=self.phi, lat=90 * u.deg - self.theta, differentials=diffs, copy=False + ) + + return super().represent_as(other_class, differential_class) + + def to_cartesian(self): + """ + Converts spherical polar coordinates to 3D rectangular cartesian + coordinates. + """ + # We need to convert Distance to Quantity to allow negative values. + d = self.r.view(Quantity) + + x = d * np.sin(self.theta) * np.cos(self.phi) + y = d * np.sin(self.theta) * np.sin(self.phi) + z = d * np.cos(self.theta) + + return CartesianRepresentation(x=x, y=y, z=z, copy=False) + + @classmethod + def from_cartesian(cls, cart, scale_radius: Optional[Quantity] = None): + """ + Converts 3D rectangular cartesian coordinates to spherical polar + coordinates. + """ + s = np.hypot(cart.x, cart.y) + r = np.hypot(s, cart.z) + + phi = np.arctan2(cart.y, cart.x) << u.rad + theta = np.arctan2(s, cart.z) << u.rad + + return cls(phi=phi, x=theta, zeta=r, scale_radius=scale_radius, copy=False) + + @classmethod + def from_physicsspherical( + cls, psphere: PhysicsSphericalRepresentation, scale_radius: Optional[Quantity] = None + ): + """ + Converts spherical polar coordinates. + """ + return cls(phi=psphere.phi, x=psphere.theta, zeta=psphere.r, scale_radius=scale_radius, copy=False) + + def transform(self, matrix, scale_radius: Optional[Quantity] = None): + """Transform the spherical coordinates using a 3x3 matrix. + + This returns a new representation and does not modify the original one. + Any differentials attached to this representation will also be + transformed. + + Parameters + ---------- + matrix : (3,3) array-like + A 3x3 matrix, such as a rotation matrix (or a stack of matrices). + """ + if self.differentials: + # TODO! shortcut if there are differentials. + # Currently just super, which uses Cartesian backend. + rep = super().transform(matrix) + + else: + # apply transformation in unit-spherical coordinates + xyz = erfa_ufunc.s2c(self.phi, 90 * u.deg - self.theta) + p = erfa_ufunc.rxp(matrix, xyz) + lon, lat, ur = erfa_ufunc.p2s(p) # `ur` is transformed unit-`r` + theta = 90 * u.deg - lat + + # create transformed physics-spherical representation, + # reapplying the distance scaling + rep = self.__class__(phi=lon, x=theta, zeta=self.r * ur, scale_radius=scale_radius) + + return rep + + def norm(self): + """Vector norm. + + The norm is the standard Frobenius norm, i.e., the square root of the + sum of the squares of all components with non-angular units. For + spherical coordinates, this is just the absolute value of the radius. + + Returns + ------- + norm : `astropy.units.Quantity` + Vector norm, with the same shape as the representation. + """ + return np.abs(self.zeta) diff --git a/sample_scf/tests/base.py b/sample_scf/tests/base.py new file mode 100644 index 0000000..ea9170d --- /dev/null +++ b/sample_scf/tests/base.py @@ -0,0 +1,144 @@ +# -*- coding: utf-8 -*- + + +############################################################################## +# IMPORTS + +# STDLIB +from abc import ABCMeta, abstractmethod +import inspect +import time + +# THIRD PARTY +import astropy.coordinates as coord +import astropy.units as u +import numpy as np +import pytest +from astropy.utils.misc import NumpyRNGContext +from galpy.potential import KeplerPotential +from numpy.testing import assert_allclose +from scipy.stats import rv_continuous + +# LOCAL +from sample_scf.conftest import _hernquist_scf_potential +from sample_scf.base_univariate import rv_potential + +############################################################################## +# TESTS +############################################################################## + + +class BaseTest_Sampler(metaclass=ABCMeta): + + @pytest.fixture( + scope="class", + params=[ + "hernquist_scf_potential", + # "nfw_scf_potential", # TODO! turn on + ], + ) + def potential(self, request): + if request.param in ("hernquist_scf_potential"): + potential = _hernquist_scf_potential + elif request.param == "nfw_scf_potential": + # potential = nfw_scf_potential.__wrapped__() + pass + yield potential + + @pytest.fixture(scope="class") + @abstractmethod + def rv_cls(self): + """Sample class.""" + raise NotImplementedError + + @pytest.fixture(scope="class") + def rv_cls_args(self): + return () + + @pytest.fixture(scope="class") + def rv_cls_kw(self): + return {} + + @pytest.fixture(scope="class") + def cls_pot_kw(self): + return {} + + @pytest.fixture(scope="class") + def full_rv_cls_kw(self, rv_cls_kw, cls_pot_kw, potential): + return {**rv_cls_kw, **cls_pot_kw.get(potential, {})} + + @pytest.fixture(scope="class") + def sampler(self, rv_cls, potential, rv_cls_args, full_rv_cls_kw): + """Set up r, theta, or phi sampler.""" + sampler = rv_cls(potential, *rv_cls_args, **full_rv_cls_kw) + return sampler + + # cdf tests + + @pytest.fixture(scope="class") + def cdf_args(self): + return () + + @pytest.fixture(scope="class") + def cdf_kw(self): + return {} + + # rvs tests + + @pytest.fixture(scope="class") + def rvs_args(self): + return () + + @pytest.fixture(scope="class") + def rvs_kw(self): + return {} + + # time-scale tests + + def cdf_time_arr(self, size): + return np.linspace(0, 1e4, size) + + @pytest.fixture(scope="class") + def cdf_time_scale(self): + return 0 + + @pytest.fixture(scope="class") + def rvs_time_scale(self): + return 0 + + # =============================================================== + # Method Tests + + def test_init_wrong_potential(self, rv_cls, rv_cls_args, rv_cls_kw): + """Test initialization when the potential is wrong.""" + # bad value + with pytest.raises(TypeError, match=""): + rv_cls(KeplerPotential(), *rv_cls_args, **rv_cls_kw) + + # --------------------------------------------------------------- + + def test_potential_property(self, sampler): + # Identity + assert sampler.potential is sampler._potential + + # =============================================================== + # Time Scaling Tests + + @pytest.mark.parametrize("size", [1, 10, 100, 1000, 10000]) + def test_cdf_time_scaling(self, sampler, size, cdf_args, cdf_kw, cdf_time_scale): + """Test that the time scales as X * size""" + x = self.cdf_time_arr(size) + tic = time.perf_counter() + sampler.cdf(x, *cdf_args, **cdf_kw) + toc = time.perf_counter() + + assert (toc - tic) < cdf_time_scale * size # linear scaling + + @pytest.mark.parametrize("size", [1, 10, 100, 1000, 10000]) + def test_rvs_time_scaling(self, sampler, size, rvs_args, rvs_kw, rvs_time_scale): + """Test that the time scales as X * size""" + tic = time.perf_counter() + sampler.rvs(size=size, *rvs_args, **rvs_kw) + toc = time.perf_counter() + + assert (toc - tic) < rvs_time_scale * size # linear scaling diff --git a/sample_scf/tests/baseline_images/test_phi_cdf_plot.png b/sample_scf/tests/baseline_images/test_phi_cdf_plot.png new file mode 100644 index 0000000000000000000000000000000000000000..f63c35e62eb77220c5ba7c583e87723d64b2ddc9 GIT binary patch literal 28168 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 I?aYv00Yn#Xs{jB1 literal 0 HcmV?d00001 diff --git a/sample_scf/tests/baseline_images/test_phi_sampling_plot.png b/sample_scf/tests/baseline_images/test_phi_sampling_plot.png new file mode 100644 index 0000000000000000000000000000000000000000..462c1158494dbfc2de7a9ecef0ff5d85b598eadb GIT binary patch literal 22866 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 literal 0 HcmV?d00001 diff --git a/sample_scf/tests/baseline_images/test_r_cdf_plot.png b/sample_scf/tests/baseline_images/test_r_cdf_plot.png new file mode 100644 index 0000000000000000000000000000000000000000..b31364ef4aa52f6f67fd4eedcd68b66b9bbb83ac GIT binary patch literal 32278 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 c`#f&X|KV+V!Z literal 0 HcmV?d00001 diff --git a/sample_scf/tests/baseline_images/test_r_sampling_plot.png b/sample_scf/tests/baseline_images/test_r_sampling_plot.png new file mode 100644 index 0000000000000000000000000000000000000000..421a27048f8c02771680b8f43672e247dcfea484 GIT binary patch literal 26961 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 AHUIzs literal 0 HcmV?d00001 diff --git a/sample_scf/tests/baseline_images/test_theta_sampling_plot.png b/sample_scf/tests/baseline_images/test_theta_sampling_plot.png new file mode 100644 index 0000000000000000000000000000000000000000..05e9f5d03e7a840fb983ce41c68ab531ef86a7dd GIT binary patch literal 24430 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

gkAZ?utW_ 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 Jjl2K)e*lciUnu|p literal 0 HcmV?d00001 diff --git a/sample_scf/tests/data/scf_tnfw_coeffs.npz b/sample_scf/tests/data/scf_tnfw_coeffs.npz new file mode 100644 index 0000000000000000000000000000000000000000..88d2b588b1d0ea282de0d8d7e8451bd9d3f8e65c GIT binary patch literal 184822 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 X=PFXN|M?c8?U&>B{$dZ&_V#}P6G()P literal 0 HcmV?d00001 diff --git a/sample_scf/tests/test_base.py b/sample_scf/tests/test_base.py deleted file mode 100644 index e2224e0..0000000 --- a/sample_scf/tests/test_base.py +++ /dev/null @@ -1,272 +0,0 @@ -# -*- coding: utf-8 -*- - -"""Testing :mod:`scample_scf.base`.""" - - -############################################################################## -# IMPORTS - -# BUILT-IN -import abc -import inspect -import time - -# THIRD PARTY -import astropy.coordinates as coord -import astropy.units as u -import numpy as np -import pytest -from astropy.utils.misc import NumpyRNGContext -from galpy.potential import KeplerPotential -from numpy.testing import assert_allclose -from scipy.stats import rv_continuous - -# LOCAL -from sample_scf import base, conftest - -############################################################################## -# TESTS -############################################################################## - - -class rvtestsampler(base.rv_potential): - """A sampler for testing the modified ``rv_continuous`` base class.""" - - def _cdf(self, x, *args, **kwargs): - return x - - cdf = _cdf - - def _rvs(self, *args, size=None, random_state=None): - if random_state is None: - random_state = np.random - - return np.atleast_1d(random_state.uniform(size=size)) - - -class Test_RVPotential(metaclass=abc.ABCMeta): - """Test `sample_scf.base.rv_potential`.""" - - def setup_class(self): - # sampler initialization - self.cls = rvtestsampler - self.cls_args = () - self.cls_kwargs = {} - self.cls_pot_kw = {} - - # (kw)args into cdf() - self.cdf_args = () - self.cdf_kwargs = {} - - # (kw)args into rvs() - self.rvs_args = () - self.rvs_kwargs = {} - - # time-scale tests - self.cdf_time_arr = lambda self, size: np.linspace(0, 1e4, size) - self.cdf_time_scale = 4e-6 - self.rvs_time_scale = 1e-4 - - @pytest.fixture() - def sampler(self, potentials): - """Set up r, theta, or phi sampler.""" - kw = {**self.cls_kwargs, **self.cls_pot_kw.get(potentials, {})} - sampler = self.cls(potentials, *self.cls_args, **kw) - - return sampler - - # =============================================================== - # Method Tests - - def test_init_signature(self, sampler): - """Test signature is compatible with `scipy.stats.rv_continuous`.""" - sig = inspect.signature(sampler.__init__) - params = sig.parameters - - scipyps = inspect.signature(rv_continuous.__init__).parameters - - assert params["momtype"].default == scipyps["momtype"].default - assert params["a"].default == scipyps["a"].default - assert params["b"].default == scipyps["b"].default - assert params["xtol"].default == scipyps["xtol"].default - assert params["badvalue"].default == scipyps["badvalue"].default - assert params["name"].default == scipyps["name"].default - assert params["longname"].default == scipyps["longname"].default - assert params["shapes"].default == scipyps["shapes"].default - assert params["extradoc"].default == scipyps["extradoc"].default - assert params["seed"].default == scipyps["seed"].default - - def test_init(self, sampler): - """Test initialization.""" - # check it has the expected attributes - assert hasattr(sampler, "_potential") - assert hasattr(sampler, "_nmax") - assert hasattr(sampler, "_lmax") - - # bad value - with pytest.raises(TypeError, match=""): - sampler.__class__(KeplerPotential(), *self.cls_args, **self.cls_kwargs) - - def test_cdf(self, sampler): - """Test :meth:`sample_scf.base.rv_potential.cdf`.""" - assert sampler.cdf(0.0, *self.cdf_args, **self.cdf_kwargs) == 0.0 - - @pytest.mark.parametrize( - "size, random, expected", - [ - (None, 0, 0.5488135039273248), - (1, 2, 0.43599490214200376), - ((3, 1), 4, (0.9670298390136767, 0.5472322491757223, 0.9726843599648843)), - ((3, 1), None, (0.9670298390136767, 0.5472322491757223, 0.9726843599648843)), - ], - ) - def test_rvs(self, sampler, size, random, expected): - """Test :meth:`sample_scf.base.rv_potential.rvs`. - - The ``NumpyRNGContext`` is to control the random generator used to make - the RandomState. For ``random != None``, this doesn't matter. - """ - with NumpyRNGContext(4): - assert_allclose(sampler.rvs(size=size, random_state=random), expected, atol=1e-16) - - # =============================================================== - # Time Scaling Tests - - @pytest.mark.parametrize("size", [1, 10, 100, 1000, 10000]) - def test_cdf_time_scaling(self, sampler, size): - """Test that the time scales as X * size""" - x = self.cdf_time_arr(size) - tic = time.perf_counter() - sampler.cdf(x, *self.cdf_args, **self.cdf_kwargs) - toc = time.perf_counter() - - assert (toc - tic) < self.cdf_time_scale * size # linear scaling - - @pytest.mark.parametrize("size", [1, 10, 100, 1000, 10000]) - def test_rvs_time_scaling(self, sampler, size): - """Test that the time scales as X * size""" - tic = time.perf_counter() - sampler.rvs(size=size, *self.rvs_args, **self.rvs_kwargs) - toc = time.perf_counter() - - assert (toc - tic) < self.rvs_time_scale * size # linear scaling - - -class RVPotentialTest(Test_RVPotential): - """Test rv_potential subclasses.""" - - def setup_class(self): - super().setup_class(self) - - # self.cls_pot_kw = conftest.cls_pot_kw - self.theory = conftest.theory - - def test_init_signature(self, sampler): - """Test signature is compatible with `scipy.stats.rv_continuous`.""" - sig = inspect.signature(sampler.__init__) - params = sig.parameters - - assert "potential" in params - - -############################################################################## - - -class Test_SCFSamplerBase: - """Test :class:`sample_scf.base.SCFSamplerBase`.""" - - def setup_class(self): - # sampler initialization - self.cls = base.SCFSamplerBase - self.cls_args = () - self.cls_kwargs = {} - self.cls_pot_kw = {} - - self.expected_rvs = { - 0: dict(r=0.548813503927, theta=1.021982822867 * u.rad, phi=0.548813503927 * u.rad), - 1: dict(r=0.548813503927, theta=1.021982822867 * u.rad, phi=0.548813503927 * u.rad), - 2: dict( - r=[0.9670298390136, 0.5472322491757, 0.9726843599648, 0.7148159936743], - theta=[0.603766487781, 1.023564077619, 0.598111966830, 0.855980333120] * u.rad, - phi=[0.9670298390136, 0.547232249175, 0.9726843599648, 0.7148159936743] * u.rad, - ), - } - - @pytest.fixture() - def sampler(self, potentials): - """Set up r, theta, & phi sampler.""" - - sampler = self.cls(potentials, *self.cls_args, **self.cls_kwargs) - sampler._r_distribution = rvtestsampler(potentials) - sampler._theta_distribution = rvtestsampler(potentials) - sampler._phi_distribution = rvtestsampler(potentials) - - return sampler - - # =============================================================== - # Method Tests - - def test_init(self, sampler, potentials): - assert sampler._potential is potentials - - def test_r_distribution_property(self, sampler): - """Test :meth:`sample_scf.base.SCFSamplerBase.rsampler`.""" - assert isinstance(sampler.rsampler, base.rv_potential) - - def test_theta_distribution_property(self, sampler): - """Test :meth:`sample_scf.base.SCFSamplerBase.thetasampler`.""" - assert isinstance(sampler.thetasampler, base.rv_potential) - - def test_phi_distribution_property(self, sampler): - """Test :meth:`sample_scf.base.SCFSamplerBase.phisampler`.""" - assert isinstance(sampler.phisampler, base.rv_potential) - - @pytest.mark.parametrize( - "r, theta, phi, expected", - [ - (0, 0, 0, [0, 0, 0]), - (1, 0, 0, [1, 0, 0]), - ([0, 1], [0, 0], [0, 0], [[0, 0, 0], [1, 0, 0]]), - ], - ) - def test_cdf(self, sampler, r, theta, phi, expected): - """Test :meth:`sample_scf.base.SCFSamplerBase.cdf`.""" - cdf = sampler.cdf(r, theta, phi) - assert np.allclose(cdf, expected, atol=1e-16) - - # also test shape - assert tuple(np.atleast_1d(np.squeeze((*np.shape(r), 3)))) == cdf.shape - - @pytest.mark.parametrize( - "id, size, random, vectorized", - [ - (0, None, 0, True), - (0, None, 0, False), - (1, 1, 0, True), - (1, 1, 0, False), - (2, 4, 4, True), - (2, 4, 4, False), - ], - ) - def test_rvs(self, sampler, id, size, random, vectorized): - """Test :meth:`sample_scf.base.SCFSamplerBase.rvs`.""" - samples = sampler.rvs(size=size, random_state=random, vectorized=vectorized) - sce = coord.PhysicsSphericalRepresentation(**self.expected_rvs[id]) - - assert_allclose(samples.r, sce.r, atol=1e-16) - assert_allclose(samples.theta.value, sce.theta.value, atol=1e-16) - assert_allclose(samples.phi.value, sce.phi.value, atol=1e-16) - - -class SCFSamplerTestBase(Test_SCFSamplerBase, metaclass=abc.ABCMeta): - @abc.abstractmethod - def setup_class(self): - pass - - @pytest.fixture() - def sampler(self, potentials): - """Set up r, theta, phi sampler.""" - kw = {**self.cls_kwargs, **self.cls_pot_kw.get(potentials, {})} - sampler = self.cls(potentials, *self.cls_args, **kw) - - return sampler diff --git a/sample_scf/tests/test_base_multivariate.py b/sample_scf/tests/test_base_multivariate.py new file mode 100644 index 0000000..4a8cb42 --- /dev/null +++ b/sample_scf/tests/test_base_multivariate.py @@ -0,0 +1,214 @@ +# -*- coding: utf-8 -*- + +"""Testing :mod:`scample_scf.base_multivariate`.""" + + +############################################################################## +# IMPORTS + +# STDLIB +import inspect +import time +from abc import ABCMeta, abstractmethod + +# THIRD PARTY +import astropy.units as u +import numpy as np +import pytest +from astropy.coordinates import BaseRepresentation +from galpy.potential import SCFPotential +from numpy.testing import assert_allclose +from astropy.coordinates import PhysicsSphericalRepresentation + + +# LOCAL +from sample_scf import conftest +from sample_scf.base_univariate import r_distribution_base, theta_distribution_base, phi_distribution_base + +from .base import BaseTest_Sampler +from .test_base_univariate import rvtestsampler, radii, thetas, phis + + +############################################################################## +# TESTS +############################################################################## + + +class BaseTest_SCFSamplerBase(BaseTest_Sampler): + """Test :class:`sample_scf.base_multivariate.SCFSamplerBase`.""" + + @pytest.fixture(scope="class") + @abstractmethod + def rv_cls(self): + raise NotImplementedError + + @pytest.fixture(scope="class") + def r_distribution_cls(self): + return r_distribution_base + + @pytest.fixture(scope="class") + def theta_distribution_cls(self): + return theta_distribution_base + + @pytest.fixture(scope="class") + def phi_distribution_cls(self): + return phi_distribution_base + + def setup_class(self): + self.expected_rvs = { + 0: dict(r=0.548813503927, theta=1.021982822867 * u.rad, phi=0.548813503927 * u.rad), + 1: dict(r=0.548813503927, theta=1.021982822867 * u.rad, phi=0.548813503927 * u.rad), + 2: dict( + r=[0.9670298390136, 0.5472322491757, 0.9726843599648, 0.7148159936743], + theta=[0.603766487781, 1.023564077619, 0.598111966830, 0.855980333120] * u.rad, + phi=[0.9670298390136, 0.547232249175, 0.9726843599648, 0.7148159936743] * u.rad, + ), + } + + # =============================================================== + # Method Tests + + def test_init_attrs(self, sampler): + assert hasattr(sampler, "_potential") + assert hasattr(sampler, "_r_distribution") + assert hasattr(sampler, "_theta_distribution") + assert hasattr(sampler, "_phi_distribution") + + # --------------------------------------------------------------- + + def test_potential_property(self, sampler, potential): + """Test :meth:`sample_scf.base_multivariate.SCFSamplerBase.potential`.""" + # Identity + assert sampler.potential is sampler._potential + # Properties + assert isinstance(sampler.potential, SCFPotential) + + def test_r_distribution_property(self, sampler, r_distribution_cls): + """Test :meth:`sample_scf.base_multivariate.SCFSamplerBase.r_distribution`.""" + # Identity + assert sampler.r_distribution is sampler._r_distribution + # Properties + assert isinstance(sampler.r_distribution, r_distribution_cls) + + def test_theta_distribution_property(self, sampler, theta_distribution_cls): + """Test :meth:`sample_scf.base_multivariate.SCFSamplerBase.theta_distribution`.""" + # Identity + assert sampler.theta_distribution is sampler._theta_distribution + # Properties + assert isinstance(sampler.theta_distribution, theta_distribution_cls) + + def test_phi_distribution_property(self, sampler, phi_distribution_cls): + """Test :meth:`sample_scf.base_multivariate.SCFSamplerBase.phi_distribution`.""" + # Identity + assert sampler.phi_distribution is sampler._phi_distribution + # Properties + assert isinstance(sampler.phi_distribution, phi_distribution_cls) + + def test_radial_scale_factor_property(self, sampler): + # Identity + assert sampler.radial_scale_factor is sampler.r_distribution.radial_scale_factor + + def test_nmax_property(self, sampler): + # Identity + assert sampler.nmax is sampler.r_distribution.nmax + + def test_lmax_property(self, sampler): + # Identity + assert sampler.lmax is sampler.r_distribution.lmax + + # --------------------------------------------------------------- + + @abstractmethod + def test_cdf(self, sampler, position, expected): + """Test cdf method.""" + cdf = sampler.cdf(size=size, *position) + + assert isinstance(cdf, np.ndarray) + assert False + + assert_allclose(cdf, expected, atol=1e-16) + + @abstractmethod + def test_rvs(self, sampler, size, random, expected): + """Test rvs method. + + The ``NumpyRNGContext`` is to control the random generator used to make + the RandomState. For ``random != None``, this doesn't matter. + + Each child class will need to define the set of expected results. + """ + with NumpyRNGContext(4): + rvs = sampler.rvs(size=size, random_state=random) + + assert isinstance(rvs, BaseRepresentation) + + r = rvs.represent_as(PhysicsSphericalRepresentation) + assert_allclose(r.r, expected.r, atol=1e-16) + assert_allclose(r.theta, expected.theta, atol=1e-16) + assert_allclose(r.phi, expected.phi, atol=1e-16) + + # --------------------------------------------------------------- + + def test_repr(self): + """Test :meth:`sample_scf.base_multivariate.SCFSamplerBase.__repr__`.""" + assert False + + +############################################################################## + + +class Test_SCFSamplerBase(BaseTest_SCFSamplerBase): + + @pytest.fixture(scope="class") + def rv_cls(self): + return SCFSamplerBase + + @pytest.fixture() + def sampler(self, potential): + """Set up r, theta, phi sampler.""" + super().sampler(potential) + + sampler._r_distribution = rvtestsampler(potentials) + sampler._theta_distribution = rvtestsampler(potentials) + sampler._phi_distribution = rvtestsampler(potentials) + + return sampler + + # =============================================================== + # Method Tests + + @pytest.mark.parametrize( + "r, theta, phi, expected", + [ + (0, 0, 0, [0, 0, 0]), + (1, 0, 0, [1, 0, 0]), + ([0, 1], [0, 0], [0, 0], [[0, 0, 0], [1, 0, 0]]), + ], + ) + def test_cdf(self, sampler, r, theta, phi, expected): + """Test :meth:`sample_scf.base_multivariate.SCFSamplerBase.cdf`.""" + cdf = sampler.cdf(r, theta, phi) + assert np.allclose(cdf, expected, atol=1e-16) + + # also test shape + assert tuple(np.atleast_1d(np.squeeze((*np.shape(r), 3)))) == cdf.shape + + @pytest.mark.parametrize( + "id, size, random, vectorized", + [ + (0, None, 0, True), + (0, None, 0, False), + (1, 1, 0, True), + (1, 1, 0, False), + (2, 4, 4, True), + (2, 4, 4, False), + ], + ) + def test_rvs(self, sampler, id, size, random, vectorized): + """Test :meth:`sample_scf.base_multivariate.SCFSamplerBase.rvs`.""" + samples = sampler.rvs(size=size, random_state=random, vectorized=vectorized) + sce = PhysicsSphericalRepresentation(**self.expected_rvs[id]) + + assert_allclose(samples.r, sce.r, atol=1e-16) + assert_allclose(samples.theta.value, sce.theta.value, atol=1e-16) + assert_allclose(samples.phi.value, sce.phi.value, atol=1e-16) diff --git a/sample_scf/tests/test_base_univariate.py b/sample_scf/tests/test_base_univariate.py new file mode 100644 index 0000000..54323a4 --- /dev/null +++ b/sample_scf/tests/test_base_univariate.py @@ -0,0 +1,254 @@ +# -*- coding: utf-8 -*- + +"""Testing :mod:`scample_scf.base_univariate`.""" + + +############################################################################## +# IMPORTS + +# STDLIB +from abc import ABCMeta, abstractmethod +import inspect +import time + +# THIRD PARTY +import astropy.coordinates as coord +import astropy.units as u +import numpy as np +import pytest +from astropy.utils.misc import NumpyRNGContext +from galpy.potential import KeplerPotential +from numpy.testing import assert_allclose +from scipy.stats import rv_continuous + +# LOCAL +from sample_scf.conftest import _hernquist_scf_potential +from sample_scf.base_univariate import rv_potential, r_distribution_base + +from .data import results +from .base import BaseTest_Sampler + + +############################################################################## +# PARAMETERS + +radii = np.concatenate(([0], np.geomspace(1e-1, 1e3, 28), [np.inf])) # same shape as ↓ +thetas = np.linspace(0, np.pi, 30) +phis = np.linspace(0, 2 * np.pi, 30) + + +############################################################################## +# TESTS +############################################################################## + + +class BaseTest_rv_potential(BaseTest_Sampler): + """Test subclasses of `sample_scf.base_univariate.rv_potential`.""" + + # =============================================================== + # Method Tests + + def test_init_signature(self, sampler): + """Test signature is compatible with `scipy.stats.rv_continuous`. + + The subclasses pass to parent by kwargs, so can't check the full + suite of parameters. + """ + sig = inspect.signature(sampler.__init__) + params = sig.parameters + + assert "potential" in params + + def test_init_attrs(self, sampler, rv_cls_args, rv_cls_kw): + """Test attributes set at initialization.""" + # check it has the expected attributes + assert hasattr(sampler, "_potential") + assert hasattr(sampler, "_nmax") + assert hasattr(sampler, "_lmax") + assert hasattr(sampler, "_radial_scale_factor") + + # TODO! expected parameters from scipy rv_continuous + + # --------------------------------------------------------------- + + def test_radial_scale_factor_property(self, sampler): + # Identity + assert sampler.radial_scale_factor is sampler._radial_scale_factor + + def test_nmax_property(self, sampler): + # Identity + assert sampler.nmax is sampler._nmax + + def test_lmax_property(self, sampler): + # Identity + assert sampler.lmax is sampler._lmax + + # --------------------------------------------------------------- + + @abstractmethod + def test_cdf(self, sampler, position, expected): + """Test cdf method.""" + assert_allclose(sampler.cdf(size=size, *position), expected, atol=1e-16) + + @abstractmethod + def test_rvs(self, sampler, size, random, expected): + """Test rvs method. + + The ``NumpyRNGContext`` is to control the random generator used to make + the RandomState. For ``random != None``, this doesn't matter. + + Each child class will need to define the set of expected results. + """ + with NumpyRNGContext(4): + assert_allclose(sampler.rvs(size=size, random_state=random), expected, atol=1e-16) + + +############################################################################## + + +class rvtestsampler(rv_potential): + """A sampler for testing the modified ``rv_continuous`` base class.""" + + def _cdf(self, x, *args, **kwargs): + return x + + cdf = _cdf + + def _rvs(self, *args, size=None, random_state=None): + if random_state is None: + random_state = np.random + + return np.atleast_1d(random_state.uniform(size=size)) + + +class Test_rv_potential(BaseTest_rv_potential): + """Test :class:`sample_scf.base_univariate.rv_potential`.""" + + @pytest.fixture(scope="class") + def rv_cls(self): + return rvtestsampler + + @pytest.fixture(scope="class") + def cdf_time_scale(self): + return 4e-6 + + @pytest.fixture(scope="class") + def rvs_time_scale(self): + return 1e-4 + + # =============================================================== + # Method Tests + + def test_init_signature(self, sampler): + """Test signature is compatible with `scipy.stats.rv_continuous`.""" + sig = inspect.signature(sampler.__init__) + params = sig.parameters + + scipyps = inspect.signature(rv_continuous.__init__).parameters + + assert params["momtype"].default == scipyps["momtype"].default + assert params["a"].default == scipyps["a"].default + assert params["b"].default == scipyps["b"].default + assert params["xtol"].default == scipyps["xtol"].default + assert params["badvalue"].default == scipyps["badvalue"].default + assert params["name"].default == scipyps["name"].default + assert params["longname"].default == scipyps["longname"].default + assert params["shapes"].default == scipyps["shapes"].default + assert params["extradoc"].default == scipyps["extradoc"].default + assert params["seed"].default == scipyps["seed"].default + + @pytest.mark.parametrize( # TODO! instead by request.param lookup and index + list(results["Test_rv_potential"]["rvs"].keys()), + zip(*results["Test_rv_potential"]["rvs"].values()), + ) + def test_rvs(self, sampler, size, random, expected): + super().test_rvs(sampler, size, random, expected) + + +############################################################################## + + +class BaseTest_r_distribution_base(BaseTest_rv_potential): + """Test :class:`sample_scf.base_multivariate.r_distribution_base`.""" + + @pytest.fixture(scope="class") + @abstractmethod + def rv_cls(self): + """Sample class.""" + return r_distribution_base + + # =============================================================== + # Method Tests + + +############################################################################## + + +class BaseTest_theta_distribution_base(BaseTest_rv_potential): + """Test :class:`sample_scf.base_multivariate.theta_distribution_base`.""" + + @pytest.fixture(scope="class") + @abstractmethod + def rv_cls(self): + return theta_distribution_base + + def cdf_time_arr(self, size: int): + return np.linspace(0, np.pi, size) + + # =============================================================== + # Method Tests + + def test_init_attrs(self, sampler): + """Test attributes set at initialization.""" + super().test_init_attrs(sampler) + + assert hasattr(sampler, "_lrange") + assert min(sampler._lrange) == 0 + assert max(sampler._lrange) == sampler.lmax + 1 + + @pytest.mark.skip("TODO!") + def test_calculate_Qls(self, potential, sampler): + """Test :meth:`sample_scf.base_univariate.theta_distribution_base.calculate_Qls`.""" + assert False + + +############################################################################## + + +class BaseTest_phi_distribution_base(BaseTest_rv_potential): + """Test :class:`sample_scf.base_multivariate.phi_distribution_base`.""" + + @pytest.fixture(scope="class") + @abstractmethod + def rv_cls(self): + return theta_distribution_base + + def cdf_time_arr(self, size: int): + return np.linspace(0, 2*np.pi, size) + + # =============================================================== + # Method Tests + + def test_init_attrs(self, sampler): + """Test attributes set at initialization.""" + super().test_init_attrs(sampler) + + # l-range + assert hasattr(sampler, "_lrange") + assert min(sampler._lrange) == 0 + assert max(sampler._lrange) == sampler.lmax + 1 + + @pytest.mark.skip("TODO!") + def test_pnts_Scs(self, sampler): + """Test :class:`sample_scf.base_multivariate.phi_distribution_base._pnts_Scs`.""" + assert False + + @pytest.mark.skip("TODO!") + def test_pnts_Scs(self, sampler): + """Test :class:`sample_scf.base_multivariate.phi_distribution_base._grid_Scs`.""" + assert False + + @pytest.mark.skip("TODO!") + def test_calculate_Scs(self, sampler): + """Test :class:`sample_scf.base_multivariate.phi_distribution_base.calculate_Scs`.""" + assert False diff --git a/sample_scf/tests/test_conftest.py b/sample_scf/tests/test_conftest.py index e5940b6..d64d9b1 100644 --- a/sample_scf/tests/test_conftest.py +++ b/sample_scf/tests/test_conftest.py @@ -1,120 +1,110 @@ -# -*- coding: utf-8 -*- - -"""Testing :mod:`~sample_scf.conftest`. - -Even the test source should be tested. -In particular, the potential fixtures need confirmation that the SCF form -matches the theoretical, within tolerances. -""" - -__all__ = [ - "Test_ClassName", - "test_function", -] - - -############################################################################## -# IMPORTS - -# BUILT-IN -import abc - -# THIRD PARTY -import numpy as np -import pytest - -# LOCAL -from sample_scf import conftest - -############################################################################## -# PARAMETERS - - -############################################################################## -# TESTS -############################################################################## - - -class PytestPotential(metaclass=abc.ABCMeta): - """Test a Pytest Potential.""" - - @classmethod - @abc.abstractmethod - def setup_class(self): - """Setup fixtures for testing.""" - self.R = np.linspace(0.0, 3.0, num=1001) - self.atol = 1e-6 - self.restrict_ind = np.ones(1001, dtype=bool) - - @pytest.fixture(scope="class") - @abc.abstractmethod - def scf_potential(self): - """The `galpy.potential.SCFPotential` from a `pytest.fixture`.""" - return - - def compare_to_theory(self, theory, scf, atol=1e-6): - # test that where theory is finite they match and where it's infinite, - # the scf is NaN - fnt = ~np.isinf(theory) - ind = self.restrict_ind & fnt - - assert np.allclose(theory[ind], scf[ind], atol=atol) - assert np.all(np.isnan(scf[~fnt])) - - # =============================================================== - # sanity checks - - def test_df(self): - assert self.df._pot is self.theory - - # =============================================================== - - def test_density_along_Rz_equality(self, scf_potential): - theory = self.theory.dens(self.R, self.R) - scf = scf_potential.dens(self.R, self.R) - self.compare_to_theory(theory, scf, atol=self.atol) - - @pytest.mark.parametrize("z", [0, 10, 15]) - def test_density_at_z(self, scf_potential, z): - theory = self.theory.dens(self.R, z) - scf = scf_potential.dens(self.R, z) - self.compare_to_theory(theory, scf, atol=self.atol) - - -# ------------------------------------------------------------------- - - -class Test_hernquist_scf_potential(PytestPotential): - @classmethod - def setup_class(self): - """Setup fixtures for testing.""" - super().setup_class() - - self.theory = conftest.hernquist_potential - self.df = conftest.hernquist_df - - @pytest.fixture(scope="class") - def scf_potential(self, hernquist_scf_potential): - """The `galpy.potential.SCFPotential` from a `pytest.fixture`.""" - return hernquist_scf_potential - - -# ------------------------------------------------------------------- - - -class Test_nfw_scf_potential(PytestPotential): - @classmethod - def setup_class(self): - """Setup fixtures for testing.""" - super().setup_class() - - self.theory = conftest.nfw_potential - self.df = conftest.nfw_df - - self.atol = 1e-2 - self.restrict_ind[:18] = False # skip some of the inner ones - - @pytest.fixture(scope="class") - def scf_potential(self, nfw_scf_potential): - """The `galpy.potential.SCFPotential` from a `pytest.fixture`.""" - return nfw_scf_potential +# # -*- coding: utf-8 -*- +# +# """Testing :mod:`~sample_scf.conftest`. +# +# Even the test source should be tested. +# In particular, the potential fixtures need confirmation that the SCF form +# matches the theoretical, within tolerances. +# """ +# +# ############################################################################## +# # IMPORTS +# +# # STDLIB +# import abc +# +# # THIRD PARTY +# import numpy as np +# import pytest +# +# # LOCAL +# from sample_scf import conftest +# +# ############################################################################## +# # TESTS +# ############################################################################## +# +# +# class PytestPotential(metaclass=abc.ABCMeta): +# """Test a Pytest Potential.""" +# +# @classmethod +# @abc.abstractmethod +# def setup_class(self): +# """Setup fixtures for testing.""" +# self.R = np.linspace(0.0, 3.0, num=1001) +# self.atol = 1e-6 +# self.restrict_ind = np.ones(1001, dtype=bool) +# +# @pytest.fixture(scope="class") +# @abc.abstractmethod +# def scf_potential(self): +# """The `galpy.potential.SCFPotential` from a `pytest.fixture`.""" +# return +# +# def compare_to_theory(self, theory, scf, atol=1e-6): +# # test that where theory is finite they match and where it's infinite, +# # the scf is NaN +# fnt = ~np.isinf(theory) +# ind = self.restrict_ind & fnt +# +# assert np.allclose(theory[ind], scf[ind], atol=atol) +# assert np.all(np.isnan(scf[~fnt])) +# +# # =============================================================== +# # sanity checks +# +# def test_df(self): +# assert self.df._pot is self.theory +# +# # =============================================================== +# +# def test_density_along_Rz_equality(self, scf_potential): +# theory = self.theory.dens(self.R, self.R) +# scf = scf_potential.dens(self.R, self.R) +# self.compare_to_theory(theory, scf, atol=self.atol) +# +# @pytest.mark.parametrize("z", [0, 10, 15]) +# def test_density_at_z(self, scf_potential, z): +# theory = self.theory.dens(self.R, z) +# scf = scf_potential.dens(self.R, z) +# self.compare_to_theory(theory, scf, atol=self.atol) +# +# +# # ------------------------------------------------------------------- +# +# +# class Test_hernquist_scf_potential(PytestPotential): +# @classmethod +# def setup_class(self): +# """Setup fixtures for testing.""" +# super().setup_class() +# +# self.theory = conftest.hernquist_potential +# self.df = conftest.hernquist_df +# +# @pytest.fixture(scope="class") +# def scf_potential(self, hernquist_scf_potential): +# """The `galpy.potential.SCFPotential` from a `pytest.fixture`.""" +# return hernquist_scf_potential +# +# +# # ------------------------------------------------------------------- +# +# +# # class Test_nfw_scf_potential(PytestPotential): +# # @classmethod +# # def setup_class(self): +# # """Setup fixtures for testing.""" +# # super().setup_class() +# # +# # self.theory = conftest.nfw_potential +# # self.df = conftest.nfw_df +# # +# # self.atol = 1e-2 +# # self.restrict_ind[:18] = False # skip some of the inner ones +# # +# # @pytest.fixture(scope="class") +# # def scf_potential(self, nfw_scf_potential): +# # """The `galpy.potential.SCFPotential` from a `pytest.fixture`.""" +# # return nfw_scf_potential diff --git a/sample_scf/tests/test_core.py b/sample_scf/tests/test_core.py index 07db811..ec08ce1 100644 --- a/sample_scf/tests/test_core.py +++ b/sample_scf/tests/test_core.py @@ -12,34 +12,48 @@ import pytest # LOCAL -from .test_base import SCFSamplerTestBase -from .test_interpolated import pgrid, rgrid, tgrid -from sample_scf import conftest, core +from sample_scf import SCFSampler +from sample_scf.exact import exact_r_distribution, exact_theta_distribution, exact_phi_distribution + +from .test_base_multivariate import BaseTest_SCFSamplerBase ############################################################################## # TESTS ############################################################################## -class Test_SCFSampler(SCFSamplerTestBase): - """Test :class:`sample_scf.core.SCFSample`.""" +def test_MethodsMapping(potentials): + """Test `sample_scf.core.MethodsMapping`.""" + # Good + mm = MethodsMapping( + r=exact_r_distribution(potentials, total_mass=1), + theta=exact_theta_distribution(potentials, ) + phi=exact_phi_distribution(potentials) + ) - @pytest.fixture() - def sampler(self, potentials): - """Set up r, theta, phi sampler.""" - kw = {**self.cls_kwargs, **self.cls_pot_kw.get(potentials, {})} - sampler = self.cls(potentials, *self.cls_args, **kw) + assert False - return sampler - def setup_class(self): - super().setup_class(self) +############################################################################## - self.cls = core.SCFSampler - self.cls_args = ("interp",) # TODO! iterate over this - self.cls_kwargs = dict(rgrid=rgrid, thetagrid=tgrid, phigrid=pgrid) - self.cls_pot_kw = {} +class Test_SCFSampler(BaseTest_SCFSamplerBase): + """Test :class:`sample_scf.core.SCFSample`.""" + + @pytest.fixture(scope="class") + def rv_cls(self): + return SCFSampler + + @pytest.fixture(scope="class") + def rv_cls_args(self): + return ("interp",) # TODO! iterate over this + + @pytest.fixture(scope="class") + def rv_cls_kw(self): + # return dict(rgrid=rgrid, thetagrid=tgrid, phigrid=pgrid) + return {} + + def setup_class(self): # TODO! make sure these are right! self.expected_rvs = { 0: dict(r=2.8473287899985, theta=1.473013568997 * u.rad, phi=3.4482969442579 * u.rad), @@ -63,7 +77,7 @@ def setup_class(self): ], ) def test_cdf(self, sampler, r, theta, phi, expected): - """Test :meth:`sample_scf.base.SCFSamplerBase.cdf`.""" + """Test :meth:`sample_scf.base_multivariate.SCFSamplerBase.cdf`.""" cdf = sampler.cdf(r, theta, phi) assert np.allclose(cdf, expected, atol=1e-16) diff --git a/sample_scf/tests/test_init.py b/sample_scf/tests/test_init.py index 30e598f..f5a81d0 100644 --- a/sample_scf/tests/test_init.py +++ b/sample_scf/tests/test_init.py @@ -5,12 +5,14 @@ ############################################################################## # IMPORTS -# BUILT-IN +# STDLIB import inspect # LOCAL import sample_scf -from sample_scf import core, exact, interpolated +from sample_scf.core import SCFSampler +from sample_scf.exact import ExactSCFSampler +from sample_scf.interpolated import InterpolatedSCFSampler ############################################################################## # TESTS @@ -20,10 +22,7 @@ def test_expected_imports(): """Test can import expected modules and objects.""" assert inspect.ismodule(sample_scf) - assert inspect.ismodule(core) - assert inspect.ismodule(exact) - assert inspect.ismodule(interpolated) - assert sample_scf.SCFSampler is core.SCFSampler - assert sample_scf.ExactSCFSampler is exact.SCFSampler - assert sample_scf.InterpolatedSCFSampler is interpolated.SCFSampler + assert sample_scf.SCFSampler is SCFSampler + assert sample_scf.ExactSCFSampler is ExactSCFSampler + assert sample_scf.InterpolatedSCFSampler is InterpolatedSCFSampler diff --git a/sample_scf/tests/test_representation.py b/sample_scf/tests/test_representation.py new file mode 100644 index 0000000..59b3e21 --- /dev/null +++ b/sample_scf/tests/test_representation.py @@ -0,0 +1,524 @@ +# -*- coding: utf-8 -*- + +"""Testing :mod:`scample_scf.representation`.""" + + +############################################################################## +# IMPORTS + +# STDLIB +import contextlib +import re + +# THIRD PARTY +import astropy.units as u +import numpy as np +import pytest +from astropy.units import Quantity, UnitConversionError, allclose +from astropy.coordinates import Distance, PhysicsSphericalRepresentation, SphericalRepresentation, UnitSphericalRepresentation, CartesianRepresentation + +# LOCAL +from sample_scf.representation import ( + FiniteSphericalRepresentation, + zeta_of_r, + r_of_zeta, + x_of_theta, + theta_of_x, +) + + +############################################################################## +# TESTS +############################################################################## + + +class Test_FiniteSphericalRepresentation: + """Test :class:`sample_scf.FiniteSphericalRepresentation`.""" + + def setup_class(self): + """Setup class for testing.""" + + self.phi = 0 * u.rad + self.x = 0 + self.zeta = 0 + self.scale_radius = 8 * u.kpc + + @pytest.fixture + def rep_cls(self): + return FiniteSphericalRepresentation + + @pytest.fixture + def differentials(self): + return None # TODO! maybe as a class-level parametrize + + @pytest.fixture + def scale_radius(self): + return 8 * u.kpc + + @pytest.fixture + def rep(self, rep_cls, scale_radius, differentials): + return rep_cls( + self.phi, + x=self.x, + zeta=self.zeta, + scale_radius=scale_radius, + copy=False, + differentials=differentials, + ) + + # =============================================================== + # Method Tests + + def test_init_simple(self, rep_cls): + """ + Test initializing an FiniteSphericalRepresentation. + This is actually mostly tested by the pytest fixtures, which will fail + if bad input is given. + """ + rep = rep_cls(phi=1 * u.deg, x=-1, zeta=0, scale_radius=8 * u.kpc) + + assert isinstance(rep, rep_cls) + assert (rep.phi, rep.x, rep.zeta) == (1 * u.deg, -1, 0) + assert rep.scale_radius == 8 * u.kpc + + def test_init_dimensionless_radius(self, rep_cls): + """Test initialization when scale radius is unit-less.""" + rep = rep_cls(phi=1 * u.deg, x=-1, zeta=0, scale_radius=8) + + assert isinstance(rep, rep_cls) + assert (rep.phi, rep.x, rep.zeta) == (1 * u.deg, -1, 0) + assert rep.scale_radius == 8 + + def test_init_x_is_theta(self, rep_cls): + """Test initialization when x has angular units.""" + rep = rep_cls(phi=1 * u.deg, x=90 * u.deg, zeta=0, scale_radius=8 * u.kpc) + + assert isinstance(rep, rep_cls) + assert rep.phi == 1 * u.deg + assert allclose(rep.x, 0, atol=1e-16) + assert rep.zeta == 0 + assert rep.scale_radius == 8 * u.kpc + + def test_init_zeta_is_r(self, rep_cls): + """Test initialization when zeta has units of length.""" + # When scale_radius is None + rep = rep_cls(phi=1 * u.deg, x=-1, zeta=8 * u.kpc) + assert isinstance(rep, rep_cls) + assert (rep.phi, rep.x, rep.zeta) == (1 * u.deg, -1, 7 / 9) + assert rep.scale_radius == 1 * u.kpc + + # When scale_radius is not None + rep = rep_cls(phi=1 * u.deg, x=-1, zeta=8 * u.kpc, scale_radius=8 * u.kpc) + assert isinstance(rep, rep_cls) + assert (rep.phi, rep.x, rep.zeta) == (1 * u.deg, -1, 0) + assert rep.scale_radius == 8 * u.kpc + + # Scale radius must match the units of zeta + with pytest.raises(TypeError, match="scale_radius must be a Quantity"): + rep_cls(phi=1 * u.deg, x=-1, zeta=8 * u.kpc, scale_radius=8) + + def test_init_needs_scale_radius(self, rep_cls): + """ + Test initialization when zeta is correctly unit-less, but no scale + radius was given. + """ + with pytest.raises(ValueError, match="if zeta is not a length"): + rep_cls(phi=1 * u.deg, x=-1, zeta=0) + + def test_init_x_out_of_bounds(self, rep_cls): + """ + Test initialization when transformed inclination angle is out of bounds. + """ + with pytest.raises(ValueError, match=re.escape("inclination angle(s) must be within")): + rep_cls(phi=1 * u.deg, x=-2, zeta=1 * u.kpc) + + with pytest.raises(ValueError, match=re.escape("inclination angle(s) must be within")): + rep_cls(phi=1 * u.deg, x=2, zeta=1 * u.kpc) + + def test_init_zeta_out_of_bounds(self, rep_cls): + """Test initialization when transformed distance is out of bounds.""" + with pytest.raises(ValueError, match="distances must be within"): + rep_cls(phi=1 * u.deg, x=0, zeta=-2, scale_radius=1) + + with pytest.raises(ValueError, match="distances must be within"): + rep_cls(phi=1 * u.deg, x=0, zeta=2, scale_radius=1) + + # ------------------------------------------- + + def test_phi(self, rep_cls, rep): + """Test :attr:`sample_scf.FiniteSphericalRepresentation.phi`.""" + # class + assert isinstance(rep_cls.phi, property) + + # instance + assert rep.phi is rep._phi + assert isinstance(rep.phi, Quantity) + assert rep.phi.unit.physical_type == "angle" + + def test_x(self, rep_cls, rep): + """Test :attr:`sample_scf.FiniteSphericalRepresentation.x`.""" + # class + assert isinstance(rep_cls.x, property) + + # instance + assert rep.x is rep._x + assert isinstance(rep.x, Quantity) + assert rep.x.unit.physical_type == "dimensionless" + + def test_zeta(self, rep_cls, rep): + """Test :attr:`sample_scf.FiniteSphericalRepresentation.zeta`.""" + # class + assert isinstance(rep_cls.zeta, property) + + # instance + assert rep.zeta is rep._zeta + assert isinstance(rep.zeta, Quantity) + assert rep.zeta.unit.physical_type == "dimensionless" + + def test_scale_radius(self, rep_cls, rep): + """Test :attr:`sample_scf.FiniteSphericalRepresentation.scale_radius`.""" + # class + assert isinstance(rep_cls.scale_radius, property) + + # instance + assert rep.scale_radius is rep._scale_radius + assert isinstance(rep.scale_radius, Quantity) + assert rep.scale_radius.unit.physical_type == "length" + + # ----------------------------------------------------- + # corresponding PhysicsSpherical coordinates + + def test_theta(self, rep_cls, rep): + """Test :attr:`sample_scf.FiniteSphericalRepresentation.theta`.""" + # class + assert isinstance(rep_cls.theta, property) + + # instance + assert rep.theta == rep.calculate_theta_of_x(rep.x) + assert isinstance(rep.theta, Quantity) + assert rep.theta.unit.physical_type == "angle" + + def test_r(self, rep_cls, rep): + """Test :attr:`sample_scf.FiniteSphericalRepresentation.r`.""" + # class + assert isinstance(rep_cls.r, property) + + # instance + assert rep.r == rep.calculate_r_of_zeta(rep.zeta) + assert isinstance(rep.r, Distance) + assert rep.r.unit == rep.scale_radius.unit + assert rep.r.unit.physical_type == "length" + + # ----------------------------------------------------- + # conversion functions + # TODO! from below tests + + @pytest.mark.skip("TODO!") + def test_calculate_zeta_of_r(self, rep): + """Test :meth:`sample_scf.FiniteSphericalRepresentation.calculate_zeta_of_r`.""" + assert False + + @pytest.mark.skip("TODO!") + def test_calculate_r_of_zeta(self, rep): + """Test :meth:`sample_scf.FiniteSphericalRepresentation.calculate_r_of_zeta`.""" + assert False + + @pytest.mark.skip("TODO!") + def test_calculate_x_of_theta(self, rep): + """Test :meth:`sample_scf.FiniteSphericalRepresentation.calculate_x_of_theta`.""" + assert False + + @pytest.mark.skip("TODO!") + def test_calculate_theta_of_x(self, rep): + """Test :meth:`sample_scf.FiniteSphericalRepresentation.calculate_theta_of_x`.""" + assert False + + # ----------------------------------------------------- + + @pytest.mark.skip("TODO!") + def test_unit_vectors(self, rep): + """Test :meth:`sample_scf.FiniteSphericalRepresentation.unit_vectors`.""" + assert False + + @pytest.mark.skip("TODO!") + def test_scale_factors(self, rep): + """Test :meth:`sample_scf.FiniteSphericalRepresentation.scale_factors`.""" + assert False + + # -------------------------------------------- + + def test_represent_as_PhysicsSphericalRepresentation(self, rep): + """Test :meth:`sample_scf.FiniteSphericalRepresentation.represent_as`.""" + r = rep.represent_as(PhysicsSphericalRepresentation) + assert allclose(r.phi, rep.phi) + assert allclose(r.theta, rep.theta) + assert allclose(r.r, rep.r) + + def test_represent_as_SphericalRepresentation(self, rep): + """Test :meth:`sample_scf.FiniteSphericalRepresentation.represent_as`.""" + r = rep.represent_as(SphericalRepresentation) + assert allclose(r.lon, rep.phi) + assert allclose(r.lat, 90 * u.deg - rep.theta) + assert allclose(r.distance, rep.r) + + def test_represent_as_UnitSphericalRepresentation(self, rep): + """Test :meth:`sample_scf.FiniteSphericalRepresentation.represent_as`.""" + r = rep.represent_as(UnitSphericalRepresentation) + assert allclose(r.lon, rep.phi) + assert allclose(r.lat, 90 * u.deg - rep.theta) + + def test_represent_as_CartesianRepresentation(self, rep): + """Test :meth:`sample_scf.FiniteSphericalRepresentation.represent_as`.""" + assert rep.represent_as(CartesianRepresentation) == rep.to_cartesian() + + # -------------------------------------------- + + def test_to_cartesian(self, rep): + """Test :meth:`sample_scf.FiniteSphericalRepresentation.to_cartesian`.""" + r = rep.to_cartesian() + + x = rep.r * np.sin(rep.theta) * np.cos(rep.phi) + y = rep.r * np.sin(rep.theta) * np.sin(rep.phi) + z = rep.r * np.cos(rep.theta) + + assert allclose(r.x, x) + assert allclose(r.y, y) + assert allclose(r.z, z) + + def test_from_cartesian(self, rep_cls, rep, scale_radius): + """Test :meth:`sample_scf.FiniteSphericalRepresentation.from_cartesian`.""" + cart = rep.to_cartesian() + + # Not passing a scale radius + r = rep_cls.from_cartesian(cart) + assert rep != r + + r = rep_cls.from_cartesian(cart, scale_radius=scale_radius) + assert allclose(rep.phi, r.phi) + assert allclose(rep.theta, r.theta) + assert allclose(rep.zeta, r.zeta) + + def test_from_physicsspherical(self, rep_cls, rep, scale_radius): + """Test :meth:`sample_scf.FiniteSphericalRepresentation.from_physicsspherical`.""" + psphere = rep.represent_as(PhysicsSphericalRepresentation) + + # Not passing a scale radius + r = rep_cls.from_physicsspherical(psphere) + assert rep != r + + r = rep_cls.from_physicsspherical(psphere, scale_radius=scale_radius) + assert allclose(rep.phi, r.phi) + assert allclose(rep.theta, r.theta) + assert allclose(rep.zeta, r.zeta) + + def test_transform(self, rep, scale_radius): + """Test :meth:`sample_scf.FiniteSphericalRepresentation.transform`.""" + # Identity + matrix = np.eye(3) + r = rep.transform(matrix, scale_radius) + assert allclose(rep.phi, r.phi) + assert allclose(rep.theta, r.theta) + assert allclose(rep.zeta, r.zeta) + + # alternating coordinates + matrix = np.array([[0, 1, 0], + [1, 0, 0], + [0, 0, 1]]) + r = rep.transform(matrix, scale_radius) + assert allclose(rep.phi, r.phi - np.pi / 2 * u.rad) + assert allclose(rep.theta, r.theta) + assert allclose(rep.zeta, r.zeta) + + def test_norm(self, rep): + """Test :meth:`sample_scf.FiniteSphericalRepresentation.norm`.""" + assert rep.norm() == np.abs(rep.zeta) + + +############################################################################## + + +def test_zeta_of_r_fail(): + """Test :func:`sample_scf.representation.r_of_zeta` with wrong r type.""" + # Negative value + with pytest.raises(ValueError, match="r must be >= 0"): + zeta_of_r(-1) + + # Type mismatch + with pytest.raises(TypeError, match="scale radius cannot be a Quantity"): + zeta_of_r(1, scale_radius=8 * u.kpc) + + # Negative value + with pytest.raises(ValueError, match="scale_radius must be > 0"): + zeta_of_r(1, scale_radius=-1) + + +@pytest.mark.parametrize( + "r, scale_radius, expected, warns", + [ + (0, None, -1.0, False), + (1, None, 0.0, False), + (np.inf, None, 1.0, RuntimeWarning), # edge case + (10, None, 9 / 11, False), + ([0, 1, np.inf], None, [-1.0, 0.0, 1.0], False), + ([0, 1, np.inf], None, [-1.0, 0.0, 1.0], False), + ] +) +def test_zeta_of_r_ArrayLike(r, scale_radius, expected, warns): + """Test :func:`sample_scf.representation.r_of_zeta` with wrong r type.""" + with pytest.warns(warns) if warns is not False else contextlib.nullcontext(): + zeta = zeta_of_r(r, scale_radius=scale_radius) # TODO! scale radius + + assert allclose(zeta, expected) + assert not isinstance(zeta, Quantity) + + +def test_zeta_of_r_Quantity_fail(): + """Test :func:`sample_scf.representation.r_of_zeta`: r=Quantity, with errors.""" + # Wrong units + with pytest.raises(UnitConversionError, match="r must have units of length"): + zeta_of_r(8 * u.s) + + # Negative value + with pytest.raises(ValueError, match="r must be >= 0"): + zeta_of_r(-1 * u.kpc) + + # Type mismatch + with pytest.raises(TypeError, match="scale_radius must be a Quantity"): + zeta_of_r(8 * u.kpc, scale_radius=1) + + # Wrong units + with pytest.raises(UnitConversionError, match="scale_radius must have units of length"): + zeta_of_r(8 * u.kpc, scale_radius=1 * u.s) + + # Non-positive value + with pytest.raises(ValueError, match="scale_radius must be > 0"): + zeta_of_r(1 * u.kpc, scale_radius=-1 * u.kpc) + + +@pytest.mark.parametrize( + "r, scale_radius, expected, warns", + [ + (0 * u.kpc, None, -1.0, False), + (1 * u.kpc, None, 0.0, False), + (np.inf * u.kpc, None, 1.0, RuntimeWarning), # edge case + (10 * u.km, None, 9 / 11, False), + ([0, 1, np.inf] * u.kpc, None, [-1.0, 0.0, 1.0], False), + ([0, 1, np.inf] * u.km, None, [-1.0, 0.0, 1.0], False), + ], +) +def test_zeta_of_r_Quantity(r, scale_radius, expected, warns): + """Test :func:`sample_scf.representation.r_of_zeta` with wrong r type.""" + with pytest.warns(warns) if warns is not False else contextlib.nullcontext(): + zeta = zeta_of_r(r, scale_radius=scale_radius) # TODO! scale radius + + assert allclose(zeta, expected) + assert isinstance(zeta, Quantity) + assert zeta.unit.physical_type == "dimensionless" + + +@pytest.mark.parametrize("r", [0 * u.kpc, 1 * u.kpc, np.inf * u.kpc, [0, 1, np.inf] * u.kpc]) +def test_zeta_of_r_roundtrip(r): + """Test zeta and r round trip. Note that Quantities don't round trip.""" + assert allclose(r_of_zeta(zeta_of_r(r, None), 1), r.value) + # TODO! scale radius + + +# ----------------------------------------------------- + + +@pytest.mark.parametrize( + "zeta, expected, warns", + [ + (-1.0, 0, False), + (0.0, 1, False), + (1.0, np.inf, RuntimeWarning), # edge case + (np.array([-1.0, 0.0, 1.0]), [0, 1, np.inf], False), + ], +) +def test_r_of_zeta(zeta, expected, warns): + """Test :func:`sample_scf.representation.r_of_zeta`.""" + with pytest.warns(warns) if warns is not False else contextlib.nullcontext(): + r = r_of_zeta(zeta, 1) + + assert allclose(r, expected) # TODO! scale_radius + assert isinstance(r, np.ndarray) + + +def test_r_of_zeta_fail(): + """Test when the input is bad.""" + # Under lower bound + with pytest.raises(ValueError, match="zeta must be in"): + r_of_zeta(-2) + + # Above upper bound + with pytest.raises(ValueError, match="zeta must be in"): + r_of_zeta(2) + + +@pytest.mark.parametrize( + "zeta, scale_radius, expected", + [ + (0, 1 * u.pc, 1 * u.pc), + ], +) +def test_r_of_zeta_unit_input(zeta, expected, scale_radius): + """Test when input units.""" + assert allclose(r_of_zeta(zeta, scale_radius), expected) + + +@pytest.mark.skip("TODO!") +@pytest.mark.parametrize("zeta", [-1, 0, 1, [-1, 0, 1]]) +def test_r_of_zeta_roundtrip(zeta): + """Test zeta and r round trip. Note that Quantities don't round trip.""" + assert allclose(zeta_of_r(r_of_zeta(zeta, None), None), zeta) + + +# ----------------------------------------------------- + + +@pytest.mark.parametrize( + "theta, expected", + [ + (0, 1), + (np.pi / 2, 0), + (np.pi, -1), + ([0, np.pi / 2, np.pi], [1, 0, -1]), # array + # with units + (0 << u.rad, 1), + (np.pi / 2 << u.rad, 0), + (np.pi << u.rad, -1), + ([np.pi, np.pi / 2, 0] << u.rad, [-1, 0, 1]), # array + ], +) +def test_x_of_theta(theta, expected): + """Test :func:`sample_scf.representation.x_of_theta`.""" + assert allclose(x_of_theta(theta), expected, atol=1e-16) + + +@pytest.mark.parametrize("theta", [0, np.pi / 2, np.pi, [0, np.pi / 2, np.pi]]) # TODO! units +def test_theta_of_x_roundtrip(theta): + """Test theta and x round trip. Note that Quantities don't round trip.""" + assert allclose(theta_of_x(x_of_theta(theta)), theta << u.rad) + + +# ----------------------------------------------------- + + +@pytest.mark.parametrize( + "x, expected", + [ + (-1, np.pi), + (0, np.pi/2), + (1, 0), + ([-1, 0, 1], [np.pi, np.pi/2, 0]), # array + ], +) +def test_theta_of_x(x, expected): + """Test :func:`sample_scf.representation.theta_of_x`.""" + assert allclose(theta_of_x(x), expected << u.rad) # TODO! units + + +@pytest.mark.parametrize("x", [-1, 0, 1, [-1, 0, 1]]) +def test_roundtrip(x): + """Test x and theta round trip. Note that Quantities don't round trip.""" + assert allclose(x_of_theta(theta_of_x(x)), x, atol=1e-16) # TODO! units diff --git a/sample_scf/tests/test_utils.py b/sample_scf/tests/test_utils.py deleted file mode 100644 index 46bb607..0000000 --- a/sample_scf/tests/test_utils.py +++ /dev/null @@ -1,249 +0,0 @@ -# -*- coding: utf-8 -*- - -"""Testing :mod:`scample_scf.utils`.""" - - -############################################################################## -# IMPORTS - -# BUILT-IN -import contextlib - -# THIRD PARTY -import astropy.units as u -import numpy as np -import pytest -from numpy.testing import assert_allclose - -# LOCAL -from sample_scf.utils import phiRSms, r_of_zeta, theta_of_x, thetaQls, x_of_theta, zeta_of_r - -############################################################################## -# TESTS -############################################################################## - - -class Test_zeta_of_r: - """Testing :func:`sample_scf.utils.zeta_of_r`.""" - - # =============================================================== - # Usage Tests - - @pytest.mark.parametrize( - "r, expected, warns", - [ - (0, -1.0, False), # int -> float - (1, 0.0, False), - (0.0, -1.0, False), # float -> float - (1.0, 0.0, False), - (np.inf, 1.0, RuntimeWarning), # edge case - (u.Quantity(10, u.km), 9 / 11, False), - (u.Quantity(8, u.s), 7 / 9, False), # Note the unit doesn't matter - ], - ) - def test_scalar_input(self, r, expected, warns): - """Test when input scalar.""" - with pytest.warns(warns) if warns is not False else contextlib.nullcontext(): - assert_allclose(zeta_of_r(r), expected) - - @pytest.mark.parametrize( - "r, expected", - [ - ([0, 1, np.inf], [-1.0, 0.0, 1.0]), - (u.Quantity([0, 1, np.inf], u.km), [-1.0, 0.0, 1.0]), - ], - ) - def test_array_input(self, r, expected): - """Test when input array.""" - with pytest.warns(RuntimeWarning): - assert_allclose(zeta_of_r(r), expected) - - @pytest.mark.parametrize("r", [0, 1, np.inf, [0, 1, np.inf]]) - def test_roundtrip(self, r): - """Test zeta and r round trip. Note that Quantities don't round trip.""" - assert_allclose(r_of_zeta(zeta_of_r(r)), r) - - -# ------------------------------------------------------------------- - - -class Test_r_of_zeta: - """Testing :func:`sample_scf.utils.r_of_zeta`.""" - - # =============================================================== - # Usage Tests - - @pytest.mark.parametrize( - "zeta, expected, warns", - [ - (-1.0, 0, False), # int -> float - (0.0, 1, False), - (-1.0, 0.0, False), # float -> float - (0.0, 1.0, False), - (1.0, np.inf, RuntimeWarning), # edge case - (2.0, 0, False), # out of bounds - (-2.0, 0, False), # out of bounds - ], - ) - def test_scalar_input(self, zeta, expected, warns): - """Test when input scalar.""" - with pytest.warns(warns) if warns is not False else contextlib.nullcontext(): - assert_allclose(r_of_zeta(zeta), expected) - - @pytest.mark.parametrize( - "zeta, expected, warns", - [ - ([-1.0, 0.0, 1.0], [0, 1, np.inf], RuntimeWarning), - ], - ) - def test_array_input(self, zeta, expected, warns): - """Test when input array.""" - with pytest.warns(warns) if warns is not False else contextlib.nullcontext(): - assert_allclose(r_of_zeta(zeta), expected) - - @pytest.mark.parametrize( - "zeta, expected, unit", - [ - (0, 1, None), - (0, 1 * u.pc, u.pc), - (0, 1 * u.Hz, u.Hz), - ], - ) - def test_unit_input(self, zeta, expected, unit): - """Test when input units.""" - assert_allclose(r_of_zeta(zeta, unit=unit), expected) - - @pytest.mark.parametrize("zeta", [-1, 0, 1, [-1, 0, 1]]) - def test_roundtrip(self, zeta): - """Test zeta and r round trip. Note that Quantities don't round trip.""" - assert_allclose(zeta_of_r(r_of_zeta(zeta)), zeta) - - -# ------------------------------------------------------------------- - - -class Test_x_of_theta: - """Test `sample_scf.utils.x_of_theta`.""" - - @pytest.mark.parametrize( - "theta, expected", - [ - (-np.pi / 2, -1), - (0, 0), - (np.pi / 2, 1), - ([-np.pi / 2, 0, np.pi / 2], [-1, 0, 1]), # array - # with units - (-np.pi / 2 << u.rad, -1), - (0 << u.deg, 0), - (np.pi / 2 << u.rad, 1), - ([-np.pi / 2, 0, np.pi / 2] << u.rad, [-1, 0, 1]), # array - ], - ) - def test_x_of_theta(self, theta, expected): - assert_allclose(x_of_theta(theta), expected, atol=1e-16) - - @pytest.mark.parametrize("theta", [-np.pi / 2, 0, np.pi / 2, [-np.pi / 2, 0, np.pi / 2]]) - def test_roundtrip(self, theta): - """Test theta and x round trip. Note that Quantities don't round trip.""" - assert_allclose(theta_of_x(x_of_theta(theta << u.rad)), theta) - - -# ------------------------------------------------------------------- - - -class Test_theta_of_x: - """Test `sample_scf.utils.theta_of_x`.""" - - @pytest.mark.parametrize( - "x, expected", - [ - (-1, -np.pi / 2), - (0, 0), - (1, np.pi / 2), - ([-1, 0, 1], [-np.pi / 2, 0, np.pi / 2]), # array - ], - ) - def test_theta_of_x(self, x, expected): - assert_allclose(theta_of_x(x), expected) - - @pytest.mark.parametrize( - "x, expected, unit", - [ - (-1, -np.pi / 2, None), - (0, 0 * u.deg, u.deg), - (1, np.pi / 2 * u.rad, u.rad), - ], - ) - def test_unit_input(self, x, expected, unit): - """Test when input units.""" - assert_allclose(theta_of_x(x, unit=unit), expected) - - @pytest.mark.parametrize("x", [-1, 0, 1, [-1, 0, 1]]) - def test_roundtrip(self, x): - """Test x and theta round trip. Note that Quantities don't round trip.""" - assert_allclose(x_of_theta(theta_of_x(x)), x, atol=1e-16) - - -# ------------------------------------------------------------------- - - -class Test_thetaQls: - """Test `sample_scf.utils.x_of_theta`.""" - - # =============================================================== - # Usage Tests - - @pytest.mark.parametrize("r, expected", [(0, 1), (1, 0.01989437), (np.inf, 0)]) - def test_hernquist(self, hernquist_scf_potential, r, expected): - Qls = thetaQls(hernquist_scf_potential, r=r) - # shape should be L (see setup_class) - assert len(Qls) == 6 - # only 1st index is non-zero - assert np.isclose(Qls[0], expected) - assert_allclose(Qls[1:], 0) - - @pytest.mark.skip("TODO!") - def test_nfw(self, nfw_scf_potential): - assert False - - -# ------------------------------------------------------------------- - - -class Test_phiRSms: - """Test `sample_scf.utils.x_of_theta`.""" - - # =============================================================== - # Tests - - # @pytest.mark.skip("TODO!") - @pytest.mark.parametrize( - "r, theta, expected", - [ - # show it doesn't depend on theta - (0, -np.pi / 2, (np.zeros(5), np.zeros(5))), - (0, 0, (np.zeros(5), np.zeros(5))), # special case when x=0 is 0 - (0, np.pi / 6, (np.zeros(5), np.zeros(5))), - (0, np.pi / 2, (np.zeros(5), np.zeros(5))), - # nor on r - (1, -np.pi / 2, (np.zeros(5), np.zeros(5))), - (10, -np.pi / 4, (np.zeros(5), np.zeros(5))), - (100, np.pi / 6, (np.zeros(5), np.zeros(5))), - (1000, np.pi / 2, (np.zeros(5), np.zeros(5))), - # Legendre[n=0, l=0, z=z] = 1 is a special case - (1, 0, (np.zeros(5), np.zeros(5))), - (10, 0, (np.zeros(5), np.zeros(5))), - (100, 0, (np.zeros(5), np.zeros(5))), - (1000, 0, (np.zeros(5), np.zeros(5))), - ], - ) - def test_phiRSms_hernquist(self, hernquist_scf_potential, r, theta, expected): - Rm, Sm = phiRSms(hernquist_scf_potential, r, theta, warn=False) - assert Rm.shape == Sm.shape - assert Rm.shape == (1, 1, 6) - assert_allclose(Rm[0, 0, 1:], expected[0], atol=1e-16) - assert_allclose(Sm[0, 0, 1:], expected[1], atol=1e-16) - - if theta == 0 and r != 0: - assert Rm[0, 0, 0] != 0 - assert Sm[0, 0, 0] == 0 diff --git a/sample_scf/utils.py b/sample_scf/utils.py deleted file mode 100644 index 5834261..0000000 --- a/sample_scf/utils.py +++ /dev/null @@ -1,410 +0,0 @@ -# -*- coding: utf-8 -*- - -"""Utility functions.""" - -############################################################################## -# IMPORTS - -from __future__ import annotations - -# BUILT-IN -import functools -import warnings -from contextlib import nullcontext -from typing import Optional, Tuple, Union - -# THIRD PARTY -import astropy.units as u -import numpy as np -import numpy.typing as npt -from galpy.potential import SCFPotential -from numpy import arange, arccos, array, atleast_1d, cos, divide, nan_to_num, pi, sqrt, stack, sum -from numpy.typing import ArrayLike -from scipy.special import legendre, lpmn - -# LOCAL -from ._typing import NDArrayF - -__all__ = [ - "zeta_of_r", - "r_of_zeta", - "x_of_theta", - "difPls", - "thetaQls", - "phiRSms", -] - -############################################################################## -# PARAMETERS - -lpmn_vec = np.vectorize(lpmn, otypes=(object, object)) - -# # pre-compute the difPls -with warnings.catch_warnings(): - warnings.simplefilter("ignore") - - # TODO! allow for lmax > 200. - lrange = arange(0, 200 + 1) - Pls = array([legendre(L) for L in lrange], dtype=object) - # l=1+. l=0 is done separately - _difPls = (Pls[2:] - Pls[:-2]) / (2 * lrange[1:-1] + 1) - - -def difPls(x: Union[float, NDArrayF], lmax: int) -> NDArrayF: - # TODO? speed up - # TODO! the lmax = 1 case - return array([dPl(x) for dPl in _difPls[:lmax]]) - - -############################################################################## -# CODE -############################################################################## - - -def zeta_of_r(r: Union[u.Quantity, NDArrayF]) -> NDArrayF: - r""":math:`\zeta = \frac{r - 1}{r + 1}` - - Map the half-infinite domain [0, infinity) -> [-1, 1] - - Parameters - ---------- - r : quantity-like ['length'] - 'r' must be in [0, infinity). - - Returns - ------- - zeta : ndarray - With shape (len(r),) - """ - ra: NDArrayF = np.array(r, dtype=np.float64, copy=False) - zeta: NDArrayF = nan_to_num(divide(ra - 1, ra + 1), nan=1) - return zeta - - -def r_of_zeta( - zeta: ArrayLike, - unit: Optional[u.UnitBase] = None, -) -> Union[u.Quantity, NDArrayF]: - r""":math:`r = \frac{1 + \zeta}{1 - \zeta}` - - Map back to the half-infinite domain [0, infinity) <- [-1, 1] - - Parameters - ---------- - zeta : array-like - unit : `astropy.units.UnitBase` or None, optional - - Returns - ------- - r: ndarray[float] or Quantity - If Quantity, has units of 'units'. - """ - z = array(zeta, subok=True) - r = atleast_1d(divide(1 + z, 1 - z)) - r[r < 0] = 0 # correct small errors - - rq: Union[NDArrayF, u.Quantity] - rq = r << unit if unit is not None else r - - return rq - - -# ------------------------------------------------------------------- - - -@functools.singledispatch -def x_of_theta(theta: ArrayLike) -> NDArrayF: - r""":math:`x = \cos{\theta}`. - - Parameters - ---------- - theta : array-like ['radian'] - :math:`\theta \in [-\pi/2, \pi/2]` - - Returns - ------- - x : ndarray[float] - :math:`x \in [-1, 1]` - """ - x: NDArrayF = cos(pi / 2 - np.asanyarray(theta)) - return x - - -@x_of_theta.register -def _(theta: u.Quantity) -> NDArrayF: - r""":math:`x = \cos{\theta}`. - - Parameters - ---------- - theta : quantity-like ['radian'] - - Returns - ------- - x : float or ndarray - """ - x: NDArrayF = cos(pi / 2 - theta.to_value(u.rad)) - return x - - -def theta_of_x( - x: ArrayLike, - unit: Optional[u.UnitBase] = None, -) -> Union[NDArrayF, u.Quantity]: - r""":math:`\theta = \cos^{-1}{x}`. - - Parameters - ---------- - x : array-like - unit : unit-like['angular'] or None, optional - - Returns - ------- - theta : float or ndarray - """ - th: NDArrayF = pi / 2 - arccos(x) - - theta: Union[NDArrayF, u.Quantity] - if unit is not None: - theta = u.Quantity(th, u.rad).to(unit) - else: - theta = th - - return theta - - -# ------------------------------------------------------------------- - - -def thetaQls(pot: SCFPotential, r: Union[float, NDArrayF]) -> NDArrayF: - r""" - Radial sums for inclination weighting factors. - The weighting factors measure perturbations from spherical symmetry. - - :math:`Q_l(r) = \sum_{n=0}^{n_{\max}}A_{nl} \tilde{\rho}_{nl0}(r)` - - Parameters - ---------- - pot - r : float ['kpc'] - - Returns - ------- - Ql : ndarray - - """ - # with warnings.catch_warnings(): # TODO! diagnose RuntimeWarning - # warnings.filterwarnings("ignore", category=RuntimeWarning, message="(^invalid value)") - - # compute the r-dependent coefficient matrix $\tilde{\rho}$ # (R, N, L) - nmax, lmax = pot._Acos.shape[:2] - rs = atleast_1d(r) # need r to be array. - rhoTilde = nan_to_num( - array([pot._rhoTilde(r, N=nmax, L=lmax) for r in rs]), - posinf=np.inf, - neginf=-np.inf, - ) - - # inclination weighting factors - Qls = nan_to_num(sum(pot._Acos[None, :, :, 0] * rhoTilde, axis=1), nan=1) # (R, N, L) - - # remove extra dimensions, e.g. scalar 'r' - Ql: NDArrayF = Qls.squeeze() - - return Ql - - -# ------------------------------------------------------------------- - - -def _pnts_phiRSms( - rhoTilde: NDArrayF, - Acos: NDArrayF, - Asin: NDArrayF, - r: ArrayLike, - theta: ArrayLike, -) -> Tuple[NDArrayF, NDArrayF]: - """Radial and inclination sums for azimuthal weighting factors. - - Parameters - ---------- - rhoTilde: (R, N, L) ndarray - Acos, Asin : (N, L, L) ndarray - r, theta : float or ndarray[float] - With shapes (R,), (T,), respectively. - - Returns - ------- - Rm, Sm : (R, T, L) ndarray - Azimuthal weighting factors. - - Warns - ----- - RuntimeWarning - For invalid values (inf addition -> Nan). - For overflow encountered related to inf and 0 division. - """ - # need r and theta to be arrays. Maintains units. - tgrid: NDArrayF = atleast_1d(theta) - - # transform to correct shape for vectorized computation - x = x_of_theta(tgrid) # (R/T,) - Xs = x[:, None, None, None] # (R/T, {N}, {L}, {L}) - - # compute the r-dependent coefficient matrix $\tilde{\rho}$ - nmax, lmax = Acos.shape[:2] - RhoT = rhoTilde[:, :, :, None] # (R/T, N, L, {L}) - - # legendre polynomials - lps = lpmn_vec(lmax - 1, lmax - 1, x)[0] # drop deriv - - PP = np.stack(lps, axis=0).astype(float)[:, None, :, :] # (R/T, {N}, L, L) - - # full R & S matrices - RSnlm = RhoT * sqrt(1 - Xs ** 2) * PP # (R/T, N, L, L) - - # n-sum # (R/T, N, L, L) -> (R/T, L, L) - Rlm = sum(Acos[None, :, :, :] * RSnlm, axis=1) - Slm = sum(Asin[None, :, :, :] * RSnlm, axis=1) - # fix adding +/- inf -> NaN. happens when r=0. - idx = np.all(np.isnan(Rlm[:, 0, :]), axis=-1) - Rlm[idx, 0, :] = nan_to_num(Rlm[idx, 0, :]) - Slm[idx, 0, :] = nan_to_num(Slm[idx, 0, :]) - - # m-sum # (R/T, L) - sumidx = range(Rlm.shape[1]) - Rm = stack([sum(Rlm[:, m:, m], axis=1) for m in sumidx], axis=1) - Sm = stack([sum(Slm[:, m:, m], axis=1) for m in sumidx], axis=1) - - return Rm, Sm - - -def _grid_phiRSms( - rhoTilde: NDArrayF, - Acos: NDArrayF, - Asin: NDArrayF, - r: ArrayLike, - theta: ArrayLike, -) -> Tuple[NDArrayF, NDArrayF]: - """Radial and inclination sums for azimuthal weighting factors. - - Parameters - ---------- - rhoTilde: (R, N, L) ndarray - Acos, Asin : (N, L, L) ndarray - r, theta : float or ndarray[float] - With shapes (R,), (T,), respectively. - - Returns - ------- - Rm, Sm : (R, T, L) ndarray - Azimuthal weighting factors. - - Warns - ----- - RuntimeWarning - For invalid values (inf addition -> Nan). - For overflow encountered related to inf and 0 division. - """ - # need r and theta to be arrays. Maintains units. - tgrid: NDArrayF = atleast_1d(theta) - rgrid: NDArrayF = atleast_1d(r) - - # transform to correct shape for vectorized computation - x = x_of_theta(tgrid) # (T,) - Xs = x[None, :, None, None, None] # ({R}, X, {N}, {L}, {L}) - - # format the r-dependent coefficient matrix $\tilde{\rho}$ - nmax, lmax = Acos.shape[:2] - RhoT = rhoTilde[:, None, :, :, None] # (R, {X}, N, L, {L}) - - # legendre polynomials # raises RuntimeWarnings - lps = lpmn_vec(lmax - 1, lmax - 1, x)[0] # drop deriv - - PP = np.stack(lps, axis=0).astype(float)[None, :, None, :, :] - # ({R}, X, {N}, L, L) - - # full R & S matrices # raises RuntimeWarnings - RSnlm = RhoT * sqrt(1 - Xs ** 2) * PP # (R, X, N, L, L) - # for r=0, rhoT can be +/- inf. If added / multiplied by 0 this will be NaN - # we can safely set this to 0 if rhoT's infinities cancel - i0 = rgrid == 0 - if not np.sum(np.nan_to_num(RhoT[i0, 0, :, 0, 0], posinf=1, neginf=-1)) == 0: - # note: this if statement works even if ind0 is all False - warnings.warn("RhoT have non-cancelling infinities at r==0") - else: - RSnlm[i0, 0, :, 0, :] = np.nan_to_num(RSnlm[i0, 0, :, 0, :], copy=False) - - # n-sum # (R, X, L, L) # raises RuntimeWarnings - Rlm = sum(Acos[None, None, :, :, :] * RSnlm, axis=2) - Slm = sum(Asin[None, None, :, :, :] * RSnlm, axis=2) - # fix adding +/- inf -> NaN. happens when r=0. - # the check for cancelling infinities is done above. - # the [X]=0 case is handled above as well. - Rlm[i0, 1:, 0, :] = nan_to_num(Rlm[i0, 1:, 0, :], copy=False) - Slm[i0, 1:, 0, :] = nan_to_num(Slm[i0, 1:, 0, :], copy=False) - - # m-sum # (R, X, L) - sumidx = range(Rlm.shape[2]) - Rm = stack([sum(Rlm[:, :, m:, m], axis=2) for m in sumidx], axis=2) - Sm = stack([sum(Slm[:, :, m:, m], axis=2) for m in sumidx], axis=2) - - return Rm, Sm - - -def phiRSms( - pot: SCFPotential, - r: ArrayLike, - theta: ArrayLike, - grid: bool = True, - warn: bool = True, -) -> Tuple[NDArrayF, NDArrayF]: - r"""Radial and inclination sums for azimuthal weighting factors. - - .. math:: - [R/S]_{l}^{m}(r,x)= \left(\sum_{n=0}^{n_{\max}} [A/B]_{nlm} - \tilde{\rho}_{nlm}(r) \right) r \sqrt{1-x^2} - P_{l}^{m}(x) - - [R/S]^{m}(r, x) = \sum_{l=m}^{l_{\max}} [R/S]_{l}^{m}(r,x) - - Parameters - ---------- - pot : :class:`galpy.potential.SCFPotential` - Has coefficient matrices Acos and Asin with shape (N, L, L). - r : float or ndarray[float] - theta : float or ndarray[float] - - Returns - ------- - Rm, Sm : ndarray[float] - Azimuthal weighting factors. Shape (len(r), len(theta), L). - """ - # need r and theta to be arrays. The extra dimensions will be 'squeeze'd. - rgrid: npt.NDArray = atleast_1d(r) - tgrid: npt.NDArray = atleast_1d(theta) - - # compute the r-dependent coefficient matrix $\tilde{\rho}$ # (R, N, L) - nmax: int - lmax: int - nmax, lmax = pot._Acos.shape[:2] - rhoTilde = nan_to_num( - array([pot._rhoTilde(r, N=nmax, L=lmax) for r in rgrid]), # todo! vectorize - nan=0, - posinf=np.inf, - neginf=-np.inf, - ) - - # pass to actual calculator, which takes the matrices and r, theta grids. - with warnings.catch_warnings() if not warn else nullcontext(): - if not warn: - warnings.filterwarnings( - "ignore", - category=RuntimeWarning, - message="(^invalid value)|(^overflow encountered)", - ) - if grid: - Rm, Sm = _grid_phiRSms(rhoTilde, pot._Acos, pot._Asin, rgrid, tgrid) - else: - Rm, Sm = _pnts_phiRSms(rhoTilde, pot._Acos, pot._Asin, rgrid, tgrid) - - return Rm, Sm diff --git a/setup.py b/setup.py index 17ad75e..81872f8 100755 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ # NOTE: The configuration for the package, including the name, version, and # other information are set in the setup.cfg file. -# BUILT-IN +# STDLIB import os import sys From cdd8f148afe5a57d8ac4613a12050f2cf1fcabad Mon Sep 17 00:00:00 2001 From: nstarman Date: Fri, 8 Apr 2022 10:43:08 -0400 Subject: [PATCH 30/31] codestyle Signed-off-by: nstarman --- sample_scf/base_multivariate.py | 19 ++-- sample_scf/base_univariate.py | 82 ++++++++--------- sample_scf/conftest.py | 2 +- sample_scf/exact/__init__.py | 5 +- sample_scf/exact/inclination.py | 60 ++++++------- sample_scf/exact/tests/test_core.py | 6 +- sample_scf/exact/tests/test_utils.py | 3 - sample_scf/interpolated/azimuth.py | 90 +++++++++++-------- sample_scf/interpolated/core.py | 81 ++++++++++------- sample_scf/interpolated/inclination.py | 58 ++++++++---- sample_scf/interpolated/radial.py | 82 ++++++++--------- .../interpolated/tests/test_interpolated.py | 12 ++- sample_scf/representation.py | 43 +++++---- sample_scf/tests/base.py | 7 +- sample_scf/tests/data/__init__.py | 8 +- .../tests/data/data_Test_rv_potential.py | 12 +-- sample_scf/tests/test_base_multivariate.py | 31 ++++--- sample_scf/tests/test_base_univariate.py | 22 +++-- sample_scf/tests/test_conftest.py | 62 ++++++------- sample_scf/tests/test_core.py | 9 +- sample_scf/tests/test_representation.py | 29 +++--- 21 files changed, 385 insertions(+), 338 deletions(-) diff --git a/sample_scf/base_multivariate.py b/sample_scf/base_multivariate.py index 9de8f60..8cc271d 100644 --- a/sample_scf/base_multivariate.py +++ b/sample_scf/base_multivariate.py @@ -19,8 +19,13 @@ from galpy.potential import SCFPotential # LOCAL +from .base_univariate import ( + phi_distribution_base, + r_distribution_base, + rv_potential, + theta_distribution_base, +) from sample_scf._typing import NDArrayF, RandomGenerator, RandomLike -from .base_univariate import theta_distribution_base, r_distribution_base, phi_distribution_base, rv_potential __all__: List[str] = ["SCFSamplerBase"] @@ -95,7 +100,7 @@ def radial_scale_factor(self) -> Quantity: @property def nmax(self) -> int: return self._r_distribution._nmax - + @property def lmax(self) -> int: return self._r_distribution._lmax @@ -104,11 +109,11 @@ def lmax(self) -> int: def calculate_rhoTilde(self, radii: Quantity) -> NDArrayF: """ - + Parameters ---------- radii : (R,) Quantity['length', float] - + returns ------- (R, N, L) ndarray[float] @@ -119,9 +124,9 @@ def calculate_Qls(self, r: Quantity, rhoTilde=None) -> NDArrayF: r""" Radial sums for inclination weighting factors. The weighting factors measure perturbations from spherical symmetry. - + :math:`Q_l(r) = \sum_{n=0}^{n_{\max}}A_{nl} \tilde{\rho}_{nl0}(r)` - + Parameters ---------- r : (R,) Quantity['kpc', float] @@ -151,7 +156,7 @@ def calculate_Scs( theta : float or (T,) ndarray[float] grid : bool, optional keyword-only warn : bool, optional keyword-only - + Returns ------- Rm, Sm : (R, T, L) ndarray[float] diff --git a/sample_scf/base_univariate.py b/sample_scf/base_univariate.py index 757dc93..1284b37 100644 --- a/sample_scf/base_univariate.py +++ b/sample_scf/base_univariate.py @@ -16,11 +16,11 @@ import astropy.units as u import numpy as np from galpy.potential import SCFPotential -from numpy import atleast_1d, arange, inf, pi, zeros, tril_indices, nan_to_num, array, isinf, sum +from numpy import arange, array, atleast_1d, inf, isinf, nan_to_num, pi, sum, tril_indices, zeros from numpy.typing import ArrayLike from scipy._lib._util import check_random_state -from scipy.stats import rv_continuous from scipy.special import lpmv +from scipy.stats import rv_continuous # LOCAL from sample_scf._typing import NDArrayF, RandomGenerator, RandomLike @@ -257,7 +257,6 @@ def rvs( *args: Union[np.floating, ArrayLike], size: Optional[int] = None, random_state: RandomLike = None, - # return_thetas: bool = True, ) -> NDArrayF: return super().rvs( *args, @@ -268,7 +267,7 @@ def rvs( # --------------------------------------------------------------- - def calculate_Qls(self, r: Quantity, rhoTilde: Optional[NDArrayF]=None) -> NDArrayF: + def calculate_Qls(self, r: Quantity, rhoTilde: Optional[NDArrayF] = None) -> NDArrayF: r""" Compute the radial sums for inclination weighting factors. The weighting factors measure perturbations from spherical symmetry. @@ -294,7 +293,7 @@ def calculate_Qls(self, r: Quantity, rhoTilde: Optional[NDArrayF]=None) -> NDArr # this matrix can have incorrect NaN values when radii=0 because # rhoTilde will have +/- infs which when summed produce a NaN. # at r=0 this can be changed to 0. # TODO! double confirm math - ind0 = (r == 0) + ind0 = r == 0 if not sum(nan_to_num(rhoT[ind0, :, 0], posinf=1, neginf=-1)) == 0: # note: this if statement works even if ind0 is all False warnings.warn("Qls have non-cancelling infinities at r==0") @@ -324,23 +323,24 @@ def __init__(self, potential: SCFPotential, **kwargs: Any) -> None: @staticmethod def _pnts_Scs( - r: NDArrayF, + radii: NDArrayF, + theta: NDArrayF, rhoTilde: NDArrayF, Acos: NDArrayF, Asin: NDArrayF, - theta: NDArrayF, ) -> Tuple[NDArrayF, NDArrayF]: """Radial and inclination sums for azimuthal weighting factors. Parameters ---------- - rhoTilde: (R, N, L) ndarray - Acos, Asin : (N, L, L) ndarray - theta : (T,) ndarray[float] + radii : (R/T,) ndarray[float] + rhoTilde: (R/T, N, L) ndarray[float] + Acos, Asin : (N, L, L) ndarray[float] + theta : (R/T,) ndarray[float] Returns ------- - Scm, Ssm : (R, T, L) ndarray + Scm, Ssm : (R, T, L) ndarray[float] Azimuthal weighting factors. Cosine and Sine, respectively. @@ -365,7 +365,7 @@ def _pnts_Scs( ls, ms = tril_indices(L + 1) # index set I_(L, M) lps = zeros((T, L + 1, M + 1)) # (R/T, L, M) - lps[:, ls, ms] = lpmv(ls[None, :], ms[None, :], xs[:, 0, 0, 0]) + lps[:, ls, ms] = lpmv(ms[None, :], ls[None, :], xs[:, 0, 0, 0]) Plm = lps[:, None, :, :] # (R/T, {N}, L, M) # full S matrices (R/T, N, L, M) # TODO! where's Nlm @@ -373,10 +373,10 @@ def _pnts_Scs( Sclm = np.sum(Acos[None, :, :, :] * RhoT * Plm, axis=-3) Sslm = np.sum(Asin[None, :, :, :] * RhoT * Plm, axis=-3) - # # fix adding +/- inf -> NaN. happens when r=0. - # idx = np.all(np.isnan(Rlm[:, 0, :]), axis=-1) - # Rlm[idx, 0, :] = nan_to_num(Rlm[idx, 0, :]) - # Slm[idx, 0, :] = nan_to_num(Slm[idx, 0, :]) + # fix adding +/- inf -> NaN. happens when r=0. + idx = radii == 0 + Sclm[idx] = nan_to_num(Sclm[idx], posinf=np.inf, neginf=-np.inf) + Sslm[idx] = nan_to_num(Sslm[idx], posinf=np.inf, neginf=-np.inf) # l'-sum # FIXME! confirm correct som Scm = np.sum(Sclm, axis=-2) @@ -386,23 +386,24 @@ def _pnts_Scs( @staticmethod def _grid_Scs( - r: NDArrayF, + radii: NDArrayF, + thetas: NDArrayF, rhoTilde: NDArrayF, Acos: NDArrayF, Asin: NDArrayF, - theta: NDArrayF, ) -> Tuple[NDArrayF, NDArrayF]: """Radial and inclination sums for azimuthal weighting factors. Parameters ---------- - rhoTilde: (R, N, L) ndarray - Acos, Asin : (N, L, L) ndarray - theta : (T,) ndarray[float] + radii : (R,) ndarray[float] + rhoTilde: (R, N, L) ndarray[float] + Acos, Asin : (N, L, L) ndarray[float] + thetas : (T,) ndarray[float] Returns ------- - Scm, Ssm : (R, T, L) ndarray + Scm, Ssm : (R, T, L) ndarray[float] Azimuthal weighting factors. Cosine and Sine, respectively. @@ -412,7 +413,7 @@ def _grid_Scs( For invalid values (inf addition -> Nan). For overflow encountered related to inf and 0 division. """ - T: int = len(theta) + T: int = len(thetas) N = Acos.shape[0] - 1 L = M = Acos.shape[1] - 1 @@ -420,14 +421,14 @@ def _grid_Scs( RhoT = rhoTilde[:, None, :, :, None] # (R, {T}, N, L, {M}) # need r and theta to be arrays. Maintains units. - x: NDArrayF = x_of_theta(theta) # (T,) + x: NDArrayF = x_of_theta(thetas << u.rad) # (T,) xs = x[None, :, None, None, None] # ({R}, T, {N}, {L}, {M}) # legendre polynomials ls, ms = tril_indices(L + 1) # index set I_(L, M) lps = zeros((T, L + 1, M + 1)) # (T, L, M) - lps[:, ls, ms] = lpmv(ls[None, ...], ms[None, ...], xs[0, :, 0, 0, 0, None]) + lps[:, ls, ms] = lpmv(ms[None, ...], ls[None, ...], xs[0, :, 0, 0, 0, None]) Plm = lps[None, :, None, :, :] # ({R}, T, {N}, L, M) # full S matrices (R, T, N, L, M) @@ -436,11 +437,11 @@ def _grid_Scs( Sslm = np.sum(Asin[None, None, :, :, :] * RhoT * Plm, axis=-3) # fix adding +/- inf -> NaN. happens when r=0. - idx = (r == 0) - Sclm[idx] = nan_to_num(Sclm[idx]) - Sslm[idx] = nan_to_num(Sslm[idx]) + idx = radii == 0 + Sclm[idx, ...] = nan_to_num(Sclm[idx, ...], posinf=np.inf, neginf=-np.inf) + Sslm[idx, ...] = nan_to_num(Sslm[idx, ...], posinf=np.inf, neginf=-np.inf) - # l'-sum # FIXME! confirm correct som + # l'-sum Scm = np.sum(Sclm, axis=-2) Ssm = np.sum(Sslm, axis=-2) @@ -458,10 +459,9 @@ def calculate_Scs( Parameters ---------- - pot : :class:`galpy.potential.SCFPotential` - Has coefficient matrices Acos and Asin with shape (N, L, L). r : float or (R,) ndarray[float] theta : float or (T,) ndarray[float] + grid : bool, optional keyword-only warn : bool, optional keyword-only @@ -472,24 +472,14 @@ def calculate_Scs( """ # need r and theta to be float arrays. rdtype = np.result_type(float, np.result_type(r)) - radii: NDArrayF = atleast_1d(r) # (R,) + radii: NDArrayF = atleast_1d(r).astype(rdtype) # (R,) thetas: NDArrayF = atleast_1d(theta) << u.rad # (T,) if not grid and len(thetas) != len(radii): raise ValueError # compute the r-dependent coefficient matrix $\tilde{\rho}$ # (R, N, L) - nmaxp1: int = self.potential._Acos.shape[0] - lmaxp1: int = self.potential._Acos.shape[1] - galpyrs = radii.to_value(u.kpc) / self.potential._ro - rhoTilde = nan_to_num( - array( - [self.potential._rhoTilde(r, N=nmaxp1, L=lmaxp1) for r in galpyrs] - ), # TODO! vectorize - nan=0, - posinf=inf, - neginf=-inf, - ) + rhoTilde = self.calculate_rhoTilde(radii) # pass to actual calculator, which takes the matrices and r, theta grids. with warnings.catch_warnings() if not warn else nullcontext(): @@ -501,11 +491,11 @@ def calculate_Scs( ) func = self._grid_Scs if grid else self._pnts_Scs Sc, Ss = func( - r, - rhoTilde, + radii, + thetas, + rhoTilde=rhoTilde, Acos=self.potential._Acos, Asin=self.potential._Asin, - theta=thetas, ) return Sc, Ss diff --git a/sample_scf/conftest.py b/sample_scf/conftest.py index 34d33e9..e89fc0c 100644 --- a/sample_scf/conftest.py +++ b/sample_scf/conftest.py @@ -94,7 +94,7 @@ def pytest_configure(config): # Acos = copy.deepcopy(data["Acos"]) # Asin = None # a_scf = data["a_scf"] -# +# # _nfw_scf_potential = SCFPotential(Acos=Acos, Asin=None, a=a_scf, normalize=1.0) # _nfw_scf_potential.turn_physical_on() diff --git a/sample_scf/exact/__init__.py b/sample_scf/exact/__init__.py index f710950..ce8ca03 100644 --- a/sample_scf/exact/__init__.py +++ b/sample_scf/exact/__init__.py @@ -2,11 +2,10 @@ # Licensed under a 3-clause BSD style license - see LICENSE.rst # LOCAL +from .azimuth import exact_phi_distribution, exact_phi_fixed_distribution from .core import ExactSCFSampler +from .inclination import exact_theta_distribution, exact_theta_fixed_distribution from .radial import exact_r_distribution -from .inclination import exact_theta_fixed_distribution, exact_theta_distribution -from .azimuth import exact_phi_fixed_distribution, exact_phi_distribution - __all__ = [ # multivariate diff --git a/sample_scf/exact/inclination.py b/sample_scf/exact/inclination.py index 0ecee29..2924f3d 100644 --- a/sample_scf/exact/inclination.py +++ b/sample_scf/exact/inclination.py @@ -21,7 +21,7 @@ # LOCAL from sample_scf._typing import NDArrayF, RandomLike from sample_scf.base_univariate import theta_distribution_base -from sample_scf.representation import x_of_theta, theta_of_x +from sample_scf.representation import theta_of_x, x_of_theta __all__ = ["exact_theta_fixed_distribution", "exact_theta_distribution"] @@ -77,35 +77,35 @@ def _cdf(self, x: NDArrayF, Qls: NDArrayF) -> NDArrayF: cdf = term0 + np.nan_to_num((factor * (sumPlm1 - sumPlp1).T).T) # (R, T) return cdf # TODO! get rid of sf function -# @abc.abstractmethod -# def _cdf(self, x: NDArrayF, Qls: NDArrayF) -> NDArrayF: -# """Cumulative Distribution Function. -# -# .. math:: -# -# F_{\theta}(\theta; r) = \frac{1 + \cos{\theta}}{2} + -# \frac{1}{2 Q_0(r)}\sum_{\ell=1}^{L_{\max}}Q_{\ell}(r) -# \frac{\sin(\theta) P_{\ell}^{1}(\cos{\theta})}{\ell(\ell+1)} -# -# Where -# -# Q_{\ell}(r) = \sum_{n=0}^{N_{\max}} N_{\ell 0} A_{n\ell 0}^{(\cos)} -# \tilde{\rho}_{n\ell}(r) -# -# Parameters -# ---------- -# x : number or (T,) array[number] -# :math:`x = \cos\theta`. Must be in the range [-1, 1] -# Qls : (R, L) array[float] -# Radially-dependent coefficients parameterizing the deviations from -# a uniform distribution on the inclination angle. -# -# Returns -# ------- -# (R, T) array -# """ -# sf = self._sf(x, Qls) -# return 1.0 - sf + # @abc.abstractmethod + # def _cdf(self, x: NDArrayF, Qls: NDArrayF) -> NDArrayF: + # """Cumulative Distribution Function. + # + # .. math:: + # + # F_{\theta}(\theta; r) = \frac{1 + \cos{\theta}}{2} + + # \frac{1}{2 Q_0(r)}\sum_{\ell=1}^{L_{\max}}Q_{\ell}(r) + # \frac{\sin(\theta) P_{\ell}^{1}(\cos{\theta})}{\ell(\ell+1)} + # + # Where + # + # Q_{\ell}(r) = \sum_{n=0}^{N_{\max}} N_{\ell 0} A_{n\ell 0}^{(\cos)} + # \tilde{\rho}_{n\ell}(r) + # + # Parameters + # ---------- + # x : number or (T,) array[number] + # :math:`x = \cos\theta`. Must be in the range [-1, 1] + # Qls : (R, L) array[float] + # Radially-dependent coefficients parameterizing the deviations from + # a uniform distribution on the inclination angle. + # + # Returns + # ------- + # (R, T) array + # """ + # sf = self._sf(x, Qls) + # return 1.0 - sf def _rvs( self, diff --git a/sample_scf/exact/tests/test_core.py b/sample_scf/exact/tests/test_core.py index e885f1b..844d44a 100644 --- a/sample_scf/exact/tests/test_core.py +++ b/sample_scf/exact/tests/test_core.py @@ -11,12 +11,12 @@ import matplotlib.pyplot as plt import numpy as np import pytest +from sampler_scf.base_multivariate import SCFSamplerBase # LOCAL -from .test_base_multivariate import BaseTest_SCFSamplerBase, radii, thetas, phis -from sampler_scf.base_multivariate import SCFSamplerBase +from .test_base_multivariate import BaseTest_SCFSamplerBase, phis, radii, thetas from sample_scf import ExactSCFSampler -from sample_scf.exact import exact_r_distribution, exact_theta_distribution, exact_phi_distribution +from sample_scf.exact import exact_phi_distribution, exact_r_distribution, exact_theta_distribution ############################################################################## # CODE diff --git a/sample_scf/exact/tests/test_utils.py b/sample_scf/exact/tests/test_utils.py index d94a493..715a2fa 100644 --- a/sample_scf/exact/tests/test_utils.py +++ b/sample_scf/exact/tests/test_utils.py @@ -15,9 +15,6 @@ import pytest from numpy.testing import assert_allclose -# LOCAL -from sample_scf.interpolated.utils import - ############################################################################## # TESTS ############################################################################## diff --git a/sample_scf/interpolated/azimuth.py b/sample_scf/interpolated/azimuth.py index 976aa2c..d1f0b44 100644 --- a/sample_scf/interpolated/azimuth.py +++ b/sample_scf/interpolated/azimuth.py @@ -20,6 +20,7 @@ import astropy.units as u import numpy as np from galpy.potential import SCFPotential +from numpy import argsort, linspace, nan_to_num, pi, arange, sum, sin, cos, inf from numpy.typing import ArrayLike from scipy.interpolate import RegularGridInterpolator, splev, splrep @@ -28,9 +29,17 @@ from sample_scf.base_univariate import phi_distribution_base from sample_scf.representation import x_of_theta, zeta_of_r +from .radial import interpolated_r_distribution +from .inclination import interpolated_theta_distribution + __all__ = ["interpolated_phi_distribution"] +############################################################################## +# PARAMETERS + +_phi_filter = dict(category=RuntimeWarning, message="(^invalid value)|(^overflow encountered)") + ############################################################################## # CODE ############################################################################## @@ -60,53 +69,50 @@ def __init__( nintrp: float = 1e3, **kw: Any, ) -> None: - (Sc, Ss) = kw.pop("Scs", (None, None)) + rhoTilde = kw.pop("rhoTilde", None) # must be same sort order as super().__init__(potential, **kw) # allowed range of r - self._phi_interpolant = np.linspace(0, 2 * np.pi, int(nintrp)) << u.rad + self._phi_interpolant = linspace(0, 2 * pi, int(nintrp)) << u.rad self._ninterpolant = len(self._phi_interpolant) - self._q_interpolant = qarr = np.linspace(0, 1, self._ninterpolant) + self._q_interpolant = qarr = linspace(0, 1, self._ninterpolant) # ------- # build CDF - zetas = zeta_of_r(radii) # (R,) - - xs_unsorted = x_of_theta(thetas << u.rad) # (T,) - xsort = np.argsort(xs_unsorted) - xs = xs_unsorted[xsort] - thetas = thetas[xsort] + radii, zetas = interpolated_r_distribution.order_radii(self, radii) # (R,) + thetas, xs = interpolated_theta_distribution.order_thetas(thetas) # (T,) + phis = interpolated_phi_distribution.order_phis(phis) # (P,) + self._phis = phis lR, lT, _ = len(radii), len(thetas), len(phis) - Phis = phis[None, None, :, None] # ({R}, {T}, P, {L}) # get Sc, Ss. We have defaults from above. - if Sc is None: - print("WTF?") - Sc, Ss = self.calculate_Scs(radii, thetas, grid=True, warn=False) # (R, T, L) - elif (Sc.shape != Ss.shape) or (Sc.shape != (lR, lT, self._lmax + 1)): - # check the user-passed values are the right shape - raise ValueError(f"Sc, Ss must be shape ({lR}, {lT}, {self._lmax + 1})") + if rhoTilde is None: + rhoTilde = self.calculate_rhoTilde(radii) + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", **_phi_filter) + Sc, Ss = interpolated_phi_distribution._grid_Scs( + radii, thetas, rhoTilde=rhoTilde, Acos=potential._Acos, Asin=potential._Asin + ) # (R, T, L) + self._Scms, self._Ssms = Sc, Ss # l = 0 : spherical symmetry - term0 = Phis[..., 0] / (2 * np.pi) # (1, 1, P) + term0 = Phis[..., 0] / (2 * pi) # (1, 1, P) # l = 1+ : non-symmetry with warnings.catch_warnings(): # ignore true_divide RuntimeWarnings warnings.simplefilter("ignore") - factor = 1 / Sc[:, :, :1] # R0 (R, T, 1) # can be inf - - ms = np.arange(1, self._lmax + 1)[None, None, None, :] # ({R}, {T}, {P}, L) - term1p = np.sum( - ( - (Sc[:, :, None, 1:] * np.sin(ms * Phis)) - + (Ss[:, :, None, 1:] * (1 - np.cos(ms * Phis))) - ) - / (2 * np.pi * ms), + factor = 1.0 / Sc[:, :, :1] # R0 (R, T, 1) + + ms = arange(1, self._lmax + 1)[None, None, None, :] # ({R}, {T}, {P}, L) + term1p = sum( + ((Sc[:, :, None, 1:] * sin(ms * Phis)) + (Ss[:, :, None, 1:] * (1 - cos(ms * Phis)))) + / (2 * pi * ms), axis=-1, ) - cdfs = term0 + np.nan_to_num(factor * term1p) # (R, T, P) + # cdfs = term0 + nan_to_num(factor * term1p) # (R, T, P) + cdfs = term0 + nan_to_num(factor * term1p, posinf=inf, neginf=-inf) # (R, T, P) # 'factor' can be inf and term1p 0 => inf * 0 = nan -> 0 # interpolate @@ -120,29 +126,37 @@ def __init__( # start by supersampling Zetas, Xs, Phis = np.meshgrid(zetas, xs, self._phi_interpolant.value, indexing="ij") - _cdfs = self._spl_cdf((Zetas.ravel(), Xs.ravel(), Phis.ravel())).reshape( - lR, - lT, - len(self._phi_interpolant), - ) + _cdfs = self._spl_cdf((Zetas.ravel(), Xs.ravel(), Phis.ravel())) + _cdfs.shape = (lR, lT, len(self._phi_interpolant)) + + self._cdfs = _cdfs + return + # build reverse spline + # TODO! vectorize ppfs = np.empty((lR, lT, self._ninterpolant), dtype=np.float64) for (i, j) in itertools.product(*map(range, ppfs.shape[:2])): try: spl = splrep(_cdfs[i, j, :], self._phi_interpolant, s=0) except ValueError: # CDF is non-real + # STDLIB import pdb - + pdb.set_trace() raise ppfs[i, j, :] = splev(qarr, spl, ext=0) # interpolate - self._spl_ppf = RegularGridInterpolator( - (zetas, xs, qarr), - ppfs, - bounds_error=False, - ) + self._spl_ppf = RegularGridInterpolator((zetas, xs, qarr), ppfs, bounds_error=False) + + @staticmethod + def order_phis(phis: Quantity) -> Tuple[Quantity]: + """Return ordered phis.""" + psort = argsort(phis) + phis = phis[psort] + return phis + + # --------------------------------------------------------------- def _cdf(self, phi: ArrayLike, *args: Any, zeta: ArrayLike, x: ArrayLike) -> NDArrayF: cdf: NDArrayF = self._spl_cdf((zeta, x, phi)) diff --git a/sample_scf/interpolated/core.py b/sample_scf/interpolated/core.py index 8fa3a77..ada2a9b 100644 --- a/sample_scf/interpolated/core.py +++ b/sample_scf/interpolated/core.py @@ -18,8 +18,8 @@ # THIRD PARTY import astropy.units as u import numpy as np -from numpy import nan_to_num, inf, sum, isinf, array from galpy.potential import SCFPotential +from numpy import array, inf, isinf, nan_to_num, sum # LOCAL from .azimuth import interpolated_phi_distribution @@ -27,10 +27,10 @@ from .radial import interpolated_r_distribution from sample_scf._typing import NDArrayF from sample_scf.base_multivariate import SCFSamplerBase +from sample_scf.representation import x_of_theta __all__ = ["InterpolatedSCFSampler"] - ############################################################################## # CODE ############################################################################## @@ -99,48 +99,61 @@ def __init__( self, potential: SCFPotential, radii: Quantity, thetas: Quantity, phis: Quantity, **kw: Any ) -> None: super().__init__(potential, **kw) - # coefficients - Acos: np.ndarray = potential._Acos - Asin: np.ndarray = potential._Asin - rsort = np.argsort(radii) - radii = radii[rsort] + # ------------------- + # Radial + + # sampler + self._r_distribution = interpolated_r_distribution(potential, radii, **kw) + radii = self._radii # sorted - # Compute the r-dependent coefficient matrix. + # compute the r-dependent coefficient matrix. rhoT = self.calculate_rhoTilde(radii) - # Compute the radial sums for inclination weighting factors. - Qls = kw.pop("Qls", None) - if Qls is None: - Qls = self.calculate_Qls(radii, rhoTilde=rhoT) - - # ---------- - # phi Rm, Sm - # radial and inclination sums - - Scs = kw.pop("Scs", None) - if Scs is None: - with warnings.catch_warnings(): - warnings.filterwarnings( - "ignore", - category=RuntimeWarning, - message="(^invalid value)|(^overflow encountered)", - ) - Scs = interpolated_phi_distribution._grid_Scs( - radii, rhoT, Acos=Acos, Asin=Asin, theta=thetas - ) - - # ---------- - # make samplers + # ------------------- + # Thetas - self._r_distribution = interpolated_r_distribution(potential, radii, **kw) + # sampler self._theta_distribution = interpolated_theta_distribution( - potential, radii, thetas, Qls=Qls, **kw + potential, radii, thetas, rhoTilde=rhoT, **kw ) + thetas, xs = self._thetas, self._xs # sorted + + # ------------------- + # Phis + self._phi_distribution = interpolated_phi_distribution( - potential, radii, thetas, phis, Scs=Scs, **kw + potential, radii, thetas, phis, rhoTilde=rhoT, **kw ) + @property + def _radii(self) -> Quantity: + return self._r_distribution._radii + + @property + def _zetas(self) -> Quantity: + return self._r_distribution._zetas + + @property + def _thetas(self) -> Quantity: + return self._theta_distribution._thetas + + @property + def _xs(self) -> Quantity: + return self._theta_distribution._xs + @property def _Qls(self) -> NDArrayF: return self._theta_distribution._Qls + + @property + def _phis(self) -> Quantity: + self._phi_distribution._phis + + @property + def _Scms(self) -> NDArrayF: + return self._phi_distribution._Scms + + @property + def _Ssms(self) -> NDArrayF: + return self._phi_distribution._Ssms diff --git a/sample_scf/interpolated/inclination.py b/sample_scf/interpolated/inclination.py index 9922289..05c9517 100644 --- a/sample_scf/interpolated/inclination.py +++ b/sample_scf/interpolated/inclination.py @@ -18,7 +18,8 @@ # THIRD PARTY import astropy.units as u -import numpy as np +from numpy import argsort, linspace, pi, array +from numpy.random import RandomState, Generator from galpy.potential import SCFPotential from numpy.typing import ArrayLike from scipy.interpolate import RectBivariateSpline, splev, splrep @@ -29,6 +30,8 @@ from sample_scf.exact.inclination import exact_theta_distribution_base from sample_scf.representation import x_of_theta, zeta_of_r +from .radial import interpolated_r_distribution + __all__ = ["interpolated_theta_distribution"] @@ -44,9 +47,8 @@ class interpolated_theta_distribution(theta_distribution_base): Parameters ---------- pot : `~galpy.potential.SCFPotential` - radii : (R,) |Quantity| - Must be in correct sort order - thetas : (T, ) ndarray ['radian'] or Quantity ['angle'] + radii : (R,) Quantity['angle', float] + thetas : (T, ) Quantity ['angle', float] intrp_step : float, optional Interpolation step. **kw @@ -62,22 +64,22 @@ def __init__( nintrp: float = 1e3, **kw: Any, ) -> None: - Qls: NDArrayF = kw.pop("Qls", None) + rhoTilde: NDArrayF = kw.pop("rhoTilde", None) super().__init__(potential, **kw) # allowed range of theta - self._theta_interpolant = np.linspace(0, np.pi, num=int(nintrp)) << u.rad + self._theta_interpolant = linspace(0, pi, num=int(nintrp)) << u.rad self._x_interpolant = x_of_theta(self._theta_interpolant) - self._q_interpolant = np.linspace(0, 1, len(self._theta_interpolant)) + self._q_interpolant = linspace(0, 1, len(self._theta_interpolant)) - zetas_unsorted = zeta_of_r(radii, scale_radius=self.radial_scale_factor) # (R,) - rsort = np.argsort(zetas_unsorted) - zetas = zetas_unsorted[rsort] + # Sorting + radii, zetas = interpolated_r_distribution.order_radii(self, radii) + thetas, xs = interpolated_theta_distribution.order_thetas(thetas) + self._thetas, self._xs = thetas, xs # ------- # build CDF in shells - xs = np.sort(x_of_theta(thetas << u.rad)) # (T,) sorted for interpolation - Qls = Qls[rsort, :] if Qls is not None else self.calculate_Qls(radii) + Qls = self.calculate_Qls(radii, rhoTilde=rhoTilde) # check it's the right shape (R, L) if Qls.shape != (len(radii), self._lmax + 1): raise ValueError(f"Qls must be shape ({len(radii)}, {self._lmax + 1})") @@ -88,7 +90,7 @@ def __init__( # ------- # interpolate - # currently assumes a regular grid + # assumes a regular grid self._spl_cdf = RectBivariateSpline( # (R, T) zetas, @@ -100,17 +102,14 @@ def __init__( s=kw.get("s", 0), ) - self._zetas = zetas # FIXME! - # return - # ppf, one per r, supersampled # TODO! see if can use this to avoid resplining _cdfs = self._spl_cdf(zetas, self._x_interpolant[::-1], grid=True) spls = ( # work through the (R, T) is anti-theta ordered splrep(_cdfs[i, ::-1], self._theta_interpolant.value, s=0) for i in range(_cdfs.shape[0]) - ) # TODO! as generator - ppfs = np.array([splev(self._q_interpolant, spl, ext=0) for spl in spls]) + ) + ppfs = array([splev(self._q_interpolant, spl, ext=0) for spl in spls]) self._spl_ppf = RectBivariateSpline( zetas, @@ -122,6 +121,27 @@ def __init__( s=kw.get("s", 0), ) + @staticmethod + def order_thetas(thetas: Quantity) -> Tuple[Quantity, NDArrayF]: + """Return ordered thetas and xs. + + Parameters + ---------- + thetas : (T,) Quantity['angle', float] + + Returns + ------- + thetas : (T,) Quantity['angle', float] + xs : (T,) ndarray[float] + """ + xs_unsorted = x_of_theta(thetas << u.rad) # (T,) + xsort = argsort(xs_unsorted) # opposite as theta sort + xs = xs_unsorted[xsort] + thetas = thetas[xsort] + return thetas, xs + + # --------------------------------------------------------------- + def _cdf(self, x: ArrayLike, *args: Any, zeta: ArrayLike, **kw: Any) -> NDArrayF: cdf: NDArrayF = self._spl_cdf(zeta, x, grid=False) return cdf @@ -165,7 +185,7 @@ def _rvs( r: Quantity, *, size: Optional[int] = None, - random_state: Union[np.random.RandomState, np.random.Generator], + random_state: Union[RandomState, Generator], # return_thetas: bool = True, # TODO! ) -> NDArrayF: """Random variate sampling. diff --git a/sample_scf/interpolated/radial.py b/sample_scf/interpolated/radial.py index de06d8d..e326062 100644 --- a/sample_scf/interpolated/radial.py +++ b/sample_scf/interpolated/radial.py @@ -13,14 +13,15 @@ # THIRD PARTY import astropy.units as u import numpy as np +from numpy import argsort from galpy.potential import SCFPotential from numpy.typing import ArrayLike -from scipy.interpolate import InterpolatedUnivariateSpline +from scipy.interpolate import InterpolatedUnivariateSpline as IUS # LOCAL from sample_scf._typing import NDArrayF from sample_scf.base_univariate import r_distribution_base -from sample_scf.representation import FiniteSphericalRepresentation, zeta_of_r, r_of_zeta +from sample_scf.representation import FiniteSphericalRepresentation, r_of_zeta, zeta_of_r __all__ = ["interpolated_r_distribution"] @@ -29,21 +30,6 @@ # CODE ############################################################################## -def calculate_mass_cdf(potential: SCFPotential, radii: Quantity) -> NDArrayF: - - rgalpy = radii.to_value(u.kpc) / potential._ro # FIXME! wrong scaling - mgrid = np.array([potential._mass(x) for x in rgalpy]) # :( - # manual fixes for endpoints and normalization - ind = np.where(np.isnan(mgrid))[0] - mgrid[ind[radii[ind] == 0]] = 0 - mgrid = (mgrid - np.nanmin(mgrid)) / (np.nanmax(mgrid) - np.nanmin(mgrid)) # rescale - infind = ind[radii[ind] == np.inf].squeeze() - mgrid[infind] = 1 - if mgrid[infind - 1] == 1: # munge the rescaling TODO! do better - mgrid[infind - 1] -= min(1e-8, np.diff(mgrid[slice(infind - 2, infind)]) / 2) - - return mgrid - class interpolated_r_distribution(r_distribution_base): """Sample radial coordinate from an SCF potential. @@ -62,35 +48,51 @@ class interpolated_r_distribution(r_distribution_base): _interp_in_zeta: bool - def __init__( - self, potential: SCFPotential, radii: Quantity, **kw: Any) -> None: + def __init__(self, potential: SCFPotential, radii: Quantity, **kw: Any) -> None: kw["a"], kw["b"] = 0, np.nanmax(radii) # allowed range of r super().__init__(potential, **kw) - ### fraction of total mass grid ### + # fraction of total mass grid # work in zeta, not r, since it is more numerically stable - zetas_unsorted = zeta_of_r(radii, scale_radius=self.radial_scale_factor) # (R,) - rsort = np.argsort(zetas_unsorted) - zetas = zetas_unsorted[rsort] - - mgrid = calculate_mass_cdf(potential, radii[rsort]) + self._radii, self._zetas = self.order_radii(radii) + self._mgrid = self.calculate_cumulative_mass(self._radii) - ### splines ### # make splines for fast calculation - self._spl_cdf = InterpolatedUnivariateSpline( - zetas, - mgrid, - ext="raise", - bbox=[-1, 1], - k=1, - ) - self._spl_ppf = InterpolatedUnivariateSpline( - mgrid, - zetas, - ext="raise", - bbox=[0, 1], - k=1, - ) + self._spl_cdf = IUS(self._zetas, self._mgrid, ext="raise", bbox=[-1, 1], k=1) + self._spl_ppf = IUS(self._mgrid, self._zetas, ext="raise", bbox=[0, 1], k=1) + + def order_radii(self, radii: Quantity) -> Tuple[Quantity, NDArrayF]: + """Return ordered radii and zetas.""" + rsort = argsort(radii) # same as zeta ordering + radii = radii[rsort] + zeta = zeta_of_r(radii, scale_radius=self.radial_scale_factor) + return radii, zeta + + def calculate_cumulative_mass(self, radii: Quantity) -> NDArrayF: + """Calculate cumulative mass function (ie the cdf). + + Parameters + ---------- + radii : (R,) Quantity['length', float] + + Returns + ------- + (R,) ndarray[float] + """ + rgalpy = radii.to_value(u.kpc) / self.potential._ro + mgrid = np.array([self.potential._mass(x) for x in rgalpy]) # :( + # manual fixes for endpoints and normalization + ind = np.where(np.isnan(mgrid))[0] + mgrid[ind[radii[ind] == 0]] = 0 + mgrid = (mgrid - np.nanmin(mgrid)) / (np.nanmax(mgrid) - np.nanmin(mgrid)) # rescale + infind = ind[radii[ind] == np.inf].squeeze() + mgrid[infind] = 1 + if mgrid[infind - 1] == 1: # munge the rescaling TODO! do better + mgrid[infind - 1] -= min(1e-8, np.diff(mgrid[slice(infind - 2, infind)]) / 2) + + return mgrid + + # --------------------------------------------------------------- def cdf(self, radii: Quantity): # TODO! return self._cdf(zeta_of_r(radii, self.radial_scale_factor)) diff --git a/sample_scf/interpolated/tests/test_interpolated.py b/sample_scf/interpolated/tests/test_interpolated.py index 66f9619..a7b8820 100644 --- a/sample_scf/interpolated/tests/test_interpolated.py +++ b/sample_scf/interpolated/tests/test_interpolated.py @@ -17,11 +17,11 @@ # LOCAL from .common import phi_distributionTestBase, r_distributionTestBase, theta_distributionTestBase from .test_base import BaseTest_rv_potential, SCFSamplerTestBase -from sample_scf.representation import x_of_theta from sample_scf.interpolated import InterpolatedSCFSampler -from sample_scf.interpolated.radial import r_distribution -from sample_scf.interpolated.inclination import theta_distribution from sample_scf.interpolated.azimuth import phi_distribution +from sample_scf.interpolated.inclination import theta_distribution +from sample_scf.interpolated.radial import r_distribution +from sample_scf.representation import x_of_theta ############################################################################## # PARAMETERS @@ -325,7 +325,11 @@ def test_cdf(self, sampler, theta, r): """Test :meth:`sample_scf.interpolated.theta_distribution.cdf`.""" assert_allclose( sampler.cdf(theta, r), - sampler._spl_cdf(FiniteSphericalRepresentation.calculate_zeta_of_r(r), x_of_theta(u.Quantity(theta, u.rad)), grid=False), + sampler._spl_cdf( + FiniteSphericalRepresentation.calculate_zeta_of_r(r), + x_of_theta(u.Quantity(theta, u.rad)), + grid=False, + ), ) @pytest.mark.skip("TODO!") diff --git a/sample_scf/representation.py b/sample_scf/representation.py index 6509a86..14f643b 100644 --- a/sample_scf/representation.py +++ b/sample_scf/representation.py @@ -9,29 +9,28 @@ # STDLIB import warnings -from inspect import isclass from contextlib import nullcontext from functools import singledispatch +from inspect import isclass from typing import Optional, Tuple, Union, overload # THIRD PARTY import astropy.units as u -from erfa import ufunc as erfa_ufunc -from astropy.units import rad import numpy as np import numpy.typing as npt -from astropy.units import Quantity, UnitConversionError -from numpy import arccos, array, atleast_1d, cos, divide, nan_to_num -from numpy.typing import ArrayLike from astropy.coordinates import ( Angle, BaseRepresentation, + CartesianRepresentation, + Distance, PhysicsSphericalRepresentation, SphericalRepresentation, UnitSphericalRepresentation, - Distance, - CartesianRepresentation, ) +from astropy.units import Quantity, UnitConversionError, rad +from erfa import ufunc as erfa_ufunc +from numpy import arccos, array, atleast_1d, cos, divide, nan_to_num +from numpy.typing import ArrayLike # LOCAL from ._typing import NDArrayF @@ -42,8 +41,11 @@ # CODE ############################################################################## + @singledispatch -def _zeta_of_r(r: Union[ArrayLike, Quantity], /, scale_radius: Union[NDArrayF, Quantity, None]=None) -> NDArrayF: +def _zeta_of_r( + r: Union[ArrayLike, Quantity], /, scale_radius: Union[NDArrayF, Quantity, None] = None +) -> NDArrayF: # Default implementation, unless there's a registered specific method. # -------------- # Checks: r must be non-negative, and the scale radius must be None or positive @@ -65,6 +67,7 @@ def _zeta_of_r(r: Union[ArrayLike, Quantity], /, scale_radius: Union[NDArrayF, Q # TODO! fix with degeneracy in NaN when not due to division. return zeta + @overload @_zeta_of_r.register def zeta_of_r(r: Quantity, /, scale_radius=None) -> NDArrayF: @@ -89,17 +92,17 @@ def zeta_of_r(r: Quantity, /, scale_radius=None) -> NDArrayF: return zeta.value -def zeta_of_r(r: Union[NDArrayF, Quantity], /, scale_radius: Optional[Quantity]=None) -> NDArrayF: +def zeta_of_r(r: Union[NDArrayF, Quantity], /, scale_radius: Optional[Quantity] = None) -> NDArrayF: r""":math:`\zeta(r) = \frac{r/a - 1}{r/a + 1}`. - + Map the half-infinite domain [0, infinity) -> [-1, 1]. - + Parameters ---------- r : (R,) Quantity['length'], position-only scale_radius : Quantity['length'] or None, optional If None (default), taken to be 1 in the units of `r`. - + Returns ------- (R,) array[floating] @@ -126,17 +129,18 @@ def zeta_of_r(r: Union[NDArrayF, Quantity], /, scale_radius: Optional[Quantity]= # ------------------------------------------------------------------- -def r_of_zeta(zeta: ndarray, /, scale_radius: Union[float, np.floating, Quantity, None] = None +def r_of_zeta( + zeta: ndarray, /, scale_radius: Union[float, np.floating, Quantity, None] = None ) -> Union[NDArrayF, Quantity]: r""":math:`r = \frac{1 + \zeta}{1 - \zeta}`. - + Map back to the half-infinite domain [0, infinity) <- [-1, 1]. - + Parameters ---------- zeta : (R,) array[floating] or (R,) Quantity['dimensionless'], position-only scale_radius : Quantity['length'] or None, optional - + Returns ------- (R,) ndarray[float] or (R,) Quantity['length'] @@ -211,6 +215,7 @@ def theta_of_x(x: ArrayLike, unit=u.rad) -> Quantity: ########################################################################### + class FiniteSphericalRepresentation(BaseRepresentation): r""" Representation of points in 3D spherical coordinates (using the physics @@ -450,7 +455,9 @@ def from_physicsspherical( """ Converts spherical polar coordinates. """ - return cls(phi=psphere.phi, x=psphere.theta, zeta=psphere.r, scale_radius=scale_radius, copy=False) + return cls( + phi=psphere.phi, x=psphere.theta, zeta=psphere.r, scale_radius=scale_radius, copy=False + ) def transform(self, matrix, scale_radius: Optional[Quantity] = None): """Transform the spherical coordinates using a 3x3 matrix. diff --git a/sample_scf/tests/base.py b/sample_scf/tests/base.py index ea9170d..0719a11 100644 --- a/sample_scf/tests/base.py +++ b/sample_scf/tests/base.py @@ -5,9 +5,9 @@ # IMPORTS # STDLIB -from abc import ABCMeta, abstractmethod import inspect import time +from abc import ABCMeta, abstractmethod # THIRD PARTY import astropy.coordinates as coord @@ -20,8 +20,8 @@ from scipy.stats import rv_continuous # LOCAL -from sample_scf.conftest import _hernquist_scf_potential from sample_scf.base_univariate import rv_potential +from sample_scf.conftest import _hernquist_scf_potential ############################################################################## # TESTS @@ -29,7 +29,6 @@ class BaseTest_Sampler(metaclass=ABCMeta): - @pytest.fixture( scope="class", params=[ @@ -108,7 +107,7 @@ def rvs_time_scale(self): # =============================================================== # Method Tests - + def test_init_wrong_potential(self, rv_cls, rv_cls_args, rv_cls_kw): """Test initialization when the potential is wrong.""" # bad value diff --git a/sample_scf/tests/data/__init__.py b/sample_scf/tests/data/__init__.py index 711dadc..d207d56 100644 --- a/sample_scf/tests/data/__init__.py +++ b/sample_scf/tests/data/__init__.py @@ -4,11 +4,7 @@ This module contains package tests. """ +# LOCAL from . import data_Test_rv_potential - -results = { - "Test_rv_potential": data_Test_rv_potential.results -} - - +results = {"Test_rv_potential": data_Test_rv_potential.results} diff --git a/sample_scf/tests/data/data_Test_rv_potential.py b/sample_scf/tests/data/data_Test_rv_potential.py index ae5c6f4..1eaa1b9 100644 --- a/sample_scf/tests/data/data_Test_rv_potential.py +++ b/sample_scf/tests/data/data_Test_rv_potential.py @@ -4,11 +4,13 @@ rvs = { "size": (None, 1, (3, 1), (3, 1)), "random": (0, 2, 4, None), - "expected": (0.5488135039273248, 0.43599490214200376, (0.9670298390136767, 0.5472322491757223, 0.9726843599648843), (0.9670298390136767, 0.5472322491757223, 0.9726843599648843)) + "expected": ( + 0.5488135039273248, + 0.43599490214200376, + (0.9670298390136767, 0.5472322491757223, 0.9726843599648843), + (0.9670298390136767, 0.5472322491757223, 0.9726843599648843), + ), } -results = { - "rvs": rvs -} - +results = {"rvs": rvs} diff --git a/sample_scf/tests/test_base_multivariate.py b/sample_scf/tests/test_base_multivariate.py index 4a8cb42..a92b4b2 100644 --- a/sample_scf/tests/test_base_multivariate.py +++ b/sample_scf/tests/test_base_multivariate.py @@ -15,19 +15,19 @@ import astropy.units as u import numpy as np import pytest -from astropy.coordinates import BaseRepresentation +from astropy.coordinates import BaseRepresentation, PhysicsSphericalRepresentation from galpy.potential import SCFPotential from numpy.testing import assert_allclose -from astropy.coordinates import PhysicsSphericalRepresentation - # LOCAL -from sample_scf import conftest -from sample_scf.base_univariate import r_distribution_base, theta_distribution_base, phi_distribution_base - from .base import BaseTest_Sampler -from .test_base_univariate import rvtestsampler, radii, thetas, phis - +from .test_base_univariate import phis, radii, rvtestsampler, thetas +from sample_scf import conftest +from sample_scf.base_univariate import ( + phi_distribution_base, + r_distribution_base, + theta_distribution_base, +) ############################################################################## # TESTS @@ -117,7 +117,7 @@ def test_lmax_property(self, sampler): assert sampler.lmax is sampler.r_distribution.lmax # --------------------------------------------------------------- - + @abstractmethod def test_cdf(self, sampler, position, expected): """Test cdf method.""" @@ -127,14 +127,14 @@ def test_cdf(self, sampler, position, expected): assert False assert_allclose(cdf, expected, atol=1e-16) - + @abstractmethod def test_rvs(self, sampler, size, random, expected): """Test rvs method. - + The ``NumpyRNGContext`` is to control the random generator used to make the RandomState. For ``random != None``, this doesn't matter. - + Each child class will need to define the set of expected results. """ with NumpyRNGContext(4): @@ -158,7 +158,6 @@ def test_repr(self): class Test_SCFSamplerBase(BaseTest_SCFSamplerBase): - @pytest.fixture(scope="class") def rv_cls(self): return SCFSamplerBase @@ -189,10 +188,10 @@ def test_cdf(self, sampler, r, theta, phi, expected): """Test :meth:`sample_scf.base_multivariate.SCFSamplerBase.cdf`.""" cdf = sampler.cdf(r, theta, phi) assert np.allclose(cdf, expected, atol=1e-16) - + # also test shape assert tuple(np.atleast_1d(np.squeeze((*np.shape(r), 3)))) == cdf.shape - + @pytest.mark.parametrize( "id, size, random, vectorized", [ @@ -208,7 +207,7 @@ def test_rvs(self, sampler, id, size, random, vectorized): """Test :meth:`sample_scf.base_multivariate.SCFSamplerBase.rvs`.""" samples = sampler.rvs(size=size, random_state=random, vectorized=vectorized) sce = PhysicsSphericalRepresentation(**self.expected_rvs[id]) - + assert_allclose(samples.r, sce.r, atol=1e-16) assert_allclose(samples.theta.value, sce.theta.value, atol=1e-16) assert_allclose(samples.phi.value, sce.phi.value, atol=1e-16) diff --git a/sample_scf/tests/test_base_univariate.py b/sample_scf/tests/test_base_univariate.py index 54323a4..18a1c54 100644 --- a/sample_scf/tests/test_base_univariate.py +++ b/sample_scf/tests/test_base_univariate.py @@ -7,9 +7,9 @@ # IMPORTS # STDLIB -from abc import ABCMeta, abstractmethod import inspect import time +from abc import ABCMeta, abstractmethod # THIRD PARTY import astropy.coordinates as coord @@ -22,12 +22,10 @@ from scipy.stats import rv_continuous # LOCAL -from sample_scf.conftest import _hernquist_scf_potential -from sample_scf.base_univariate import rv_potential, r_distribution_base - -from .data import results from .base import BaseTest_Sampler - +from .data import results +from sample_scf.base_univariate import r_distribution_base, rv_potential +from sample_scf.conftest import _hernquist_scf_potential ############################################################################## # PARAMETERS @@ -74,11 +72,11 @@ def test_init_attrs(self, sampler, rv_cls_args, rv_cls_kw): def test_radial_scale_factor_property(self, sampler): # Identity assert sampler.radial_scale_factor is sampler._radial_scale_factor - + def test_nmax_property(self, sampler): # Identity assert sampler.nmax is sampler._nmax - + def test_lmax_property(self, sampler): # Identity assert sampler.lmax is sampler._lmax @@ -93,10 +91,10 @@ def test_cdf(self, sampler, position, expected): @abstractmethod def test_rvs(self, sampler, size, random, expected): """Test rvs method. - + The ``NumpyRNGContext`` is to control the random generator used to make the RandomState. For ``random != None``, this doesn't matter. - + Each child class will need to define the set of expected results. """ with NumpyRNGContext(4): @@ -224,7 +222,7 @@ def rv_cls(self): return theta_distribution_base def cdf_time_arr(self, size: int): - return np.linspace(0, 2*np.pi, size) + return np.linspace(0, 2 * np.pi, size) # =============================================================== # Method Tests @@ -244,7 +242,7 @@ def test_pnts_Scs(self, sampler): assert False @pytest.mark.skip("TODO!") - def test_pnts_Scs(self, sampler): + def test_grid_Scs(self, sampler): """Test :class:`sample_scf.base_multivariate.phi_distribution_base._grid_Scs`.""" assert False diff --git a/sample_scf/tests/test_conftest.py b/sample_scf/tests/test_conftest.py index d64d9b1..49f9ebd 100644 --- a/sample_scf/tests/test_conftest.py +++ b/sample_scf/tests/test_conftest.py @@ -1,33 +1,33 @@ # # -*- coding: utf-8 -*- -# +# # """Testing :mod:`~sample_scf.conftest`. -# +# # Even the test source should be tested. # In particular, the potential fixtures need confirmation that the SCF form # matches the theoretical, within tolerances. # """ -# +# # ############################################################################## # # IMPORTS -# +# # # STDLIB # import abc -# +# # # THIRD PARTY # import numpy as np # import pytest -# +# # # LOCAL # from sample_scf import conftest -# +# # ############################################################################## # # TESTS # ############################################################################## -# -# +# +# # class PytestPotential(metaclass=abc.ABCMeta): # """Test a Pytest Potential.""" -# +# # @classmethod # @abc.abstractmethod # def setup_class(self): @@ -35,75 +35,75 @@ # self.R = np.linspace(0.0, 3.0, num=1001) # self.atol = 1e-6 # self.restrict_ind = np.ones(1001, dtype=bool) -# +# # @pytest.fixture(scope="class") # @abc.abstractmethod # def scf_potential(self): # """The `galpy.potential.SCFPotential` from a `pytest.fixture`.""" # return -# +# # def compare_to_theory(self, theory, scf, atol=1e-6): # # test that where theory is finite they match and where it's infinite, # # the scf is NaN # fnt = ~np.isinf(theory) # ind = self.restrict_ind & fnt -# +# # assert np.allclose(theory[ind], scf[ind], atol=atol) # assert np.all(np.isnan(scf[~fnt])) -# +# # # =============================================================== # # sanity checks -# +# # def test_df(self): # assert self.df._pot is self.theory -# +# # # =============================================================== -# +# # def test_density_along_Rz_equality(self, scf_potential): # theory = self.theory.dens(self.R, self.R) # scf = scf_potential.dens(self.R, self.R) # self.compare_to_theory(theory, scf, atol=self.atol) -# +# # @pytest.mark.parametrize("z", [0, 10, 15]) # def test_density_at_z(self, scf_potential, z): # theory = self.theory.dens(self.R, z) # scf = scf_potential.dens(self.R, z) # self.compare_to_theory(theory, scf, atol=self.atol) -# -# +# +# # # ------------------------------------------------------------------- -# -# +# +# # class Test_hernquist_scf_potential(PytestPotential): # @classmethod # def setup_class(self): # """Setup fixtures for testing.""" # super().setup_class() -# +# # self.theory = conftest.hernquist_potential # self.df = conftest.hernquist_df -# +# # @pytest.fixture(scope="class") # def scf_potential(self, hernquist_scf_potential): # """The `galpy.potential.SCFPotential` from a `pytest.fixture`.""" # return hernquist_scf_potential -# -# +# +# # # ------------------------------------------------------------------- -# -# +# +# # # class Test_nfw_scf_potential(PytestPotential): # # @classmethod # # def setup_class(self): # # """Setup fixtures for testing.""" # # super().setup_class() -# # +# # # # self.theory = conftest.nfw_potential # # self.df = conftest.nfw_df -# # +# # # # self.atol = 1e-2 # # self.restrict_ind[:18] = False # skip some of the inner ones -# # +# # # # @pytest.fixture(scope="class") # # def scf_potential(self, nfw_scf_potential): # # """The `galpy.potential.SCFPotential` from a `pytest.fixture`.""" diff --git a/sample_scf/tests/test_core.py b/sample_scf/tests/test_core.py index ec08ce1..6639397 100644 --- a/sample_scf/tests/test_core.py +++ b/sample_scf/tests/test_core.py @@ -12,10 +12,9 @@ import pytest # LOCAL -from sample_scf import SCFSampler -from sample_scf.exact import exact_r_distribution, exact_theta_distribution, exact_phi_distribution - from .test_base_multivariate import BaseTest_SCFSamplerBase +from sample_scf import SCFSampler +from sample_scf.exact import exact_phi_distribution, exact_r_distribution, exact_theta_distribution ############################################################################## # TESTS @@ -27,8 +26,8 @@ def test_MethodsMapping(potentials): # Good mm = MethodsMapping( r=exact_r_distribution(potentials, total_mass=1), - theta=exact_theta_distribution(potentials, ) - phi=exact_phi_distribution(potentials) + theta=exact_theta_distribution(potentials), + phi=exact_phi_distribution(potentials), ) assert False diff --git a/sample_scf/tests/test_representation.py b/sample_scf/tests/test_representation.py index 59b3e21..deb6c41 100644 --- a/sample_scf/tests/test_representation.py +++ b/sample_scf/tests/test_representation.py @@ -14,19 +14,24 @@ import astropy.units as u import numpy as np import pytest +from astropy.coordinates import ( + CartesianRepresentation, + Distance, + PhysicsSphericalRepresentation, + SphericalRepresentation, + UnitSphericalRepresentation, +) from astropy.units import Quantity, UnitConversionError, allclose -from astropy.coordinates import Distance, PhysicsSphericalRepresentation, SphericalRepresentation, UnitSphericalRepresentation, CartesianRepresentation # LOCAL from sample_scf.representation import ( FiniteSphericalRepresentation, - zeta_of_r, r_of_zeta, - x_of_theta, theta_of_x, + x_of_theta, + zeta_of_r, ) - ############################################################################## # TESTS ############################################################################## @@ -280,7 +285,7 @@ def test_to_cartesian(self, rep): x = rep.r * np.sin(rep.theta) * np.cos(rep.phi) y = rep.r * np.sin(rep.theta) * np.sin(rep.phi) z = rep.r * np.cos(rep.theta) - + assert allclose(r.x, x) assert allclose(r.y, y) assert allclose(r.z, z) @@ -290,7 +295,7 @@ def test_from_cartesian(self, rep_cls, rep, scale_radius): cart = rep.to_cartesian() # Not passing a scale radius - r = rep_cls.from_cartesian(cart) + r = rep_cls.from_cartesian(cart) assert rep != r r = rep_cls.from_cartesian(cart, scale_radius=scale_radius) @@ -303,7 +308,7 @@ def test_from_physicsspherical(self, rep_cls, rep, scale_radius): psphere = rep.represent_as(PhysicsSphericalRepresentation) # Not passing a scale radius - r = rep_cls.from_physicsspherical(psphere) + r = rep_cls.from_physicsspherical(psphere) assert rep != r r = rep_cls.from_physicsspherical(psphere, scale_radius=scale_radius) @@ -321,9 +326,7 @@ def test_transform(self, rep, scale_radius): assert allclose(rep.zeta, r.zeta) # alternating coordinates - matrix = np.array([[0, 1, 0], - [1, 0, 0], - [0, 0, 1]]) + matrix = np.array([[0, 1, 0], [1, 0, 0], [0, 0, 1]]) r = rep.transform(matrix, scale_radius) assert allclose(rep.phi, r.phi - np.pi / 2 * u.rad) assert allclose(rep.theta, r.theta) @@ -361,7 +364,7 @@ def test_zeta_of_r_fail(): (10, None, 9 / 11, False), ([0, 1, np.inf], None, [-1.0, 0.0, 1.0], False), ([0, 1, np.inf], None, [-1.0, 0.0, 1.0], False), - ] + ], ) def test_zeta_of_r_ArrayLike(r, scale_radius, expected, warns): """Test :func:`sample_scf.representation.r_of_zeta` with wrong r type.""" @@ -508,9 +511,9 @@ def test_theta_of_x_roundtrip(theta): "x, expected", [ (-1, np.pi), - (0, np.pi/2), + (0, np.pi / 2), (1, 0), - ([-1, 0, 1], [np.pi, np.pi/2, 0]), # array + ([-1, 0, 1], [np.pi, np.pi / 2, 0]), # array ], ) def test_theta_of_x(x, expected): From d737299db0f65b89c65c1df4d675f20a5d123f8f Mon Sep 17 00:00:00 2001 From: nstarman Date: Tue, 12 Jul 2022 12:58:33 -0400 Subject: [PATCH 31/31] push Signed-off-by: nstarman --- pyproject.toml | 26 +- sample_scf/_old/cdf_strategy.py | 139 ------ sample_scf/_typing.py | 11 +- sample_scf/base_multivariate.py | 23 +- sample_scf/base_univariate.py | 457 ++++++++++-------- sample_scf/conftest.py | 28 +- sample_scf/exact/azimuth.py | 19 +- sample_scf/exact/core.py | 11 +- sample_scf/exact/inclination.py | 28 +- sample_scf/exact/radial.py | 23 +- sample_scf/exact/tests/test_core.py | 4 +- sample_scf/exact/tests/test_exact.py | 103 ++-- sample_scf/exact/tests/test_utils.py | 41 +- sample_scf/interpolated/__init__.py | 3 + sample_scf/interpolated/azimuth.py | 32 +- sample_scf/interpolated/core.py | 14 +- sample_scf/interpolated/inclination.py | 12 +- sample_scf/interpolated/radial.py | 20 +- .../interpolated/tests/test_interpolated.py | 105 ++-- sample_scf/representation.py | 130 +++-- sample_scf/tests/base.py | 16 +- sample_scf/tests/test_base_multivariate.py | 29 +- sample_scf/tests/test_base_univariate.py | 44 +- sample_scf/tests/test_conftest.py | 10 +- sample_scf/tests/test_core.py | 7 +- sample_scf/tests/test_representation.py | 75 ++- sample_scf/utils.py | 184 +++++++ tox.ini | 11 + 28 files changed, 846 insertions(+), 759 deletions(-) delete mode 100644 sample_scf/_old/cdf_strategy.py create mode 100644 sample_scf/utils.py diff --git a/pyproject.toml b/pyproject.toml index f2ebaa8..3ea12e5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,4 @@ [build-system] - requires = ["extension-helpers", "setuptools", "setuptools_scm", @@ -8,20 +7,19 @@ requires = ["extension-helpers", build-backend = 'setuptools.build_meta' [tool.isort] -line_length = 100 -multi_line_output = 3 -include_trailing_comma = "True" -force_grid_wrap = 0 -use_parentheses = "True" -ensure_newline_before_comments = "True" -sections = ["FUTURE", "STDLIB", "THIRDPARTY", "FIRSTPARTY", "LOCALFOLDER"] -known_third_party = ["astropy", "extension_helpers", "galpy", "matplotlib", "numpy", "pytest", "scipy", "setuptools"] -known_localfolder = "sample_scf" + profile = "black" + include_trailing_comma = "True" + force_grid_wrap = 0 + use_parentheses = "True" + ensure_newline_before_comments = "True" + sections = ["FUTURE", "STDLIB", "THIRDPARTY", "FIRSTPARTY", "LOCALFOLDER"] + known_third_party = ["astropy", "extension_helpers", "galpy", "matplotlib", "numpy", "pytest", "scipy", "setuptools"] + known_localfolder = "sample_scf" -import_heading_stdlib = "STDLIB" -import_heading_thirdparty = "THIRD PARTY" -import_heading_firstparty = "FIRST PARTY" -import_heading_localfolder = "LOCAL" + import_heading_stdlib = "STDLIB" + import_heading_thirdparty = "THIRD PARTY" + import_heading_firstparty = "FIRST PARTY" + import_heading_localfolder = "LOCAL" [tool.black] line-length = 100 diff --git a/sample_scf/_old/cdf_strategy.py b/sample_scf/_old/cdf_strategy.py deleted file mode 100644 index df463de..0000000 --- a/sample_scf/_old/cdf_strategy.py +++ /dev/null @@ -1,139 +0,0 @@ -# -*- coding: utf-8 -*- - -""" -Deal with non-monotonic CDFs. -The problem can arise if the PDF (density field) ever dips negative because of -an incorrect solution to the SCF coefficients. E.g. when solving for the -coefficients from an analytic density profile. - -""" - -# __all__ = [] - - -############################################################################## -# IMPORTS - -from __future__ import annotations - -# STDLIB -import inspect -from abc import ABCMeta, abstractmethod -from typing import Any, Dict, Type, Union, cast - -# THIRD PARTY -import numpy as np -from astropy.utils.state import ScienceState - -# LOCAL -from sample_scf._typing import NDArrayF - -__all__ = ["get_strategy", "CDFStrategy", "NoCDFStrategy", "LinearInterpolateCDFStrategy"] - - -############################################################################## -# PARAMETERS - -CDF_STRATEGIES: Dict[Union[str, None], Type[CDFStrategy]] = {} - - -StrategyLike = Union[str, None, "CDFStrategy"] -"""Type variable describing.""" - -############################################################################## -# CODE -############################################################################## - - -def get_strategy(key: StrategyLike, /) -> CDFStrategy: - item: CDFStrategy - if isinstance(key, CDFStrategy): - item = key - elif key in CDF_STRATEGIES: - item = CDF_STRATEGIES[key]() - else: - raise ValueError - - return item - - -# ============================================================================ - - -class CDFStrategy(metaclass=ABCMeta): - def __init_subclass__(cls, key: str, **kwargs: Any) -> None: - super().__init_subclass__() - - CDF_STRATEGIES[key] = cls - - @classmethod - @abstractmethod - def apply(cls, cdf: NDArrayF, **kw: Any) -> NDArrayF: - """Apply CDF strategy. - - .. warning:: - operates in-place on numpy arrays - - Parameters - ---------- - cdf : array[float] - **kw : Any - Not used. - - Returns - ------- - cdf : array[float] - Modified in-place. - """ - - -class NoCDFStrategy(CDFStrategy, key=None): - @classmethod - def apply(cls, cdf: NDArrayF, **kw: Any) -> NDArrayF: - """ - - .. warning:: - operates in-place on numpy arrays - - """ - # find where cdf breaks monotonicity - notreal = np.where(np.diff(cdf) <= 0)[0] + 1 - # raise error if any breaks - if np.any(notreal): - msg = "cdf contains unreal elements " - msg += f"at index {kw['index']}" if "index" in kw else "" - raise ValueError(msg) - - -class LinearInterpolateCDFStrategy(CDFStrategy, key="linear"): - @classmethod - def apply(cls, cdf: NDArrayF, **kw: Any) -> NDArrayF: - """Apply linear interpolation. - - .. warning:: - - operates in-place on numpy arrays - - Parameters - ---------- - cdf : array[float] - **kw : Any - Not used. - - Returns - ------- - cdf : array[float] - Modified in-place. - """ - # Find where cdf breaks monotonicity, and the startpoint of each break. - notreal = np.where(np.diff(cdf) <= 0)[0] + 1 - breaks = np.where(np.diff(notreal) > 1)[0] + 1 - startnotreal = np.concatenate((notreal[:1], notreal[breaks])) - - # Loop over each start. Can't vectorize because they depend on each other. - for i in startnotreal: - i0 = i - 1 # before it dips negative - i1 = i0 + np.argmax(cdf[i0:] - cdf[i0] > 0) # start of net positive - cdf[i0 : i1 + 1] = np.linspace(cdf[i0], cdf[i1], num=i1 - i0 + 1, endpoint=True) - - return cdf diff --git a/sample_scf/_typing.py b/sample_scf/_typing.py index 3c30243..e9829e2 100644 --- a/sample_scf/_typing.py +++ b/sample_scf/_typing.py @@ -7,12 +7,15 @@ from typing import Union # THIRD PARTY -import numpy as np -from numpy.typing import ArrayLike, NDArray +from numpy import floating +from numpy.random import Generator, RandomState +from numpy.typing import NDArray -RandomGenerator = Union[np.random.RandomState, np.random.Generator] +__all__ = ["RandomGenerator", "RandomLike", "NDArrayF", "FArrayLike"] + +RandomGenerator = Union[RandomState, Generator] RandomLike = Union[None, int, RandomGenerator] -NDArrayF = NDArray[np.floating] +NDArrayF = NDArray[floating] # float array-like FArrayLike = Union[float, NDArrayF] diff --git a/sample_scf/base_multivariate.py b/sample_scf/base_multivariate.py index 8cc271d..3c609d6 100644 --- a/sample_scf/base_multivariate.py +++ b/sample_scf/base_multivariate.py @@ -12,20 +12,15 @@ from typing import Any, List, Optional, Tuple, Type, TypeVar # THIRD PARTY -import astropy.units as u -import numpy as np from astropy.coordinates import BaseRepresentation, PhysicsSphericalRepresentation -from astropy.utils.misc import NumpyRNGContext +from astropy.units import Quantity from galpy.potential import SCFPotential +from numpy import column_stack # LOCAL -from .base_univariate import ( - phi_distribution_base, - r_distribution_base, - rv_potential, - theta_distribution_base, -) -from sample_scf._typing import NDArrayF, RandomGenerator, RandomLike +from .base_univariate import _calculate_Qls, _calculate_rhoTilde, _calculate_Scs +from .base_univariate import phi_distribution_base, r_distribution_base, theta_distribution_base +from sample_scf._typing import NDArrayF, RandomLike __all__: List[str] = ["SCFSamplerBase"] @@ -118,7 +113,7 @@ def calculate_rhoTilde(self, radii: Quantity) -> NDArrayF: ------- (R, N, L) ndarray[float] """ - return rv_potential.calculate_rhoTilde(self, radii) + return _calculate_rhoTilde(self, radii) def calculate_Qls(self, r: Quantity, rhoTilde=None) -> NDArrayF: r""" @@ -136,7 +131,7 @@ def calculate_Qls(self, r: Quantity, rhoTilde=None) -> NDArrayF: ------- Ql : (R, L) array[float] """ - return theta_distribution_base.calculate_Qls(self, r, rhoTilde=rhoTilde) + return _calculate_Qls(self, r=r, rhoTilde=rhoTilde) def calculate_Scs( self, @@ -162,7 +157,7 @@ def calculate_Scs( Rm, Sm : (R, T, L) ndarray[float] Azimuthal weighting factors. """ - return phi_distribution_base.calculate_Scs(self, r, theta, grid=grid, warn=warn) + return _calculate_Scs(self, r=r, theta=theta, grid=grid, warn=warn) # ----------------------------------------------------- @@ -183,7 +178,7 @@ def cdf(self, r: Quantity, theta: Quantity, phi: Quantity) -> NDArrayF: Theta: NDArrayF = self.theta_distribution.cdf(theta, r=r) Phi: NDArrayF = self.phi_distribution.cdf(phi, r=r, theta=theta) - c: NDArrayF = np.c_[R, Theta, Phi].squeeze() + c: NDArrayF = column_stack((R, Theta, Phi)).squeeze() return c def rvs( diff --git a/sample_scf/base_univariate.py b/sample_scf/base_univariate.py index 1284b37..ed6d47a 100644 --- a/sample_scf/base_univariate.py +++ b/sample_scf/base_univariate.py @@ -10,13 +10,15 @@ # STDLIB import warnings from abc import ABCMeta -from typing import Any, List, Optional, Tuple, Type, TypeVar, Union +from contextlib import nullcontext +from typing import TYPE_CHECKING, Any, List, Optional, Tuple, Union # THIRD PARTY import astropy.units as u -import numpy as np +from astropy.units import Quantity from galpy.potential import SCFPotential -from numpy import arange, array, atleast_1d, inf, isinf, nan_to_num, pi, sum, tril_indices, zeros +from numpy import arange, array, atleast_1d, floating, inf, isinf, nan_to_num, pi, result_type, sum +from numpy import tril_indices, zeros from numpy.typing import ArrayLike from scipy._lib._util import check_random_state from scipy.special import lpmv @@ -26,6 +28,10 @@ from sample_scf._typing import NDArrayF, RandomGenerator, RandomLike from sample_scf.representation import x_of_theta +if TYPE_CHECKING: + # LOCAL + from .base_multivariate import SCFSamplerBase + __all__: List[str] = [] # nothing is publicly scoped ############################################################################## @@ -33,6 +39,256 @@ ############################################################################## +def _calculate_rhoTilde(distr: Union["rv_potential", SCFSamplerBase], /, r: Quantity) -> NDArrayF: + """Compute the r-dependent coefficient matrix. + + Parameters + ---------- + distr : `rv_potential` or `SCFSamplerBase` + r : (R,) Quantity['length', float] + + returns + ------- + (R, N, L) ndarray[float] + """ + # compute the r-dependent coefficient matrix $\tilde{\rho}$ + nmaxp1, lmaxp1 = distr._potential._Acos.shape[:2] + gprs = atleast_1d(r.to_value(u.kpc)) / distr._potential._ro + rhoT = array([distr._potential._rhoTilde(r, N=nmaxp1, L=lmaxp1) for r in gprs]) # (R, N, L) + # this matrix can have incorrect NaN values when r=0, inf + # and needs to be corrected + ind = (r == 0) | isinf(r) + rhoT[ind] = nan_to_num(rhoT[ind], copy=False, posinf=inf, neginf=-inf) + + return rhoT + + +def _calculate_Qls( + distr: Union["rv_potential", SCFSamplerBase], + /, + r: Quantity, + rhoTilde: Optional[NDArrayF] = None, +) -> NDArrayF: + r""" + Compute the radial sums for inclination weighting factors. + The weighting factors measure perturbations from spherical symmetry. + The sin component disappears in the integral. + + :math:`Q_l(r) = \sum_{n=0}^{n_{\max}}A_{nl} \tilde{\rho}_{nl0}(r)` + + Parameters + ---------- + r : (R,) Quantity['kpc', float] + Radii. Scalar or 1D array. + rhoTilde : (R, N, L) array[float] + + Returns + ------- + Ql : (R, L) array[float] + """ + Acos = distr.potential._Acos # (N, L, M) + rhoT = distr.calculate_rhoTilde(r) if rhoTilde is None else rhoTilde + + # inclination weighting factors + Qls: NDArrayF = sum(Acos[None, :, :, 0] * rhoT, axis=1) # (R, L) + # this matrix can have incorrect NaN values when radii=0 because + # rhoTilde will have +/- infs which when summed produce a NaN. + # at r=0 this can be changed to 0. # TODO! double confirm math + ind0 = r == 0 + if not sum(nan_to_num(rhoT[ind0, :, 0], posinf=1, neginf=-1)) == 0: + # note: this if statement works even if ind0 is all False + warnings.warn("Qls have non-cancelling infinities at r==0") + else: + Qls[ind0] = nan_to_num(Qls[ind0], copy=False) # TODO! Nan-> 0 or 1? + + return Qls + + +def _pnts_Scs( + radii: NDArrayF, + theta: NDArrayF, + rhoTilde: NDArrayF, + Acos: NDArrayF, + Asin: NDArrayF, +) -> Tuple[NDArrayF, NDArrayF]: + """Radial and inclination sums for azimuthal weighting factors. + + Parameters + ---------- + radii : (R/T,) ndarray[float] + rhoTilde: (R/T, N, L) ndarray[float] + Acos, Asin : (N, L, L) ndarray[float] + theta : (R/T,) ndarray[float] + + Returns + ------- + Scm, Ssm : (R, T, L) ndarray[float] + Azimuthal weighting factors. + Cosine and Sine, respectively. + + Warns + ----- + RuntimeWarning + For invalid values (inf addition -> Nan). + For overflow encountered related to inf and 0 division. + """ + T: int = len(theta) + L = M = Acos.shape[1] - 1 + # N = Acos.shape[0] - 1 + + # The r-dependent coefficient matrix $\tilde{\rho}$ + RhoT = rhoTilde[..., None] # (R/T, N, L, {M}) + + # need r and theta to be arrays. Maintains units. + x: NDArrayF = x_of_theta(theta) # (T,) + xs = x[:, None, None, None] # (R/T, {N}, {L}, {M}) + + # legendre polynomials + ls, ms = tril_indices(L + 1) # index set I_(L, M) + + lps = zeros((T, L + 1, M + 1)) # (R/T, L, M) + lps[:, ls, ms] = lpmv(ms[None, :], ls[None, :], xs[:, 0, 0, 0]) + Plm = lps[:, None, :, :] # (R/T, {N}, L, M) + + # full S matrices (R/T, N, L, M) # TODO! where's Nlm + # n-sum # (R/T, N, L, M) -> (R, T, L, M) + Sclm = sum(Acos[None, :, :, :] * RhoT * Plm, axis=-3) + Sslm = sum(Asin[None, :, :, :] * RhoT * Plm, axis=-3) + + # fix adding +/- inf -> NaN. happens when r=0. + idx = radii == 0 + Sclm[idx] = nan_to_num(Sclm[idx], posinf=inf, neginf=-inf) + Sslm[idx] = nan_to_num(Sslm[idx], posinf=inf, neginf=-inf) + + # l'-sum # FIXME! confirm correct som + Scm = sum(Sclm, axis=-2) + Ssm = sum(Sslm, axis=-2) + + return Scm, Ssm + + +# TODO! it's possible to make the r, theta grids, flatten, use _pnts_Scs, +# then reshape or asssign by index to the grid. Then the Sc calc is only +# in one place. +def _grid_Scs( + radii: NDArrayF, + thetas: NDArrayF, + rhoTilde: NDArrayF, + Acos: NDArrayF, + Asin: NDArrayF, +) -> Tuple[NDArrayF, NDArrayF]: + """Radial and inclination sums for azimuthal weighting factors. + + Parameters + ---------- + radii : (R,) ndarray[float] + rhoTilde: (R, N, L) ndarray[float] + Acos, Asin : (N, L, L) ndarray[float] + thetas : (T,) ndarray[float] + + Returns + ------- + Scm, Ssm : (R, T, L) ndarray[float] + Azimuthal weighting factors. + Cosine and Sine, respectively. + + Warns + ----- + RuntimeWarning + For invalid values (inf addition -> Nan). + For overflow encountered related to inf and 0 division. + """ + T: int = len(thetas) + L = M = Acos.shape[1] - 1 + + # The r-dependent coefficient matrix $\tilde{\rho}$ + RhoT = rhoTilde[:, None, :, :, None] # (R, {T}, N, L, {M}) + + # need r and theta to be arrays. Maintains units. + x: NDArrayF = x_of_theta(thetas << u.rad) # (T,) + xs = x[None, :, None, None, None] # ({R}, T, {N}, {L}, {M}) + + # legendre polynomials + ls, ms = tril_indices(L + 1) # index set I_(L, M) + + lps = zeros((T, L + 1, M + 1)) # (T, L, M) + lps[:, ls, ms] = lpmv(ms[None, ...], ls[None, ...], xs[0, :, 0, 0, 0, None]) + Plm = lps[None, :, None, :, :] # ({R}, T, {N}, L, M) + + # full S matrices (R, T, N, L, M) + # n-sum # (R, T, N, L, M) -> (R, T, L, M) + Sclm = sum(Acos[None, None, :, :, :] * RhoT * Plm, axis=-3) + Sslm = sum(Asin[None, None, :, :, :] * RhoT * Plm, axis=-3) + + # fix adding +/- inf -> NaN. happens when r=0. + idx = radii == 0 + Sclm[idx, ...] = nan_to_num(Sclm[idx, ...], posinf=inf, neginf=-inf) + Sslm[idx, ...] = nan_to_num(Sslm[idx, ...], posinf=inf, neginf=-inf) + + # l'-sum + Scm = sum(Sclm, axis=-2) + Ssm = sum(Sslm, axis=-2) + + return Scm, Ssm + + +def _calculate_Scs( + distr, + r: Quantity, + theta: Quantity, + *, + grid: bool = True, + warn: bool = True, +) -> Tuple[NDArrayF, NDArrayF]: + r"""Radial and inclination sums for azimuthal weighting factors. + + Parameters + ---------- + r : float or (R,) ndarray[float] + theta : float or (T,) ndarray[float] + + grid : bool, optional keyword-only + warn : bool, optional keyword-only + + Returns + ------- + Rm, Sm : (R, T, L) ndarray[float] + Azimuthal weighting factors. + """ + # need r and theta to be float arrays. + rdtype = result_type(float, result_type(r)) + radii: NDArrayF = atleast_1d(r).astype(rdtype) # (R,) + thetas: NDArrayF = atleast_1d(theta) << u.rad # (T,) + + if not grid and len(thetas) != len(radii): + raise ValueError + + # compute the r-dependent coefficient matrix $\tilde{\rho}$ # (R, N, L) + rhoTilde = _calculate_rhoTilde(distr, radii) + + # pass to actual calculator, which takes the matrices and r, theta grids. + with warnings.catch_warnings() if not warn else nullcontext(): + if not warn: + warnings.filterwarnings( + "ignore", + category=RuntimeWarning, + message="(^invalid value)|(^overflow encountered)", + ) + func = _grid_Scs if grid else _pnts_Scs + Sc, Ss = func( + radii, + thetas, + rhoTilde=rhoTilde, + Acos=distr.potential._Acos, + Asin=distr.potential._Asin, + ) + + return Sc, Ss + + +############################################################################## + + class rv_potential(rv_continuous, metaclass=ABCMeta): """ Modified :class:`scipy.stats.rv_continuous` to use custom ``rvs`` methods. @@ -62,7 +318,7 @@ class rv_potential(rv_continuous, metaclass=ABCMeta): The tolerance for fixed point calculation for generic ppf. badvalue : float, optional keyword-only The value in a result arrays that indicates a value that for which - some argument restriction is violated, default is np.nan. + some argument restriction is violated, default is `~numpy.nan`. name : str, optional keyword-only The name of the instance. This string is used to construct the default example for distributions. @@ -83,7 +339,7 @@ class rv_potential(rv_continuous, metaclass=ABCMeta): seed : {None, int, `numpy.random.Generator`, `numpy.random.RandomState`}, optional keyword-only - If `seed` is None (or `np.random`), the `numpy.random.RandomState` + If `seed` is None (or `numpy.random`), the `numpy.random.RandomState` singleton is used. If `seed` is an int, a new ``RandomState`` instance is used, seeded with `seed`. @@ -163,22 +419,13 @@ def calculate_rhoTilde(self, radii: Quantity) -> NDArrayF: ------- (R, N, L) ndarray[float] """ - # compute the r-dependent coefficient matrix $\tilde{\rho}$ - nmaxp1, lmaxp1 = self._potential._Acos.shape[:2] - gprs = np.atleast_1d(radii.to_value(u.kpc)) / self._potential._ro - rhoT = array([self._potential._rhoTilde(r, N=nmaxp1, L=lmaxp1) for r in gprs]) # (R, N, L) - # this matrix can have incorrect NaN values when radii=0, inf - # and needs to be corrected - ind = (radii == 0) | isinf(radii) - rhoT[ind] = nan_to_num(rhoT[ind], copy=False, posinf=inf, neginf=-inf) - - return rhoT + return _calculate_rhoTilde(self, r=radii) # --------------------------------------------------------------- def rvs( self, - *args: Union[np.floating, ArrayLike], + *args: Union[floating, ArrayLike], size: Optional[int] = None, random_state: RandomLike = None, **kwargs, @@ -210,6 +457,7 @@ def rvs( rndm = check_random_state(random_state) else: rndm = self._random_state + random_state_saved = None # go directly to `_rvs` vals: NDArrayF = self._rvs(*args, size=size, random_state=rndm, **kwargs) @@ -254,7 +502,7 @@ def __init__(self, potential: SCFPotential, **kwargs) -> None: def rvs( self, - *args: Union[np.floating, ArrayLike], + *args: Union[floating, ArrayLike], size: Optional[int] = None, random_state: RandomLike = None, ) -> NDArrayF: @@ -285,22 +533,7 @@ def calculate_Qls(self, r: Quantity, rhoTilde: Optional[NDArrayF] = None) -> NDA ------- Ql : (R, L) array[float] """ - Acos = self.potential._Acos # (N, L, M) - rhoT = self.calculate_rhoTilde(r) if rhoTilde is None else rhoTilde - - # inclination weighting factors - Qls: NDArrayF = sum(Acos[None, :, :, 0] * rhoT, axis=1) # (R, L) - # this matrix can have incorrect NaN values when radii=0 because - # rhoTilde will have +/- infs which when summed produce a NaN. - # at r=0 this can be changed to 0. # TODO! double confirm math - ind0 = r == 0 - if not sum(nan_to_num(rhoT[ind0, :, 0], posinf=1, neginf=-1)) == 0: - # note: this if statement works even if ind0 is all False - warnings.warn("Qls have non-cancelling infinities at r==0") - else: - Qls[ind0] = nan_to_num(Qls[ind0], copy=False) # TODO! Nan-> 0 or 1? - - return Qls + return _calculate_Qls(self, r=r, rhoTilde=rhoTilde) class phi_distribution_base(rv_potential): @@ -319,134 +552,6 @@ def __init__(self, potential: SCFPotential, **kwargs: Any) -> None: self._lrange = arange(0, self._lmax + 1) - # --------------------------------------------------------------- - - @staticmethod - def _pnts_Scs( - radii: NDArrayF, - theta: NDArrayF, - rhoTilde: NDArrayF, - Acos: NDArrayF, - Asin: NDArrayF, - ) -> Tuple[NDArrayF, NDArrayF]: - """Radial and inclination sums for azimuthal weighting factors. - - Parameters - ---------- - radii : (R/T,) ndarray[float] - rhoTilde: (R/T, N, L) ndarray[float] - Acos, Asin : (N, L, L) ndarray[float] - theta : (R/T,) ndarray[float] - - Returns - ------- - Scm, Ssm : (R, T, L) ndarray[float] - Azimuthal weighting factors. - Cosine and Sine, respectively. - - Warns - ----- - RuntimeWarning - For invalid values (inf addition -> Nan). - For overflow encountered related to inf and 0 division. - """ - T: int = len(theta) - N = Acos.shape[0] - 1 - L = M = Acos.shape[1] - 1 - - # The r-dependent coefficient matrix $\tilde{\rho}$ - RhoT = rhoTilde[..., None] # (R/T, N, L, {M}) - - # need r and theta to be arrays. Maintains units. - x: NDArrayF = x_of_theta(theta) # (T,) - xs = x[:, None, None, None] # (R/T, {N}, {L}, {M}) - - # legendre polynomials - ls, ms = tril_indices(L + 1) # index set I_(L, M) - - lps = zeros((T, L + 1, M + 1)) # (R/T, L, M) - lps[:, ls, ms] = lpmv(ms[None, :], ls[None, :], xs[:, 0, 0, 0]) - Plm = lps[:, None, :, :] # (R/T, {N}, L, M) - - # full S matrices (R/T, N, L, M) # TODO! where's Nlm - # n-sum # (R/T, N, L, M) -> (R, T, L, M) - Sclm = np.sum(Acos[None, :, :, :] * RhoT * Plm, axis=-3) - Sslm = np.sum(Asin[None, :, :, :] * RhoT * Plm, axis=-3) - - # fix adding +/- inf -> NaN. happens when r=0. - idx = radii == 0 - Sclm[idx] = nan_to_num(Sclm[idx], posinf=np.inf, neginf=-np.inf) - Sslm[idx] = nan_to_num(Sslm[idx], posinf=np.inf, neginf=-np.inf) - - # l'-sum # FIXME! confirm correct som - Scm = np.sum(Sclm, axis=-2) - Ssm = np.sum(Sslm, axis=-2) - - return Scm, Ssm - - @staticmethod - def _grid_Scs( - radii: NDArrayF, - thetas: NDArrayF, - rhoTilde: NDArrayF, - Acos: NDArrayF, - Asin: NDArrayF, - ) -> Tuple[NDArrayF, NDArrayF]: - """Radial and inclination sums for azimuthal weighting factors. - - Parameters - ---------- - radii : (R,) ndarray[float] - rhoTilde: (R, N, L) ndarray[float] - Acos, Asin : (N, L, L) ndarray[float] - thetas : (T,) ndarray[float] - - Returns - ------- - Scm, Ssm : (R, T, L) ndarray[float] - Azimuthal weighting factors. - Cosine and Sine, respectively. - - Warns - ----- - RuntimeWarning - For invalid values (inf addition -> Nan). - For overflow encountered related to inf and 0 division. - """ - T: int = len(thetas) - N = Acos.shape[0] - 1 - L = M = Acos.shape[1] - 1 - - # The r-dependent coefficient matrix $\tilde{\rho}$ - RhoT = rhoTilde[:, None, :, :, None] # (R, {T}, N, L, {M}) - - # need r and theta to be arrays. Maintains units. - x: NDArrayF = x_of_theta(thetas << u.rad) # (T,) - xs = x[None, :, None, None, None] # ({R}, T, {N}, {L}, {M}) - - # legendre polynomials - ls, ms = tril_indices(L + 1) # index set I_(L, M) - - lps = zeros((T, L + 1, M + 1)) # (T, L, M) - lps[:, ls, ms] = lpmv(ms[None, ...], ls[None, ...], xs[0, :, 0, 0, 0, None]) - Plm = lps[None, :, None, :, :] # ({R}, T, {N}, L, M) - - # full S matrices (R, T, N, L, M) - # n-sum # (R, T, N, L, M) -> (R, T, L, M) - Sclm = np.sum(Acos[None, None, :, :, :] * RhoT * Plm, axis=-3) - Sslm = np.sum(Asin[None, None, :, :, :] * RhoT * Plm, axis=-3) - - # fix adding +/- inf -> NaN. happens when r=0. - idx = radii == 0 - Sclm[idx, ...] = nan_to_num(Sclm[idx, ...], posinf=np.inf, neginf=-np.inf) - Sslm[idx, ...] = nan_to_num(Sslm[idx, ...], posinf=np.inf, neginf=-np.inf) - - # l'-sum - Scm = np.sum(Sclm, axis=-2) - Ssm = np.sum(Sslm, axis=-2) - - return Scm, Ssm - def calculate_Scs( self, r: Quantity, @@ -470,32 +575,4 @@ def calculate_Scs( Rm, Sm : (R, T, L) ndarray[float] Azimuthal weighting factors. """ - # need r and theta to be float arrays. - rdtype = np.result_type(float, np.result_type(r)) - radii: NDArrayF = atleast_1d(r).astype(rdtype) # (R,) - thetas: NDArrayF = atleast_1d(theta) << u.rad # (T,) - - if not grid and len(thetas) != len(radii): - raise ValueError - - # compute the r-dependent coefficient matrix $\tilde{\rho}$ # (R, N, L) - rhoTilde = self.calculate_rhoTilde(radii) - - # pass to actual calculator, which takes the matrices and r, theta grids. - with warnings.catch_warnings() if not warn else nullcontext(): - if not warn: - warnings.filterwarnings( - "ignore", - category=RuntimeWarning, - message="(^invalid value)|(^overflow encountered)", - ) - func = self._grid_Scs if grid else self._pnts_Scs - Sc, Ss = func( - radii, - thetas, - rhoTilde=rhoTilde, - Acos=self.potential._Acos, - Asin=self.potential._Asin, - ) - - return Sc, Ss + return _calculate_Scs(self, r=r, theta=theta, grid=grid, warn=warn) diff --git a/sample_scf/conftest.py b/sample_scf/conftest.py index e89fc0c..3a175e1 100644 --- a/sample_scf/conftest.py +++ b/sample_scf/conftest.py @@ -11,21 +11,13 @@ """ # STDLIB -import copy import os # THIRD PARTY -import numpy as np import pytest -from astropy.utils.data import get_pkg_data_filename, get_pkg_data_path -from galpy.df import isotropicHernquistdf, isotropicNFWdf, osipkovmerrittNFWdf -from galpy.potential import ( - HernquistPotential, - NFWPotential, - SCFPotential, - TriaxialNFWPotential, - scf_compute_coeffs_axi, -) +from galpy.df import isotropicHernquistdf +from galpy.potential import HernquistPotential, SCFPotential +from numpy import zeros try: # THIRD PARTY @@ -71,7 +63,7 @@ def pytest_configure(config): hernquist_potential.turn_physical_on() hernquist_df = isotropicHernquistdf(hernquist_potential) -Acos = np.zeros((5, 6, 6)) +Acos = zeros((5, 6, 6)) Acos[0, 0, 0] = 1 _hernquist_scf_potential = SCFPotential(Acos=Acos) _hernquist_scf_potential.turn_physical_on() @@ -84,13 +76,13 @@ def pytest_configure(config): # # FIXME! load this up as a test data file # fpath = get_pkg_data_path("tests/data/nfw.npz", package="sample_scf") # try: -# data = np.load(fpath) +# data = load(fpath) # except FileNotFoundError: # a_scf = 80 # Acos, Asin = scf_compute_coeffs_axi(nfw_potential.dens, N=40, L=30, a=a_scf) -# np.savez(fpath, Acos=Acos, Asin=Asin, a_scf=a_scf) +# savez(fpath, Acos=Acos, Asin=Asin, a_scf=a_scf) # else: -# data = np.load(fpath, allow_pickle=True) +# data = load(fpath, allow_pickle=True) # Acos = copy.deepcopy(data["Acos"]) # Asin = None # a_scf = data["a_scf"] @@ -140,7 +132,9 @@ def hernquist_scf_potential(): def potentials(request): if request.param in ("hernquist_scf_potential"): potential = hernquist_scf_potential.__wrapped__() - elif request.param == "nfw_scf_potential": - potential = nfw_scf_potential.__wrapped__() + # elif request.param == "nfw_scf_potential": + # potential = nfw_scf_potential.__wrapped__() + else: + raise ValueError yield potential diff --git a/sample_scf/exact/azimuth.py b/sample_scf/exact/azimuth.py index 4837821..4266896 100644 --- a/sample_scf/exact/azimuth.py +++ b/sample_scf/exact/azimuth.py @@ -12,8 +12,8 @@ # THIRD PARTY import astropy.units as u -import numpy as np from galpy.potential import SCFPotential +from numpy import arange, atleast_1d, cos, nan_to_num, pi, sin, sum from numpy.typing import ArrayLike # LOCAL @@ -41,9 +41,9 @@ class exact_phi_distribution_base(phi_distribution_base): """ def __init__(self, potential: SCFPotential, **kw: Any) -> None: - kw["a"], kw["b"] = 0, 2 * np.pi + kw["a"], kw["b"] = 0, 2 * pi super().__init__(potential, **kw) - self._lrange = np.arange(0, self._lmax + 1) + self._lrange = arange(0, self._lmax + 1) # for compatibility self._Sc: Optional[NDArrayF] = None @@ -69,21 +69,20 @@ def _cdf(self, phi: NDArrayF, *args: Any, **kw: Any) -> NDArrayF: """ Rm, Sm = kw.get("Scs", (self._Sc, self._Ss)) # (R/T, L) - Phis: NDArrayF = np.atleast_1d(phi)[:, None] # (P, {L}) + Phis: NDArrayF = atleast_1d(phi)[:, None] # (P, {L}) # l = 0 : spherical symmetry - term0: NDArrayF = Phis[..., 0] / (2 * np.pi) # (1, P) + term0: NDArrayF = Phis[..., 0] / (2 * pi) # (1, P) # l = 1+ : non-symmetry factor = 1 / Rm[:, 0] # R0 (R/T,) # can be inf - ms = np.arange(1, self._lmax)[None, :] # ({R/T/P}, L) - term1p = np.sum( - (Rm[:, 1:] * np.sin(ms * Phis) + Sm[:, 1:] * (1 - np.cos(ms * Phis))) - / (2 * np.pi * ms), + ms = arange(1, self._lmax)[None, :] # ({R/T/P}, L) + term1p = sum( + (Rm[:, 1:] * sin(ms * Phis) + Sm[:, 1:] * (1 - cos(ms * Phis))) / (2 * pi * ms), axis=-1, ) - cdf: NDArrayF = term0 + np.nan_to_num(factor * term1p) # (R/T/P,) + cdf: NDArrayF = term0 + nan_to_num(factor * term1p) # (R/T/P,) # 'factor' can be inf and term1p 0 => inf * 0 = nan -> 0 return cdf diff --git a/sample_scf/exact/core.py b/sample_scf/exact/core.py index ff59baa..5801638 100644 --- a/sample_scf/exact/core.py +++ b/sample_scf/exact/core.py @@ -8,7 +8,6 @@ from __future__ import annotations # STDLIB -import abc from typing import Any, Optional # THIRD PARTY @@ -18,11 +17,11 @@ # LOCAL from .azimuth import exact_phi_distribution from .inclination import exact_theta_distribution -from .radial import exact_r_distribution -from sample_scf._typing import NDArrayF, RandomLike +from sample_scf._typing import RandomLike from sample_scf.base_multivariate import SCFSamplerBase +from sample_scf.exact.radial import exact_r_distribution -__all__ = ["SCFSampler"] +__all__ = ["ExactSCFSampler"] ############################################################################## @@ -51,7 +50,7 @@ def __init__(self, potential: SCFPotential, **kw: Any) -> None: # make samplers total_mass = kw.pop("total_mass", None) - self._r_distribution = r_distribution(potential, total_mass=total_mass, **kw) + self._r_distribution = exact_r_distribution(potential, total_mass=total_mass, **kw) self._theta_distribution = exact_theta_distribution(potential, **kw) # r=None self._phi_distribution = exact_phi_distribution(potential, **kw) # r=None, theta=None @@ -74,4 +73,4 @@ def rvs( ------- `~astropy.coordinates.PhysicsSphericalRepresentation` """ - return super().rvs(size=size, random_state=random_state, vectorized=False) + return super().rvs(size=size, random_state=random_state) diff --git a/sample_scf/exact/inclination.py b/sample_scf/exact/inclination.py index 2924f3d..ccfa19a 100644 --- a/sample_scf/exact/inclination.py +++ b/sample_scf/exact/inclination.py @@ -8,13 +8,13 @@ from __future__ import annotations # STDLIB -import abc -from typing import Any, Optional, Union, cast +from typing import Any, Optional, Union # THIRD PARTY import astropy.units as u -import numpy as np +from astropy.units import Quantity from galpy.potential import SCFPotential +from numpy import atleast_1d, atleast_2d, floating, nan_to_num, pad from numpy.polynomial.legendre import legval from numpy.typing import ArrayLike @@ -39,19 +39,19 @@ def _cdf(self, x: NDArrayF, Qls: NDArrayF) -> NDArrayF: .. math:: - F_{\theta}(\theta; r) = \frac{1 + \cos{\theta}}{2} + - \frac{1}{2 Q_0(r)}\sum_{\ell=1}^{L_{\max}}Q_{\ell}(r) - \frac{\sin(\theta) P_{\ell}^{1}(\cos{\theta})}{\ell(\ell+1)} + F_{\theta}(\theta; r) = \frac{1 + \\cos{\theta}}{2} + + \frac{1}{2 Q_0(r)}\\sum_{\\ell=1}^{L_{\\max}}Q_{\\ell}(r) + \frac{\\sin(\theta) P_{\\ell}^{1}(\\cos{\theta})}{\\ell(\\ell+1)} Where - Q_{\ell}(r) = \sum_{n=0}^{N_{\max}} N_{\ell 0} A_{n\ell 0}^{(\cos)} - \tilde{\rho}_{n\ell}(r) + Q_{\\ell}(r) = \\sum_{n=0}^{N_{\\max}} N_{\\ell 0} A_{n\\ell 0}^{(\\cos)} + \tilde{\rho}_{n\\ell}(r) Parameters ---------- x : number or (T,) array[number] - :math:`x = \cos\theta`. Must be in the range [-1, 1] + :math:`x = \\cos\theta`. Must be in the range [-1, 1] Qls : (R, L) array[float] Radially-dependent coefficients parameterizing the deviations from a uniform distribution on the inclination angle. @@ -60,8 +60,8 @@ def _cdf(self, x: NDArrayF, Qls: NDArrayF) -> NDArrayF: ------- (R, T) array """ - xs = np.atleast_1d(x) # (T,) - Qls = np.atleast_2d(Qls) # (R, L) + xs = atleast_1d(x) # (T,) + Qls = atleast_2d(Qls) # (R, L) # l = 0 term0 = 0.5 * (1.0 - xs) # (T,) @@ -69,12 +69,12 @@ def _cdf(self, x: NDArrayF, Qls: NDArrayF) -> NDArrayF: factor = 1.0 / (2.0 * Qls[:, 0]) # (R,) wQls = Qls[:, 1:] / (2 * self._lrange[None, 1:] + 1) # apply over (L,) dimension - wQls_lp1 = np.pad(wQls, [[0, 0], [2, 0]]) # pad start of (L,) dimension + wQls_lp1 = pad(wQls, [[0, 0], [2, 0]]) # pad start of (L,) dimension sumPlp1 = legval(xs, wQls_lp1.T, tensor=True) # (R, T) sumPlm1 = legval(xs, wQls.T, tensor=True) # (R, T) - cdf = term0 + np.nan_to_num((factor * (sumPlm1 - sumPlp1).T).T) # (R, T) + cdf = term0 + nan_to_num((factor * (sumPlm1 - sumPlp1).T).T) # (R, T) return cdf # TODO! get rid of sf function # @abc.abstractmethod @@ -109,7 +109,7 @@ def _cdf(self, x: NDArrayF, Qls: NDArrayF) -> NDArrayF: def _rvs( self, - *args: Union[np.floating, ArrayLike], + *args: Union[floating, ArrayLike], size: Optional[int] = None, random_state: RandomLike = None, # return_thetas: bool = True diff --git a/sample_scf/exact/radial.py b/sample_scf/exact/radial.py index 5659dec..fc13a9d 100644 --- a/sample_scf/exact/radial.py +++ b/sample_scf/exact/radial.py @@ -8,20 +8,15 @@ from __future__ import annotations # STDLIB -import abc -from typing import Any, Optional, Union, cast +from typing import Any, Optional # THIRD PARTY -import astropy.units as u -import numpy as np -from astropy.coordinates import PhysicsSphericalRepresentation from astropy.units import Quantity from galpy.potential import SCFPotential -from numpy.typing import ArrayLike +from numpy import atleast_1d, inf, isnan, vectorize # LOCAL -from sample_scf._typing import NDArrayF, RandomLike -from sample_scf.base_multivariate import SCFSamplerBase +from sample_scf._typing import NDArrayF from sample_scf.base_univariate import r_distribution_base __all__ = ["exact_r_distribution"] @@ -48,20 +43,20 @@ def __init__( self, potential: SCFPotential, total_mass: Optional[Quantity] = None, **kw: Any ) -> None: # make sampler - kw["a"], kw["b"] = 0, np.inf # allowed range of r + kw["a"], kw["b"] = 0, inf # allowed range of r super().__init__(potential, **kw) # normalization for total mass # TODO! if mass has units if total_mass is None: - total_mass = potential._mass(np.inf) - if np.isnan(total_mass): + total_mass = potential._mass(inf) + if isnan(total_mass): raise ValueError( "total mass is NaN. Need to pass kwarg `total_mass` with a non-NaN value.", ) self._mtot = total_mass # vectorize mass function, which is scalar - self._vec_cdf = np.vectorize(self._potential._mass) + self._vec_cdf = vectorize(self._potential._mass) def _cdf(self, r: Quantity, *args: Any, **kw: Any) -> NDArrayF: """Cumulative Distribution Function. @@ -77,9 +72,9 @@ def _cdf(self, r: Quantity, *args: Any, **kw: Any) -> NDArrayF: mass : array-like Shape matches 'r'. """ - mass: NDArrayF = np.atleast_1d(self._vec_cdf(r)) / self._mtot + mass: NDArrayF = atleast_1d(self._vec_cdf(r)) / self._mtot mass[r == 0] = 0 - mass[r == np.inf] = 1 + mass[r == inf] = 1 return mass.item() if mass.shape == (1,) else mass cdf = _cdf diff --git a/sample_scf/exact/tests/test_core.py b/sample_scf/exact/tests/test_core.py index 844d44a..1a82423 100644 --- a/sample_scf/exact/tests/test_core.py +++ b/sample_scf/exact/tests/test_core.py @@ -9,14 +9,12 @@ # THIRD PARTY import astropy.units as u import matplotlib.pyplot as plt -import numpy as np import pytest from sampler_scf.base_multivariate import SCFSamplerBase # LOCAL -from .test_base_multivariate import BaseTest_SCFSamplerBase, phis, radii, thetas +from .test_base_multivariate import BaseTest_SCFSamplerBase from sample_scf import ExactSCFSampler -from sample_scf.exact import exact_phi_distribution, exact_r_distribution, exact_theta_distribution ############################################################################## # CODE diff --git a/sample_scf/exact/tests/test_exact.py b/sample_scf/exact/tests/test_exact.py index 007fd19..95b9b10 100644 --- a/sample_scf/exact/tests/test_exact.py +++ b/sample_scf/exact/tests/test_exact.py @@ -9,22 +9,29 @@ # THIRD PARTY import astropy.units as u import matplotlib.pyplot as plt -import numpy as np import pytest from astropy.utils.misc import NumpyRNGContext +from numpy import allclose, atleast_1d, atleast_2d, concatenate, geomspace, isclose, linspace, pi +from numpy import random from numpy.testing import assert_allclose # LOCAL from .common import phi_distributionTestBase, r_distributionTestBase, theta_distributionTestBase from .test_base import SCFSamplerTestBase -from sample_scf import conftest, exact +from sample_scf import conftest +from sample_scf.base_univariate import _calculate_Qls +from sample_scf.exact import ExactSCFSampler +from sample_scf.exact.azimuth import exact_phi_distribution +from sample_scf.exact.inclination import exact_theta_distribution +from sample_scf.exact.radial import exact_r_distribution +from sample_scf.representation import r_of_zeta, x_of_theta ############################################################################## # PARAMETERS -rgrid = np.concatenate(([0], np.geomspace(1e-1, 1e3, 29))) # same shape as ↓ -tgrid = np.linspace(-np.pi / 2, np.pi / 2, 30) -pgrid = np.linspace(0, 2 * np.pi, 30) +rgrid = concatenate(([0], geomspace(1e-1, 1e3, 29))) # same shape as ↓ +tgrid = linspace(-pi / 2, pi / 2, 30) +pgrid = linspace(0, 2 * pi, 30) ############################################################################## @@ -39,7 +46,7 @@ def setup_class(self): super().setup_class(self) # sampler initialization - self.cls = exact.SCFSampler + self.cls = ExactSCFSampler self.cls_args = () self.cls_kwargs = {} self.cls_pot_kw = conftest.cls_pot_kw @@ -62,9 +69,9 @@ def test_init(self, potentials): kw = {**self.cls_kwargs, **self.cls_pot_kw.get(potentials, {})} instance = self.cls(potentials, *self.cls_args, **kw) - assert isinstance(instance.r_distribution, exact.r_distribution) - assert isinstance(instance.theta_distribution, exact.theta_distribution) - assert isinstance(instance.phi_distribution, exact.phi_distribution) + assert isinstance(instance.r_distribution, exact_r_distribution) + assert isinstance(instance.theta_distribution, exact_theta_distribution) + assert isinstance(instance.phi_distribution, exact_phi_distribution) def test_rvs(self, sampler): """Test Random Variates Sampler.""" @@ -80,7 +87,7 @@ def test_rvs(self, sampler): ) def test_cdf(self, sampler, r, theta, phi, expected): """Test :meth:`sample_scf.base_multivariate.SCFSamplerBase.cdf`.""" - assert np.allclose(sampler.cdf(r, theta, phi), expected, atol=1e-16) + assert allclose(sampler.cdf(r, theta, phi), expected, atol=1e-16) # =============================================================== # Plot Tests @@ -159,7 +166,7 @@ def setup_class(self): super().setup_class(self) # sampler initialization - self.cls = exact.r_distribution + self.cls = exact_r_distribution self.cls_args = () self.cls_kwargs = {} self.cls_pot_kw = conftest.cls_pot_kw @@ -185,13 +192,13 @@ def test_init(self): # test if mgrid is SCFPotential # TODO! use hypothesis - @pytest.mark.parametrize("r", np.random.default_rng(0).uniform(0, 1e4, 10)) + @pytest.mark.parametrize("r", random.default_rng(0).uniform(0, 1e4, 10)) def test__cdf(self, sampler, r): """Test :meth:`sample_scf.exact.r_distribution._cdf`.""" super().test__cdf(sampler, r) # expected - mass = np.atleast_1d(sampler._potential._mass(r)) / sampler._mtot + mass = atleast_1d(sampler._potential._mass(r)) / sampler._mtot assert_allclose(sampler._cdf(r), mass) @pytest.mark.parametrize( @@ -286,7 +293,7 @@ class Test_theta_distribution(theta_distributionTestBase): def setup_class(self): super().setup_class(self) - self.cls = exact.theta_distribution + self.cls = exact_theta_distribution self.cdf_time_scale = 1e-3 self.rvs_time_scale = 7e-2 @@ -300,46 +307,46 @@ def setup_class(self): "x, r", [ *zip( - np.random.default_rng(1).uniform(-1, 1, 10), - r_of_zeta(np.random.default_rng(1).uniform(-1, 1, 10)), + random.default_rng(1).uniform(-1, 1, 10), + r_of_zeta(random.default_rng(1).uniform(-1, 1, 10)), ), ], ) def test__cdf(self, sampler, x, r): """Test :meth:`sample_scf.exact.theta_distribution._cdf`.""" - Qls = np.atleast_2d(thetaQls(sampler._potential, r)) + Qls = atleast_2d(_calculate_Qls(sampler._potential, r)) # basically a test it's Hernquist, only the first term matters - if np.allclose(Qls[:, 1:], 0.0): + if allclose(Qls[:, 1:], 0.0): assert_allclose(sampler._cdf(x, r=r), 0.5 * (x + 1.0)) else: - # TODO! a more robust test + assert False # l = 0 - term0 = 0.5 * (x + 1.0) # (T,) - # l = 1+ : non-symmetry - factor = 1.0 / (2.0 * Qls[:, 0]) # (R,) - term1p = np.sum( - (Qls[None, :, 1:] * difPls(x, self._lmax - 1).T[:, None, :]).T, - axis=0, - ) - cdf = term0[None, :] + np.nan_to_num(factor[:, None] * term1p) # (R, T) - - assert_allclose(sampler._cdf(x, r=r), cdf) - - @pytest.mark.parametrize("r", r_of_zeta(np.random.default_rng(0).uniform(-1, 1, 10))) + # term0 = 0.5 * (x + 1.0) # (T,) + # # l = 1+ : non-symmetry + # factor = 1.0 / (2.0 * Qls[:, 0]) # (R,) + # term1p = sum( + # (Qls[None, :, 1:] * difPls(x, self._lmax - 1).T[:, None, :]).T, + # axis=0, + # ) + # cdf = term0[None, :] + nan_to_num(factor[:, None] * term1p) # (R, T) + + # assert_allclose(sampler._cdf(x, r=r), cdf) + + @pytest.mark.parametrize("r", r_of_zeta(random.default_rng(0).uniform(-1, 1, 10))) def test__cdf_edge(self, sampler, r): """Test :meth:`sample_scf.exact.r_distribution._cdf`.""" - assert np.isclose(sampler._cdf(-1, r=r), 0.0, atol=1e-16) - assert np.isclose(sampler._cdf(1, r=r), 1.0, atol=1e-16) + assert isclose(sampler._cdf(-1, r=r), 0.0, atol=1e-16) + assert isclose(sampler._cdf(1, r=r), 1.0, atol=1e-16) @pytest.mark.parametrize( "theta, r", [ *zip( - np.random.default_rng(0).uniform(-np.pi / 2, np.pi / 2, 10), - np.random.default_rng(1).uniform(0, 1e4, 10), + random.default_rng(0).uniform(-pi / 2, pi / 2, 10), + random.default_rng(1).uniform(0, 1e4, 10), ), ], ) @@ -385,12 +392,12 @@ def test_exact_theta_cdf_plot(self, sampler): ) kw = dict(marker="o", ms=5, c="k", zorder=5, label="CDF") ax.plot(tgrid, sampler.cdf(tgrid, r=10), **kw) - ax.axvline(-np.pi / 2, c="tab:blue") - ax.axhline(sampler.cdf(-np.pi / 2, r=10), c="tab:blue", label=r"$\theta=-\frac{\pi}{2}$") + ax.axvline(-pi / 2, c="tab:blue") + ax.axhline(sampler.cdf(-pi / 2, r=10), c="tab:blue", label=r"$\theta=-\frac{\pi}{2}$") ax.axvline(0, c="tab:green") ax.axhline(sampler.cdf(0, r=10), c="tab:green", label=r"$\theta=0$") - ax.axvline(np.pi / 2, c="tab:red") - ax.axhline(sampler.cdf(np.pi / 2, r=10), c="tab:red", label=r"$\theta=\frac{\pi}{2}$") + ax.axvline(pi / 2, c="tab:red") + ax.axhline(sampler.cdf(pi / 2, r=10), c="tab:red", label=r"$\theta=\frac{\pi}{2}$") ax.legend(loc="lower right") # plot 2 @@ -423,7 +430,7 @@ def test_exact_theta_sampling_plot(self, sampler): sample = sample[sample < 1e4] theory = self.theory[sampler._potential].sample(n=int(1e6)).theta() - theory -= np.pi / 2 * u.rad + theory -= pi / 2 * u.rad fig = plt.figure(figsize=(10, 3)) ax = fig.add_subplot( @@ -456,7 +463,7 @@ class Test_phi_distribution(phi_distributionTestBase): def setup_class(self): super().setup_class(self) - self.cls = exact.phi_distribution + self.cls = exact_phi_distribution self.cdf_time_scale = 3e-3 self.rvs_time_scale = 3e-3 @@ -501,13 +508,13 @@ def test_exact_phi_cdf_plot(self, sampler): ylabel=r"CDF($\phi$)", ) kw = dict(marker="o", ms=5, c="k", zorder=5, label="CDF") - ax.plot(pgrid, sampler.cdf(pgrid, r=10, theta=np.pi / 6), **kw) + ax.plot(pgrid, sampler.cdf(pgrid, r=10, theta=pi / 6), **kw) ax.axvline(0, c="tab:blue") - ax.axhline(sampler.cdf(0, r=10, theta=np.pi / 6), c="tab:blue", label=r"$\phi=0$") - ax.axvline(np.pi, c="tab:green") - ax.axhline(sampler.cdf(np.pi, r=10, theta=np.pi / 6), c="tab:green", label=r"$\phi=\pi$") - ax.axvline(2 * np.pi, c="tab:red") - ax.axhline(sampler.cdf(2 * np.pi, r=10, theta=np.pi / 6), c="tab:red", label=r"$\phi=2\pi$") + ax.axhline(sampler.cdf(0, r=10, theta=pi / 6), c="tab:blue", label=r"$\phi=0$") + ax.axvline(pi, c="tab:green") + ax.axhline(sampler.cdf(pi, r=10, theta=pi / 6), c="tab:green", label=r"$\phi=\pi$") + ax.axvline(2 * pi, c="tab:red") + ax.axhline(sampler.cdf(2 * pi, r=10, theta=pi / 6), c="tab:red", label=r"$\phi=2\pi$") ax.legend(loc="lower right") fig.tight_layout() @@ -520,7 +527,7 @@ def test_exact_phi_cdf_plot(self, sampler): def test_exact_phi_sampling_plot(self, sampler): """Test sampling.""" with NumpyRNGContext(0): # control the random numbers - sample = sampler.rvs(size=int(1e3), r=10, theta=np.pi / 6) + sample = sampler.rvs(size=int(1e3), r=10, theta=pi / 6) sample = sample[sample < 1e4] theory = self.theory[sampler._potential].sample(n=int(1e3)).phi() diff --git a/sample_scf/exact/tests/test_utils.py b/sample_scf/exact/tests/test_utils.py index 715a2fa..2a5ac79 100644 --- a/sample_scf/exact/tests/test_utils.py +++ b/sample_scf/exact/tests/test_utils.py @@ -6,15 +6,14 @@ ############################################################################## # IMPORTS -# STDLIB -import contextlib - # THIRD PARTY -import astropy.units as u -import numpy as np import pytest +from numpy import inf, isclose, pi, zeros from numpy.testing import assert_allclose +# LOCAL +from sample_scf.base_univariate import _calculate_Qls, _calculate_Scs + ############################################################################## # TESTS ############################################################################## @@ -26,13 +25,13 @@ class Test_Qls: # =============================================================== # Usage Tests - @pytest.mark.parametrize("r, expected", [(0, 1), (1, 0.01989437), (np.inf, 0)]) + @pytest.mark.parametrize("r, expected", [(0, 1), (1, 0.01989437), (inf, 0)]) def test_hernquist(self, hernquist_scf_potential, r, expected): - Qls = thetaQls(hernquist_scf_potential, r=r) + Qls = _calculate_Qls(hernquist_scf_potential, r=r) # shape should be L (see setup_class) assert len(Qls) == 6 # only 1st index is non-zero - assert np.isclose(Qls[0], expected) + assert isclose(Qls[0], expected) assert_allclose(Qls[1:], 0) @pytest.mark.skip("TODO!") @@ -53,24 +52,24 @@ class Test_phiScs: "r, theta, expected", [ # show it doesn't depend on theta - (0, -np.pi / 2, (np.zeros(5), np.zeros(5))), - (0, 0, (np.zeros(5), np.zeros(5))), # special case when x=0 is 0 - (0, np.pi / 6, (np.zeros(5), np.zeros(5))), - (0, np.pi / 2, (np.zeros(5), np.zeros(5))), + (0, -pi / 2, (zeros(5), zeros(5))), + (0, 0, (zeros(5), zeros(5))), # special case when x=0 is 0 + (0, pi / 6, (zeros(5), zeros(5))), + (0, pi / 2, (zeros(5), zeros(5))), # nor on r - (1, -np.pi / 2, (np.zeros(5), np.zeros(5))), - (10, -np.pi / 4, (np.zeros(5), np.zeros(5))), - (100, np.pi / 6, (np.zeros(5), np.zeros(5))), - (1000, np.pi / 2, (np.zeros(5), np.zeros(5))), + (1, -pi / 2, (zeros(5), zeros(5))), + (10, -pi / 4, (zeros(5), zeros(5))), + (100, pi / 6, (zeros(5), zeros(5))), + (1000, pi / 2, (zeros(5), zeros(5))), # Legendre[n=0, l=0, z=z] = 1 is a special case - (1, 0, (np.zeros(5), np.zeros(5))), - (10, 0, (np.zeros(5), np.zeros(5))), - (100, 0, (np.zeros(5), np.zeros(5))), - (1000, 0, (np.zeros(5), np.zeros(5))), + (1, 0, (zeros(5), zeros(5))), + (10, 0, (zeros(5), zeros(5))), + (100, 0, (zeros(5), zeros(5))), + (1000, 0, (zeros(5), zeros(5))), ], ) def test_phiScs_hernquist(self, hernquist_scf_potential, r, theta, expected): - Rm, Sm = phiScs(hernquist_scf_potential, r, theta, warn=False) + Rm, Sm = _calculate_Scs(hernquist_scf_potential, r, theta, warn=False) assert Rm.shape == Sm.shape assert Rm.shape == (1, 1, 6) assert_allclose(Rm[0, 0, 1:], expected[0], atol=1e-16) diff --git a/sample_scf/interpolated/__init__.py b/sample_scf/interpolated/__init__.py index b3874ef..b294d23 100644 --- a/sample_scf/interpolated/__init__.py +++ b/sample_scf/interpolated/__init__.py @@ -2,7 +2,10 @@ # Licensed under a 3-clause BSD style license - see LICENSE.rst # LOCAL +from .azimuth import interpolated_phi_distribution from .core import InterpolatedSCFSampler +from .inclination import interpolated_theta_distribution +from .radial import interpolated_r_distribution __all__ = [ "InterpolatedSCFSampler", diff --git a/sample_scf/interpolated/azimuth.py b/sample_scf/interpolated/azimuth.py index d1f0b44..9a9b75d 100644 --- a/sample_scf/interpolated/azimuth.py +++ b/sample_scf/interpolated/azimuth.py @@ -14,24 +14,24 @@ # STDLIB import itertools import warnings -from typing import Any, Optional, Union, cast +from typing import Any, Optional, Tuple # THIRD PARTY import astropy.units as u -import numpy as np +from astropy.units import Quantity from galpy.potential import SCFPotential -from numpy import argsort, linspace, nan_to_num, pi, arange, sum, sin, cos, inf +from numpy import arange, argsort, column_stack, cos, empty, float64, inf, linspace, meshgrid +from numpy import nan_to_num, pi, random, sin, sum from numpy.typing import ArrayLike from scipy.interpolate import RegularGridInterpolator, splev, splrep # LOCAL +from .inclination import interpolated_theta_distribution +from .radial import interpolated_r_distribution from sample_scf._typing import NDArrayF, RandomLike -from sample_scf.base_univariate import phi_distribution_base +from sample_scf.base_univariate import _grid_Scs, phi_distribution_base from sample_scf.representation import x_of_theta, zeta_of_r -from .radial import interpolated_r_distribution -from .inclination import interpolated_theta_distribution - __all__ = ["interpolated_phi_distribution"] @@ -85,14 +85,14 @@ def __init__( self._phis = phis lR, lT, _ = len(radii), len(thetas), len(phis) - Phis = phis[None, None, :, None] # ({R}, {T}, P, {L}) + Phis = phis.to_value(u.rad)[None, None, :, None] # ({R}, {T}, P, {L}) # get Sc, Ss. We have defaults from above. if rhoTilde is None: rhoTilde = self.calculate_rhoTilde(radii) with warnings.catch_warnings(): warnings.filterwarnings("ignore", **_phi_filter) - Sc, Ss = interpolated_phi_distribution._grid_Scs( + Sc, Ss = _grid_Scs( radii, thetas, rhoTilde=rhoTilde, Acos=potential._Acos, Asin=potential._Asin ) # (R, T, L) self._Scms, self._Ssms = Sc, Ss @@ -117,7 +117,7 @@ def __init__( # interpolate # currently assumes a regular grid - self._spl_cdf = RegularGridInterpolator((zetas, xs, phis), cdfs) + self._spl_cdf = RegularGridInterpolator((zetas, xs, phis.to_value(u.rad)), cdfs) # ------- # ppf @@ -125,19 +125,19 @@ def __init__( # cdfstrategy = get_strategy(cdf_strategy) # start by supersampling - Zetas, Xs, Phis = np.meshgrid(zetas, xs, self._phi_interpolant.value, indexing="ij") + Zetas, Xs, Phis = meshgrid(zetas, xs, self._phi_interpolant.value, indexing="ij") _cdfs = self._spl_cdf((Zetas.ravel(), Xs.ravel(), Phis.ravel())) _cdfs.shape = (lR, lT, len(self._phi_interpolant)) self._cdfs = _cdfs - return + # return # build reverse spline # TODO! vectorize - ppfs = np.empty((lR, lT, self._ninterpolant), dtype=np.float64) + ppfs = empty((lR, lT, self._ninterpolant), dtype=float64) for (i, j) in itertools.product(*map(range, ppfs.shape[:2])): try: - spl = splrep(_cdfs[i, j, :], self._phi_interpolant, s=0) + spl = splrep(_cdfs[i, j, :], self._phi_interpolant.value, s=0) except ValueError: # CDF is non-real # STDLIB import pdb @@ -174,7 +174,7 @@ def cdf(self, phi: Quantity, *, r: Quantity, theta: Quantity) -> NDArrayF: def _ppf(self, q: ArrayLike, *args: Any, r: ArrayLike, theta: NDArrayF, **kw: Any) -> NDArrayF: zeta = zeta_of_r(r, self._radial_scale_factor) x = x_of_theta(theta << u.rad) - ppf: NDArrayF = self._spl_ppf(np.c_[zeta, x, q]) + ppf: NDArrayF = self._spl_ppf(column_stack((zeta, x, q))) return ppf def _rvs( @@ -182,7 +182,7 @@ def _rvs( r: NDArrayF, theta: NDArrayF, *args: Any, - random_state: np.random.RandomState, + random_state: random.RandomState, size: Optional[int] = None, ) -> NDArrayF: # Use inverse cdf algorithm for RV generation. diff --git a/sample_scf/interpolated/core.py b/sample_scf/interpolated/core.py index ada2a9b..9fb415b 100644 --- a/sample_scf/interpolated/core.py +++ b/sample_scf/interpolated/core.py @@ -12,14 +12,11 @@ from __future__ import annotations # STDLIB -import warnings from typing import Any # THIRD PARTY -import astropy.units as u -import numpy as np +from astropy.units import Quantity from galpy.potential import SCFPotential -from numpy import array, inf, isinf, nan_to_num, sum # LOCAL from .azimuth import interpolated_phi_distribution @@ -27,7 +24,6 @@ from .radial import interpolated_r_distribution from sample_scf._typing import NDArrayF from sample_scf.base_multivariate import SCFSamplerBase -from sample_scf.representation import x_of_theta __all__ = ["InterpolatedSCFSampler"] @@ -105,25 +101,23 @@ def __init__( # sampler self._r_distribution = interpolated_r_distribution(potential, radii, **kw) - radii = self._radii # sorted # compute the r-dependent coefficient matrix. - rhoT = self.calculate_rhoTilde(radii) + rhoT = self.calculate_rhoTilde(self._radii) # ------------------- # Thetas # sampler self._theta_distribution = interpolated_theta_distribution( - potential, radii, thetas, rhoTilde=rhoT, **kw + potential, self._radii, thetas, rhoTilde=rhoT, **kw ) - thetas, xs = self._thetas, self._xs # sorted # ------------------- # Phis self._phi_distribution = interpolated_phi_distribution( - potential, radii, thetas, phis, rhoTilde=rhoT, **kw + potential, self._radii, self._thetas, phis, rhoTilde=rhoT, **kw ) @property diff --git a/sample_scf/interpolated/inclination.py b/sample_scf/interpolated/inclination.py index 05c9517..9076d40 100644 --- a/sample_scf/interpolated/inclination.py +++ b/sample_scf/interpolated/inclination.py @@ -12,26 +12,24 @@ from __future__ import annotations # STDLIB -import itertools -import warnings -from typing import Any, Optional, Union, cast +from typing import Any, Optional, Tuple, Union # THIRD PARTY import astropy.units as u -from numpy import argsort, linspace, pi, array -from numpy.random import RandomState, Generator +from astropy.units import Quantity from galpy.potential import SCFPotential +from numpy import argsort, array, linspace, pi +from numpy.random import Generator, RandomState from numpy.typing import ArrayLike from scipy.interpolate import RectBivariateSpline, splev, splrep # LOCAL +from .radial import interpolated_r_distribution from sample_scf._typing import NDArrayF, RandomLike from sample_scf.base_univariate import theta_distribution_base from sample_scf.exact.inclination import exact_theta_distribution_base from sample_scf.representation import x_of_theta, zeta_of_r -from .radial import interpolated_r_distribution - __all__ = ["interpolated_theta_distribution"] diff --git a/sample_scf/interpolated/radial.py b/sample_scf/interpolated/radial.py index e326062..b5b42f3 100644 --- a/sample_scf/interpolated/radial.py +++ b/sample_scf/interpolated/radial.py @@ -8,20 +8,20 @@ from __future__ import annotations # STDLIB -from typing import Any +from typing import Any, Tuple # THIRD PARTY import astropy.units as u -import numpy as np -from numpy import argsort +from astropy.units import Quantity from galpy.potential import SCFPotential +from numpy import argsort, array, diff, inf, isnan, nanmax, nanmin, where from numpy.typing import ArrayLike from scipy.interpolate import InterpolatedUnivariateSpline as IUS # LOCAL from sample_scf._typing import NDArrayF from sample_scf.base_univariate import r_distribution_base -from sample_scf.representation import FiniteSphericalRepresentation, r_of_zeta, zeta_of_r +from sample_scf.representation import r_of_zeta, zeta_of_r __all__ = ["interpolated_r_distribution"] @@ -49,7 +49,7 @@ class interpolated_r_distribution(r_distribution_base): _interp_in_zeta: bool def __init__(self, potential: SCFPotential, radii: Quantity, **kw: Any) -> None: - kw["a"], kw["b"] = 0, np.nanmax(radii) # allowed range of r + kw["a"], kw["b"] = 0, nanmax(radii) # allowed range of r super().__init__(potential, **kw) # fraction of total mass grid @@ -80,15 +80,15 @@ def calculate_cumulative_mass(self, radii: Quantity) -> NDArrayF: (R,) ndarray[float] """ rgalpy = radii.to_value(u.kpc) / self.potential._ro - mgrid = np.array([self.potential._mass(x) for x in rgalpy]) # :( + mgrid = array([self.potential._mass(x) for x in rgalpy]) # :( # manual fixes for endpoints and normalization - ind = np.where(np.isnan(mgrid))[0] + ind = where(isnan(mgrid))[0] mgrid[ind[radii[ind] == 0]] = 0 - mgrid = (mgrid - np.nanmin(mgrid)) / (np.nanmax(mgrid) - np.nanmin(mgrid)) # rescale - infind = ind[radii[ind] == np.inf].squeeze() + mgrid = (mgrid - nanmin(mgrid)) / (nanmax(mgrid) - nanmin(mgrid)) # rescale + infind = ind[radii[ind] == inf].squeeze() mgrid[infind] = 1 if mgrid[infind - 1] == 1: # munge the rescaling TODO! do better - mgrid[infind - 1] -= min(1e-8, np.diff(mgrid[slice(infind - 2, infind)]) / 2) + mgrid[infind - 1] -= min(1e-8, diff(mgrid[slice(infind - 2, infind)]) / 2) return mgrid diff --git a/sample_scf/interpolated/tests/test_interpolated.py b/sample_scf/interpolated/tests/test_interpolated.py index a7b8820..1539a44 100644 --- a/sample_scf/interpolated/tests/test_interpolated.py +++ b/sample_scf/interpolated/tests/test_interpolated.py @@ -9,26 +9,27 @@ # THIRD PARTY import astropy.units as u import matplotlib.pyplot as plt -import numpy as np import pytest from astropy.utils.misc import NumpyRNGContext +from numpy import allclose, concatenate, geomspace, inf, isclose, linspace, ndarray, pi, random from numpy.testing import assert_allclose # LOCAL from .common import phi_distributionTestBase, r_distributionTestBase, theta_distributionTestBase from .test_base import BaseTest_rv_potential, SCFSamplerTestBase +from sample_scf.base_univariate import _calculate_Qls, _calculate_Scs from sample_scf.interpolated import InterpolatedSCFSampler -from sample_scf.interpolated.azimuth import phi_distribution -from sample_scf.interpolated.inclination import theta_distribution -from sample_scf.interpolated.radial import r_distribution -from sample_scf.representation import x_of_theta +from sample_scf.interpolated.azimuth import interpolated_phi_distribution +from sample_scf.interpolated.inclination import interpolated_theta_distribution +from sample_scf.interpolated.radial import interpolated_r_distribution +from sample_scf.representation import r_of_zeta, x_of_theta, zeta_of_r ############################################################################## # PARAMETERS -rgrid = np.concatenate(([0], np.geomspace(1e-1, 1e3, 100), [np.inf])) -tgrid = np.linspace(-np.pi / 2, np.pi / 2, 30) -pgrid = np.linspace(0, 2 * np.pi, 30) +rgrid = concatenate(([0], geomspace(1e-1, 1e3, 100), [inf])) +tgrid = linspace(-pi / 2, pi / 2, 30) +pgrid = linspace(0, 2 * pi, 30) ############################################################################## @@ -72,7 +73,7 @@ def setup_class(self): ) def test_cdf(self, sampler, r, theta, phi, expected): """Test :meth:`sample_scf.base_multivariate.SCFSamplerBase.cdf`.""" - assert np.allclose(sampler.cdf(r, theta, phi), expected, atol=1e-16) + assert allclose(sampler.cdf(r, theta, phi), expected, atol=1e-16) # =============================================================== # Plot Tests @@ -104,7 +105,7 @@ def test_init(self, sampler): # ie that the splines are stable cdfk = sampler._spl_cdf.get_knots() ncdfk = newsampler._spl_cdf.get_knots() - if isinstance(cdfk, np.ndarray): # 1D splines + if isinstance(cdfk, ndarray): # 1D splines assert_allclose(ncdfk, cdfk, atol=1e-16) else: # 2D and 3D splines for k, nk in zip(cdfk, ncdfk): @@ -112,7 +113,7 @@ def test_init(self, sampler): ppfk = sampler._spl_ppf.get_knots() nppfk = newsampler._spl_ppf.get_knots() - if isinstance(ppfk, np.ndarray): # 1D splines + if isinstance(ppfk, ndarray): # 1D splines assert_allclose(nppfk, ppfk, atol=1e-16) else: # 2D and 3D splines for k, nk in zip(ppfk, nppfk): @@ -127,12 +128,12 @@ def test_init(self, sampler): class Test_r_distribution(r_distributionTestBase, InterpBaseTest_rv_potential): - """Test :class:`sample_scf.sample_interp.r_distribution`""" + """Test :class:`sample_scf.sample_interp.interpolated_r_distribution`""" def setup_class(self): super().setup_class(self) - self.cls = r_distribution + self.cls = interpolated_r_distribution self.cls_args = (rgrid,) self.cls_kwargs = {} self.cls_pot_kw = {} @@ -150,18 +151,18 @@ def test_init(self, sampler): # TODO! test mgrid endpoints, cdf, and ppf # TODO! use hypothesis - @pytest.mark.parametrize("r", np.random.default_rng(0).uniform(0, 1e4, 10)) + @pytest.mark.parametrize("r", random.default_rng(0).uniform(0, 1e4, 10)) def test__cdf(self, sampler, r): - """Test :meth:`sample_scf.interpolated.r_distribution._cdf`.""" + """Test :meth:`sample_scf.interpolated.interpolated_r_distribution._cdf`.""" super().test__cdf(sampler, r) # expected assert_allclose(sampler._cdf(r), sampler._spl_cdf(zeta_of_r(r))) # TODO! use hypothesis - @pytest.mark.parametrize("q", np.random.default_rng(0).uniform(0, 1, 10)) + @pytest.mark.parametrize("q", random.default_rng(0).uniform(0, 1, 10)) def test__ppf(self, sampler, q): - """Test :meth:`sample_scf.interpolated.r_distribution._ppf`.""" + """Test :meth:`sample_scf.interpolated.interpolated_r_distribution._ppf`.""" # expected assert_allclose(sampler._ppf(q), r_of_zeta(sampler._spl_ppf(q))) @@ -178,7 +179,7 @@ def test__ppf(self, sampler, q): ], ) def test_rvs(self, sampler, size, random, expected): - """Test :meth:`sample_scf.interpolated.r_distribution.rvs`.""" + """Test :meth:`sample_scf.interpolated.interpolated_r_distribution.rvs`.""" super().test_rvs(sampler, size, random, expected) # =============================================================== @@ -265,12 +266,12 @@ def test_interp_r_sampling_plot(self, sampler): class Test_theta_distribution(theta_distributionTestBase, InterpBaseTest_rv_potential): - """Test :class:`sample_scf.interpolated.theta_distribution`.""" + """Test :class:`sample_scf.interpolated.interpolated_theta_distribution`.""" def setup_class(self): super().setup_class(self) - self.cls = theta_distribution + self.cls = interpolated_theta_distribution self.cls_args = (rgrid, tgrid) self.cdf_time_scale = 3e-4 @@ -284,7 +285,7 @@ def test_init(self, sampler): super().test_init(sampler) # a shape mismatch - Qls = thetaQls(sampler._potential, rgrid[1:-1]) + Qls = _calculate_Qls(sampler._potential, rgrid[1:-1]) with pytest.raises(ValueError, match="Qls must be shape"): sampler.__class__(sampler._potential, rgrid, tgrid, Qls=Qls) @@ -293,31 +294,31 @@ def test_init(self, sampler): "x, zeta", [ *zip( - np.random.default_rng(0).uniform(-1, 1, 10), - np.random.default_rng(1).uniform(-1, 1, 10), + random.default_rng(0).uniform(-1, 1, 10), + random.default_rng(1).uniform(-1, 1, 10), ), ], ) def test__cdf(self, sampler, x, zeta): - """Test :meth:`sample_scf.interpolated.theta_distribution._cdf`.""" + """Test :meth:`sample_scf.interpolated.interpolated_theta_distribution._cdf`.""" # expected assert_allclose(sampler._cdf(x, zeta=zeta), sampler._spl_cdf(zeta, x, grid=False)) # args and kwargs don't matter assert_allclose(sampler._cdf(x, zeta=zeta), sampler._cdf(x, 10, zeta=zeta, test=14)) - @pytest.mark.parametrize("zeta", np.random.default_rng(0).uniform(-1, 1, 10)) + @pytest.mark.parametrize("zeta", random.default_rng(0).uniform(-1, 1, 10)) def test__cdf_edge(self, sampler, zeta): - """Test :meth:`sample_scf.interpolated.r_distribution._cdf`.""" - assert np.isclose(sampler._cdf(-1, zeta=zeta), 0.0, atol=1e-16) - assert np.isclose(sampler._cdf(1, zeta=zeta), 1.0, atol=1e-16) + """Test :meth:`sample_scf.interpolated.interpolated_theta_distribution._cdf`.""" + assert isclose(sampler._cdf(-1, zeta=zeta), 0.0, atol=1e-16) + assert isclose(sampler._cdf(1, zeta=zeta), 1.0, atol=1e-16) @pytest.mark.parametrize( "theta, r", [ *zip( - np.random.default_rng(0).uniform(-np.pi / 2, np.pi / 2, 10), - np.random.default_rng(1).uniform(0, 1e4, 10), + random.default_rng(0).uniform(-pi / 2, pi / 2, 10), + random.default_rng(1).uniform(0, 1e4, 10), ), ], ) @@ -326,7 +327,7 @@ def test_cdf(self, sampler, theta, r): assert_allclose( sampler.cdf(theta, r), sampler._spl_cdf( - FiniteSphericalRepresentation.calculate_zeta_of_r(r), + zeta_of_r(r), x_of_theta(u.Quantity(theta, u.rad)), grid=False, ), @@ -365,12 +366,12 @@ def test_interp_theta_cdf_plot(self, sampler): ) kw = dict(marker="o", ms=5, c="k", zorder=5, label="CDF") ax.plot(tgrid, sampler.cdf(tgrid, r=10), **kw) - ax.axvline(-np.pi / 2, c="tab:blue") - ax.axhline(sampler.cdf(-np.pi / 2, r=10), c="tab:blue", label=r"$\theta=-\frac{\pi}{2}$") + ax.axvline(-pi / 2, c="tab:blue") + ax.axhline(sampler.cdf(-pi / 2, r=10), c="tab:blue", label=r"$\theta=-\frac{\pi}{2}$") ax.axvline(0, c="tab:green") ax.axhline(sampler.cdf(0, r=10), c="tab:green", label=r"$\theta=0$") - ax.axvline(np.pi / 2, c="tab:red") - ax.axhline(sampler.cdf(np.pi / 2, r=10), c="tab:red", label=r"$\theta=\frac{\pi}{2}$") + ax.axvline(pi / 2, c="tab:red") + ax.axhline(sampler.cdf(pi / 2, r=10), c="tab:red", label=r"$\theta=\frac{\pi}{2}$") ax.legend(loc="lower right") ax = fig.add_subplot( @@ -402,7 +403,7 @@ def test_interp_theta_sampling_plot(self, sampler): sample = sample[sample < 1e4] theory = self.theory[sampler._potential].sample(n=int(1e6)).theta() - theory -= np.pi / 2 * u.rad # adjust range back + theory -= pi / 2 * u.rad # adjust range back fig = plt.figure(figsize=(10, 3)) ax = fig.add_subplot( @@ -434,12 +435,12 @@ def test_interp_theta_sampling_plot(self, sampler): class Test_phi_distribution(phi_distributionTestBase, InterpBaseTest_rv_potential): - """Test :class:`sample_scf.interpolated.phi_distribution`.""" + """Test :class:`sample_scf.interpolated.interpolated_phi_distribution`.""" def setup_class(self): super().setup_class(self) - self.cls = phi_distribution + self.cls = interpolated_phi_distribution self.cls_args = (rgrid, tgrid, pgrid) self.cdf_time_scale = 12e-4 @@ -449,37 +450,37 @@ def setup_class(self): # Method Tests def test_init(self, sampler): - """Test :meth:`sample_scf.interpolated.phi_distribution._cdf`.""" + """Test :meth:`sample_scf.interpolated.interpolated_phi_distribution._cdf`.""" # super().test_init(sampler) # doesn't work TODO! # a shape mismatch - Scs = phiScs(sampler._potential, rgrid[1:-1], tgrid[1:-1], warn=False) + Scs = _calculate_Scs(sampler._potential, rgrid[1:-1], tgrid[1:-1], warn=False) with pytest.raises(ValueError, match="Rm, Sm must be shape"): sampler.__class__(sampler._potential, rgrid, tgrid, pgrid, Scs=Scs) @pytest.mark.skip("TODO!") def test__cdf(self): - """Test :meth:`sample_scf.interpolated.phi_distribution._cdf`.""" + """Test :meth:`sample_scf.interpolated.interpolated_phi_distribution._cdf`.""" assert False @pytest.mark.skip("TODO!") def test_cdf(self): - """Test :meth:`sample_scf.interpolated.phi_distribution.cdf`.""" + """Test :meth:`sample_scf.interpolated.interpolated_phi_distribution.cdf`.""" assert False @pytest.mark.skip("TODO!") def test__ppf(self): - """Test :meth:`sample_scf.interpolated.phi_distribution._ppf`.""" + """Test :meth:`sample_scf.interpolated.interpolated_phi_distribution._ppf`.""" assert False @pytest.mark.skip("TODO!") def test__rvs(self): - """Test :meth:`sample_scf.interpolated.phi_distribution._rvs`.""" + """Test :meth:`sample_scf.interpolated.interpolated_phi_distribution._rvs`.""" assert False @pytest.mark.skip("TODO!") def test_rvs(self): - """Test :meth:`sample_scf.interpolated.phi_distribution.rvs`.""" + """Test :meth:`sample_scf.interpolated.interpolated_phi_distribution.rvs`.""" assert False # =============================================================== @@ -499,13 +500,13 @@ def test_interp_phi_cdf_plot(self, sampler): ylabel=r"CDF($\phi$)", ) kw = dict(marker="o", ms=5, c="k", zorder=5, label="CDF") - ax.plot(pgrid, sampler.cdf(pgrid, r=10, theta=np.pi / 6), **kw) + ax.plot(pgrid, sampler.cdf(pgrid, r=10, theta=pi / 6), **kw) ax.axvline(0, c="tab:blue") - ax.axhline(sampler.cdf(0, r=10, theta=np.pi / 6), c="tab:blue", label=r"$\phi=0$") - ax.axvline(np.pi, c="tab:green") - ax.axhline(sampler.cdf(np.pi, r=10, theta=np.pi / 6), c="tab:green", label=r"$\phi=\pi$") - ax.axvline(2 * np.pi, c="tab:red") - ax.axhline(sampler.cdf(2 * np.pi, r=10, theta=np.pi / 6), c="tab:red", label=r"$\phi=2\pi$") + ax.axhline(sampler.cdf(0, r=10, theta=pi / 6), c="tab:blue", label=r"$\phi=0$") + ax.axvline(pi, c="tab:green") + ax.axhline(sampler.cdf(pi, r=10, theta=pi / 6), c="tab:green", label=r"$\phi=\pi$") + ax.axvline(2 * pi, c="tab:red") + ax.axhline(sampler.cdf(2 * pi, r=10, theta=pi / 6), c="tab:red", label=r"$\phi=2\pi$") ax.legend(loc="lower right") fig.tight_layout() @@ -518,7 +519,7 @@ def test_interp_phi_cdf_plot(self, sampler): def test_interp_phi_sampling_plot(self, sampler): """Test sampling.""" with NumpyRNGContext(0): # control the random numbers - sample = sampler.rvs(size=int(1e6), r=10, theta=np.pi / 6) + sample = sampler.rvs(size=int(1e6), r=10, theta=pi / 6) sample = sample[sample < 1e4] theory = self.theory[sampler._potential].sample(n=int(1e6)).phi() diff --git a/sample_scf/representation.py b/sample_scf/representation.py index 14f643b..d5dfe9d 100644 --- a/sample_scf/representation.py +++ b/sample_scf/representation.py @@ -8,28 +8,19 @@ from __future__ import annotations # STDLIB -import warnings -from contextlib import nullcontext from functools import singledispatch from inspect import isclass -from typing import Optional, Tuple, Union, overload +from typing import Dict, Optional, Type, Union, overload # THIRD PARTY import astropy.units as u -import numpy as np -import numpy.typing as npt -from astropy.coordinates import ( - Angle, - BaseRepresentation, - CartesianRepresentation, - Distance, - PhysicsSphericalRepresentation, - SphericalRepresentation, - UnitSphericalRepresentation, -) -from astropy.units import Quantity, UnitConversionError, rad +from astropy.coordinates import Angle, BaseDifferential, BaseRepresentation +from astropy.coordinates import CartesianRepresentation, Distance, PhysicsSphericalRepresentation +from astropy.coordinates import SphericalRepresentation, UnitSphericalRepresentation +from astropy.units import Quantity, UnitConversionError from erfa import ufunc as erfa_ufunc -from numpy import arccos, array, atleast_1d, cos, divide, nan_to_num +from numpy import abs, all, any, arccos, arctan2, atleast_1d, cos, divide, floating, hypot +from numpy import isfinite, less, nan_to_num, ndarray, sin, isnan from numpy.typing import ArrayLike # LOCAL @@ -49,50 +40,44 @@ def _zeta_of_r( # Default implementation, unless there's a registered specific method. # -------------- # Checks: r must be non-negative, and the scale radius must be None or positive - if np.any(np.less(r, 0)): + if any(less(r, 0)): raise ValueError("r must be >= 0") - elif scale_radius is not None: - if isinstance(scale_radius, Quantity): - if scale_radius.unit.physical_type == "dimensionless": - scale_radius = scale_radius.value - else: - raise TypeError("scale radius cannot be a Quantity") - elif scale_radius <= 0: - raise ValueError("scale_radius must be > 0") + elif scale_radius is None: + scale_radius = 1 + elif not all(isfinite(scale_radius)) or scale_radius <= 0: + raise ValueError("scale_radius must be a finite number > 0") # Calculation - a: Quantity = scale_radius if scale_radius is not None else 1 - r_a: Quantity = np.divide(r, a) + r_a: Quantity = divide(r, scale_radius) # can be inf zeta: NDArrayF = nan_to_num(divide(r_a - 1, r_a + 1), nan=1.0) - # TODO! fix with degeneracy in NaN when not due to division. return zeta @overload @_zeta_of_r.register -def zeta_of_r(r: Quantity, /, scale_radius=None) -> NDArrayF: +def zeta_of_r(r: Quantity, /, scale_radius=None) -> NDArrayF: # type: ignore # Checks: r must be a non-negative length-type quantity, and the scale # radius must be None or a positive length-type quantity. - if r.unit.physical_type != "length": - raise UnitConversionError("r must have units of length") - elif np.any(r < 0): + if not isinstance(r, Quantity) or r.unit.physical_type != "length": + raise UnitConversionError("r must be a Quantity with units of 'length'") + elif any(isnan(r)) or any(r < 0): raise ValueError("r must be >= 0") elif scale_radius is not None: - if not isinstance(scale_radius, Quantity): - raise TypeError("scale_radius must be a Quantity") - if scale_radius.unit.physical_type != "length": - raise UnitConversionError("scale_radius must have units of length") - elif scale_radius <= 0: - raise ValueError("scale_radius must be > 0") - - a: Quantity = scale_radius if scale_radius is not None else 1 * r.unit - r_a: Quantity = r / a + if not isinstance(scale_radius, Quantity) or scale_radius.unit.physical_type != "length": + raise TypeError("scale_radius must be a Quantity with units of 'length'") + elif not isfinite(scale_radius) or scale_radius <= 0: + raise ValueError("scale_radius must be a finite number > 0") + else: + scale_radius = 1 * r.unit + + r_a: Quantity = r / scale_radius # can be inf zeta: NDArrayF = nan_to_num(divide(r_a - 1, r_a + 1), nan=1.0) - # TODO! fix with degeneracy in NaN when not due to division. return zeta.value -def zeta_of_r(r: Union[NDArrayF, Quantity], /, scale_radius: Optional[Quantity] = None) -> NDArrayF: +def zeta_of_r( + r: Union[NDArrayF, Quantity], /, scale_radius: Union[NDArrayF, Quantity, None] = None +) -> NDArrayF: r""":math:`\zeta(r) = \frac{r/a - 1}{r/a + 1}`. Map the half-infinite domain [0, infinity) -> [-1, 1]. @@ -130,7 +115,7 @@ def zeta_of_r(r: Union[NDArrayF, Quantity], /, scale_radius: Optional[Quantity] def r_of_zeta( - zeta: ndarray, /, scale_radius: Union[float, np.floating, Quantity, None] = None + zeta: ndarray, /, scale_radius: Union[float, floating, Quantity, None] = None ) -> Union[NDArrayF, Quantity]: r""":math:`r = \frac{1 + \zeta}{1 - \zeta}`. @@ -159,24 +144,29 @@ def r_of_zeta( RuntimeWarning If zeta is 1 (r is `numpy.inf`). Don't worry, it's not a problem. """ - if np.any(zeta < -1) or np.any(zeta > 1): + if any(zeta < -1) or any(zeta > 1): raise ValueError("zeta must be in [-1, 1].") - elif scale_radius <= 0 or not np.isfinite(scale_radius): + elif scale_radius is None: + scale_radius = 1 + elif scale_radius <= 0 or not isfinite(scale_radius): raise ValueError("scale_radius must be in (0, inf).") - elif isinstance(scale_radius, Quantity) and scale_radius.unit.physical_type != "length": + elif ( + isinstance(scale_radius, Quantity) + and scale_radius.unit.physical_type != "length" # type: ignore + ): raise UnitConversionError("scale_radius must have units of length") r: NDArrayF = atleast_1d(divide(1 + zeta, 1 - zeta)) r[r < 0] = 0 # correct small errors rq: Union[NDArrayF, Quantity] - rq = r * scale_radius if scale_radius is not None else r + rq = scale_radius * r return rq # ------------------------------------------------------------------- -def x_of_theta(theta: Union[ndarray, Quantity["angle"]]) -> NDArrayF: +def x_of_theta(theta: Union[ndarray, Quantity["angle"]]) -> NDArrayF: # type: ignore r""":math:`x = \cos{\theta}`. Parameters @@ -224,13 +214,7 @@ class FiniteSphericalRepresentation(BaseRepresentation): .. math:: - \zeta = \frac{1 - r / a}{1 + r/a} - x = \cos(\theta) - - .. todo:: - - Make the scale radius optional by not decomposing, so zeta = 1 [unit] / [scale unit] (ie. dimensionless **scaled**) - Unles the scale radius is passed, in which case decompose to dimensionless_unscaled + \zeta = \frac{1 - r / a}{1 + r/a} x = \cos(\theta) """ _phi: Quantity @@ -250,11 +234,11 @@ def __init__( copy: bool = True, ): # Adjustments if passing unitful quantities - if hasattr(x, "unit") and x.unit.physical_type == "angle": + if isinstance(x, Quantity) and x.unit.physical_type == "angle": # type: ignore x = x_of_theta(x) - if hasattr(zeta, "unit") and zeta.unit.physical_type == "length": + if isinstance(zeta, Quantity) and zeta.unit.physical_type == "length": # type: ignore if scale_radius is None: - scale_radius = 1 * zeta.unit + scale_radius = 1 * zeta.unit # type: ignore zeta = zeta_of_r(zeta, scale_radius=scale_radius) elif scale_radius is None: raise ValueError("if zeta is not a length, a scale_radius must given") @@ -266,10 +250,10 @@ def __init__( # Note that _phi already holds our own copy if copy=True. self._phi.wrap_at(360 * u.deg, inplace=True) - if np.any(self._x < -1) or np.any(self._x > 1): + if any(self._x < -1) or any(self._x > 1): raise ValueError(f"inclination angle(s) must be within -1 <= angle <= 1, got {x}") - if np.any(self._zeta < -1) or np.any(self._zeta > 1): + if any(self._zeta < -1) or any(self._zeta > 1): raise ValueError(f"distances must be within -1 <= zeta <= 1, got {zeta}") @property @@ -377,8 +361,8 @@ def calculate_theta_of_x(self, x: ArrayLike) -> Quantity: # ----------------------------------------------------- # def unit_vectors(self): - # sinphi, cosphi = np.sin(self.phi), np.cos(self.phi) - # sintheta, x = np.sin(self.theta), self.x + # sinphi, cosphi = sin(self.phi), cos(self.phi) + # sintheta, x = sin(self.theta), self.x # return { # "phi": CartesianRepresentation(-sinphi, cosphi, 0.0, copy=False), # "theta": CartesianRepresentation(x * cosphi, x * sinphi, -sintheta, copy=False), @@ -388,8 +372,8 @@ def calculate_theta_of_x(self, x: ArrayLike) -> Quantity: # TODO! # def scale_factors(self): # r = self.r / u.radian - # sintheta = np.sin(self.theta) - # l = np.broadcast_to(1.*u.one, self.shape, subok=True) + # sintheta = sin(self.theta) + # l = broadcast_to(1.*u.one, self.shape, subok=True) # return {'phi': r * sintheta, # 'theta': r, # 'r': l} @@ -428,9 +412,9 @@ def to_cartesian(self): # We need to convert Distance to Quantity to allow negative values. d = self.r.view(Quantity) - x = d * np.sin(self.theta) * np.cos(self.phi) - y = d * np.sin(self.theta) * np.sin(self.phi) - z = d * np.cos(self.theta) + x = d * sin(self.theta) * cos(self.phi) + y = d * sin(self.theta) * sin(self.phi) + z = d * cos(self.theta) return CartesianRepresentation(x=x, y=y, z=z, copy=False) @@ -440,11 +424,11 @@ def from_cartesian(cls, cart, scale_radius: Optional[Quantity] = None): Converts 3D rectangular cartesian coordinates to spherical polar coordinates. """ - s = np.hypot(cart.x, cart.y) - r = np.hypot(s, cart.z) + s = hypot(cart.x, cart.y) + r = hypot(s, cart.z) - phi = np.arctan2(cart.y, cart.x) << u.rad - theta = np.arctan2(s, cart.z) << u.rad + phi = arctan2(cart.y, cart.x) << u.rad + theta = arctan2(s, cart.z) << u.rad return cls(phi=phi, x=theta, zeta=r, scale_radius=scale_radius, copy=False) @@ -501,4 +485,4 @@ def norm(self): norm : `astropy.units.Quantity` Vector norm, with the same shape as the representation. """ - return np.abs(self.zeta) + return abs(self.zeta) diff --git a/sample_scf/tests/base.py b/sample_scf/tests/base.py index 0719a11..c190c57 100644 --- a/sample_scf/tests/base.py +++ b/sample_scf/tests/base.py @@ -5,22 +5,15 @@ # IMPORTS # STDLIB -import inspect import time from abc import ABCMeta, abstractmethod # THIRD PARTY -import astropy.coordinates as coord -import astropy.units as u -import numpy as np import pytest -from astropy.utils.misc import NumpyRNGContext from galpy.potential import KeplerPotential -from numpy.testing import assert_allclose -from scipy.stats import rv_continuous +from numpy import linspace # LOCAL -from sample_scf.base_univariate import rv_potential from sample_scf.conftest import _hernquist_scf_potential ############################################################################## @@ -41,7 +34,10 @@ def potential(self, request): potential = _hernquist_scf_potential elif request.param == "nfw_scf_potential": # potential = nfw_scf_potential.__wrapped__() - pass + raise NotImplementedError + else: + raise NotImplementedError + yield potential @pytest.fixture(scope="class") @@ -95,7 +91,7 @@ def rvs_kw(self): # time-scale tests def cdf_time_arr(self, size): - return np.linspace(0, 1e4, size) + return linspace(0, 1e4, size) @pytest.fixture(scope="class") def cdf_time_scale(self): diff --git a/sample_scf/tests/test_base_multivariate.py b/sample_scf/tests/test_base_multivariate.py index a92b4b2..dd043dc 100644 --- a/sample_scf/tests/test_base_multivariate.py +++ b/sample_scf/tests/test_base_multivariate.py @@ -7,27 +7,22 @@ # IMPORTS # STDLIB -import inspect -import time -from abc import ABCMeta, abstractmethod +from abc import abstractmethod # THIRD PARTY import astropy.units as u -import numpy as np import pytest from astropy.coordinates import BaseRepresentation, PhysicsSphericalRepresentation +from astropy.utils.misc import NumpyRNGContext from galpy.potential import SCFPotential +from numpy import ndarray, shape, squeeze, atleast_1d, allclose from numpy.testing import assert_allclose # LOCAL from .base import BaseTest_Sampler -from .test_base_univariate import phis, radii, rvtestsampler, thetas -from sample_scf import conftest -from sample_scf.base_univariate import ( - phi_distribution_base, - r_distribution_base, - theta_distribution_base, -) +from .test_base_univariate import rvtestsampler +from sample_scf.base_univariate import phi_distribution_base, r_distribution_base +from sample_scf.base_univariate import theta_distribution_base ############################################################################## # TESTS @@ -123,7 +118,7 @@ def test_cdf(self, sampler, position, expected): """Test cdf method.""" cdf = sampler.cdf(size=size, *position) - assert isinstance(cdf, np.ndarray) + assert isinstance(cdf, ndarray) assert False assert_allclose(cdf, expected, atol=1e-16) @@ -167,9 +162,9 @@ def sampler(self, potential): """Set up r, theta, phi sampler.""" super().sampler(potential) - sampler._r_distribution = rvtestsampler(potentials) - sampler._theta_distribution = rvtestsampler(potentials) - sampler._phi_distribution = rvtestsampler(potentials) + sampler._r_distribution = rvtestsampler(potential) + sampler._theta_distribution = rvtestsampler(potential) + sampler._phi_distribution = rvtestsampler(potential) return sampler @@ -187,10 +182,10 @@ def sampler(self, potential): def test_cdf(self, sampler, r, theta, phi, expected): """Test :meth:`sample_scf.base_multivariate.SCFSamplerBase.cdf`.""" cdf = sampler.cdf(r, theta, phi) - assert np.allclose(cdf, expected, atol=1e-16) + assert allclose(cdf, expected, atol=1e-16) # also test shape - assert tuple(np.atleast_1d(np.squeeze((*np.shape(r), 3)))) == cdf.shape + assert tuple(atleast_1d(squeeze((*shape(r), 3)))) == cdf.shape @pytest.mark.parametrize( "id, size, random, vectorized", diff --git a/sample_scf/tests/test_base_univariate.py b/sample_scf/tests/test_base_univariate.py index 18a1c54..d7e3728 100644 --- a/sample_scf/tests/test_base_univariate.py +++ b/sample_scf/tests/test_base_univariate.py @@ -8,16 +8,11 @@ # STDLIB import inspect -import time -from abc import ABCMeta, abstractmethod +from abc import abstractmethod # THIRD PARTY -import astropy.coordinates as coord -import astropy.units as u -import numpy as np import pytest from astropy.utils.misc import NumpyRNGContext -from galpy.potential import KeplerPotential from numpy.testing import assert_allclose from scipy.stats import rv_continuous @@ -25,14 +20,22 @@ from .base import BaseTest_Sampler from .data import results from sample_scf.base_univariate import r_distribution_base, rv_potential -from sample_scf.conftest import _hernquist_scf_potential +from numpy import concatenate, geomspace, inf, pi, linspace, random, atleast_1d + +# import time +# from abc import ABCMeta +# from galpy.potential import KeplerPotential +# from sample_scf.conftest import _hernquist_scf_potential +# import astropy.coordinates as coord +# import astropy.units as u + ############################################################################## # PARAMETERS -radii = np.concatenate(([0], np.geomspace(1e-1, 1e3, 28), [np.inf])) # same shape as ↓ -thetas = np.linspace(0, np.pi, 30) -phis = np.linspace(0, 2 * np.pi, 30) +radii = concatenate(([0], geomspace(1e-1, 1e3, 28), [inf])) # same shape as ↓ +thetas = linspace(0, pi, 30) +phis = linspace(0, 2 * pi, 30) ############################################################################## @@ -86,7 +89,7 @@ def test_lmax_property(self, sampler): @abstractmethod def test_cdf(self, sampler, position, expected): """Test cdf method.""" - assert_allclose(sampler.cdf(size=size, *position), expected, atol=1e-16) + assert_allclose(sampler.cdf(size=len(expected), *position), expected, atol=1e-16) @abstractmethod def test_rvs(self, sampler, size, random, expected): @@ -114,9 +117,9 @@ def _cdf(self, x, *args, **kwargs): def _rvs(self, *args, size=None, random_state=None): if random_state is None: - random_state = np.random + random_state = random - return np.atleast_1d(random_state.uniform(size=size)) + return atleast_1d(random_state.uniform(size=size)) class Test_rv_potential(BaseTest_rv_potential): @@ -188,17 +191,18 @@ class BaseTest_theta_distribution_base(BaseTest_rv_potential): @pytest.fixture(scope="class") @abstractmethod def rv_cls(self): - return theta_distribution_base + # return theta_distribution_base + raise NotImplementedError def cdf_time_arr(self, size: int): - return np.linspace(0, np.pi, size) + return linspace(0, pi, size) # =============================================================== # Method Tests def test_init_attrs(self, sampler): """Test attributes set at initialization.""" - super().test_init_attrs(sampler) + # super().test_init_attrs(sampler) assert hasattr(sampler, "_lrange") assert min(sampler._lrange) == 0 @@ -219,17 +223,19 @@ class BaseTest_phi_distribution_base(BaseTest_rv_potential): @pytest.fixture(scope="class") @abstractmethod def rv_cls(self): - return theta_distribution_base + # return theta_distribution_base + raise NotImplementedError def cdf_time_arr(self, size: int): - return np.linspace(0, 2 * np.pi, size) + return linspace(0, 2 * pi, size) # =============================================================== # Method Tests def test_init_attrs(self, sampler): """Test attributes set at initialization.""" - super().test_init_attrs(sampler) + # super().test_init_attrs(sampler) + raise NotImplementedError # l-range assert hasattr(sampler, "_lrange") diff --git a/sample_scf/tests/test_conftest.py b/sample_scf/tests/test_conftest.py index 49f9ebd..b02ff2c 100644 --- a/sample_scf/tests/test_conftest.py +++ b/sample_scf/tests/test_conftest.py @@ -32,9 +32,9 @@ # @abc.abstractmethod # def setup_class(self): # """Setup fixtures for testing.""" -# self.R = np.linspace(0.0, 3.0, num=1001) +# self.R = linspace(0.0, 3.0, num=1001) # self.atol = 1e-6 -# self.restrict_ind = np.ones(1001, dtype=bool) +# self.restrict_ind = ones(1001, dtype=bool) # # @pytest.fixture(scope="class") # @abc.abstractmethod @@ -45,11 +45,11 @@ # def compare_to_theory(self, theory, scf, atol=1e-6): # # test that where theory is finite they match and where it's infinite, # # the scf is NaN -# fnt = ~np.isinf(theory) +# fnt = ~isinf(theory) # ind = self.restrict_ind & fnt # -# assert np.allclose(theory[ind], scf[ind], atol=atol) -# assert np.all(np.isnan(scf[~fnt])) +# assert allclose(theory[ind], scf[ind], atol=atol) +# assert all(isnan(scf[~fnt])) # # # =============================================================== # # sanity checks diff --git a/sample_scf/tests/test_core.py b/sample_scf/tests/test_core.py index 6639397..9f02858 100644 --- a/sample_scf/tests/test_core.py +++ b/sample_scf/tests/test_core.py @@ -8,13 +8,14 @@ # THIRD PARTY import astropy.units as u -import numpy as np import pytest # LOCAL from .test_base_multivariate import BaseTest_SCFSamplerBase from sample_scf import SCFSampler from sample_scf.exact import exact_phi_distribution, exact_r_distribution, exact_theta_distribution +from sample_scf.core import MethodsMapping +from numpy import allclose, atleast_1d, squeeze, shape ############################################################################## # TESTS @@ -78,7 +79,7 @@ def setup_class(self): def test_cdf(self, sampler, r, theta, phi, expected): """Test :meth:`sample_scf.base_multivariate.SCFSamplerBase.cdf`.""" cdf = sampler.cdf(r, theta, phi) - assert np.allclose(cdf, expected, atol=1e-16) + assert allclose(cdf, expected, atol=1e-16) # also test shape - assert tuple(np.atleast_1d(np.squeeze((*np.shape(r), 3)))) == cdf.shape + assert tuple(atleast_1d(squeeze((*shape(r), 3)))) == cdf.shape diff --git a/sample_scf/tests/test_representation.py b/sample_scf/tests/test_representation.py index deb6c41..4fa7165 100644 --- a/sample_scf/tests/test_representation.py +++ b/sample_scf/tests/test_representation.py @@ -12,25 +12,15 @@ # THIRD PARTY import astropy.units as u -import numpy as np import pytest -from astropy.coordinates import ( - CartesianRepresentation, - Distance, - PhysicsSphericalRepresentation, - SphericalRepresentation, - UnitSphericalRepresentation, -) +from astropy.coordinates import CartesianRepresentation, Distance, PhysicsSphericalRepresentation +from astropy.coordinates import SphericalRepresentation, UnitSphericalRepresentation from astropy.units import Quantity, UnitConversionError, allclose +from numpy import eye, pi, sin, cos, ndarray, inf, array, atleast_1d # LOCAL -from sample_scf.representation import ( - FiniteSphericalRepresentation, - r_of_zeta, - theta_of_x, - x_of_theta, - zeta_of_r, -) +from sample_scf.representation import FiniteSphericalRepresentation, r_of_zeta, theta_of_x +from sample_scf.representation import x_of_theta, zeta_of_r ############################################################################## # TESTS @@ -46,7 +36,6 @@ def setup_class(self): self.phi = 0 * u.rad self.x = 0 self.zeta = 0 - self.scale_radius = 8 * u.kpc @pytest.fixture def rep_cls(self): @@ -282,9 +271,9 @@ def test_to_cartesian(self, rep): """Test :meth:`sample_scf.FiniteSphericalRepresentation.to_cartesian`.""" r = rep.to_cartesian() - x = rep.r * np.sin(rep.theta) * np.cos(rep.phi) - y = rep.r * np.sin(rep.theta) * np.sin(rep.phi) - z = rep.r * np.cos(rep.theta) + x = rep.r * sin(rep.theta) * cos(rep.phi) + y = rep.r * sin(rep.theta) * sin(rep.phi) + z = rep.r * cos(rep.theta) assert allclose(r.x, x) assert allclose(r.y, y) @@ -319,22 +308,22 @@ def test_from_physicsspherical(self, rep_cls, rep, scale_radius): def test_transform(self, rep, scale_radius): """Test :meth:`sample_scf.FiniteSphericalRepresentation.transform`.""" # Identity - matrix = np.eye(3) + matrix = eye(3) r = rep.transform(matrix, scale_radius) assert allclose(rep.phi, r.phi) assert allclose(rep.theta, r.theta) assert allclose(rep.zeta, r.zeta) # alternating coordinates - matrix = np.array([[0, 1, 0], [1, 0, 0], [0, 0, 1]]) + matrix = array([[0, 1, 0], [1, 0, 0], [0, 0, 1]]) r = rep.transform(matrix, scale_radius) - assert allclose(rep.phi, r.phi - np.pi / 2 * u.rad) + assert allclose(rep.phi, r.phi - pi / 2 * u.rad) assert allclose(rep.theta, r.theta) assert allclose(rep.zeta, r.zeta) def test_norm(self, rep): """Test :meth:`sample_scf.FiniteSphericalRepresentation.norm`.""" - assert rep.norm() == np.abs(rep.zeta) + assert rep.norm() == abs(rep.zeta) ############################################################################## @@ -360,10 +349,10 @@ def test_zeta_of_r_fail(): [ (0, None, -1.0, False), (1, None, 0.0, False), - (np.inf, None, 1.0, RuntimeWarning), # edge case + (inf, None, 1.0, RuntimeWarning), # edge case (10, None, 9 / 11, False), - ([0, 1, np.inf], None, [-1.0, 0.0, 1.0], False), - ([0, 1, np.inf], None, [-1.0, 0.0, 1.0], False), + ([0, 1, inf], None, [-1.0, 0.0, 1.0], False), + ([0, 1, inf], None, [-1.0, 0.0, 1.0], False), ], ) def test_zeta_of_r_ArrayLike(r, scale_radius, expected, warns): @@ -403,10 +392,10 @@ def test_zeta_of_r_Quantity_fail(): [ (0 * u.kpc, None, -1.0, False), (1 * u.kpc, None, 0.0, False), - (np.inf * u.kpc, None, 1.0, RuntimeWarning), # edge case + (inf * u.kpc, None, 1.0, RuntimeWarning), # edge case (10 * u.km, None, 9 / 11, False), - ([0, 1, np.inf] * u.kpc, None, [-1.0, 0.0, 1.0], False), - ([0, 1, np.inf] * u.km, None, [-1.0, 0.0, 1.0], False), + ([0, 1, inf] * u.kpc, None, [-1.0, 0.0, 1.0], False), + ([0, 1, inf] * u.km, None, [-1.0, 0.0, 1.0], False), ], ) def test_zeta_of_r_Quantity(r, scale_radius, expected, warns): @@ -419,7 +408,7 @@ def test_zeta_of_r_Quantity(r, scale_radius, expected, warns): assert zeta.unit.physical_type == "dimensionless" -@pytest.mark.parametrize("r", [0 * u.kpc, 1 * u.kpc, np.inf * u.kpc, [0, 1, np.inf] * u.kpc]) +@pytest.mark.parametrize("r", [0 * u.kpc, 1 * u.kpc, inf * u.kpc, [0, 1, inf] * u.kpc]) def test_zeta_of_r_roundtrip(r): """Test zeta and r round trip. Note that Quantities don't round trip.""" assert allclose(r_of_zeta(zeta_of_r(r, None), 1), r.value) @@ -434,8 +423,8 @@ def test_zeta_of_r_roundtrip(r): [ (-1.0, 0, False), (0.0, 1, False), - (1.0, np.inf, RuntimeWarning), # edge case - (np.array([-1.0, 0.0, 1.0]), [0, 1, np.inf], False), + (1.0, inf, RuntimeWarning), # edge case + (array([-1.0, 0.0, 1.0]), [0, 1, inf], False), ], ) def test_r_of_zeta(zeta, expected, warns): @@ -444,7 +433,7 @@ def test_r_of_zeta(zeta, expected, warns): r = r_of_zeta(zeta, 1) assert allclose(r, expected) # TODO! scale_radius - assert isinstance(r, np.ndarray) + assert isinstance(r, ndarray) def test_r_of_zeta_fail(): @@ -483,14 +472,14 @@ def test_r_of_zeta_roundtrip(zeta): "theta, expected", [ (0, 1), - (np.pi / 2, 0), - (np.pi, -1), - ([0, np.pi / 2, np.pi], [1, 0, -1]), # array + (pi / 2, 0), + (pi, -1), + ([0, pi / 2, pi], [1, 0, -1]), # array # with units (0 << u.rad, 1), - (np.pi / 2 << u.rad, 0), - (np.pi << u.rad, -1), - ([np.pi, np.pi / 2, 0] << u.rad, [-1, 0, 1]), # array + (pi / 2 << u.rad, 0), + (pi << u.rad, -1), + ([pi, pi / 2, 0] << u.rad, [-1, 0, 1]), # array ], ) def test_x_of_theta(theta, expected): @@ -498,7 +487,7 @@ def test_x_of_theta(theta, expected): assert allclose(x_of_theta(theta), expected, atol=1e-16) -@pytest.mark.parametrize("theta", [0, np.pi / 2, np.pi, [0, np.pi / 2, np.pi]]) # TODO! units +@pytest.mark.parametrize("theta", [0, pi / 2, pi, [0, pi / 2, pi]]) # TODO! units def test_theta_of_x_roundtrip(theta): """Test theta and x round trip. Note that Quantities don't round trip.""" assert allclose(theta_of_x(x_of_theta(theta)), theta << u.rad) @@ -510,10 +499,10 @@ def test_theta_of_x_roundtrip(theta): @pytest.mark.parametrize( "x, expected", [ - (-1, np.pi), - (0, np.pi / 2), + (-1, pi), + (0, pi / 2), (1, 0), - ([-1, 0, 1], [np.pi, np.pi / 2, 0]), # array + ([-1, 0, 1], [pi, pi / 2, 0]), # array ], ) def test_theta_of_x(x, expected): diff --git a/sample_scf/utils.py b/sample_scf/utils.py new file mode 100644 index 0000000..650ca2e --- /dev/null +++ b/sample_scf/utils.py @@ -0,0 +1,184 @@ +# -*- coding: utf-8 -*- + +"""Local Utilities.""" + + +__all__ = ["plot_corner_samples", "log_prior", "log_prob"] + + +############################################################################## +# IMPORTS + +# STDLIB +from typing import Optional, Tuple, Union + +# THIRD PARTY +import astropy.units as u +import corner +from astropy.coordinates import BaseRepresentation, CartesianRepresentation +from galpy.potential import SCFPotential + +# PROJECT-SPECIFIC +from matplotlib.figure import Figure +from numpy import abs, arctan2, floating, inf, isfinite, log, nan_to_num, ndarray, sign, sqrt +from numpy import square, sum + +############################################################################## +# CODE +############################################################################## + + +def plot_corner_samples( + samples: Union[BaseRepresentation, ndarray], + r_limit: float = 1_000.0, + *, + figs: Optional[Tuple[Figure, Figure]] = None, + include_log: bool = True, + **kw, +) -> Tuple[Figure, Figure]: + """Plot samples. + + Parameters + ---------- + *samples : BaseRepresentation or (N, 3) ndarray + If an `numpy.ndarray`, samples should be in Cartesian coordinates. + r_limit : float + Largerst radius that should be plotted. + Values larger will be masked. + figs : tuple[Figure, Figure] or None, optional keyword-only + include_log : bool, optional keyword-only + + Returns + ------- + tuple[Figure, Figure] + """ + # Convert to ndarray + arr: ndarray + if isinstance(samples, BaseRepresentation): + arr = samples.represent_as(CartesianRepresentation)._values.view(float).reshape(-1, 3) + else: + arr = samples + + # Correcting for large r + r = sqrt(sum(square(arr), axis=1)) + mask = r <= r_limit + + # plot stuff + truths = [0, 0, 0] + hist_kwargs = {"density": True} + hist_kwargs.update(kw.pop("hist_kwargs", {})) + kw.pop("plot_contours", None) + kw.pop("plot_density", None) + + # ----------- + # normal plot + + labels = ["x", "y", "z"] + + fig1 = corner.corner( + arr[mask, :], + labels=labels, + raster=True, + bins=50, + truths=truths, + show_titles=True, + title_kwargs={"fontsize": 12}, + label_kwargs={"fontsize": 13}, + hist_kwargs=hist_kwargs, + plot_contours=False, + plot_density=False, + fig=None if figs is None else figs[0], + **kw, + ) + fig1.suptitle("Samples") + + # ----------- + # logarithmic plot + + if not include_log: + fig2 = None + + else: + + labels = [r"$\log_{10}(x)$", r"$\log_{10}(y)$", r"$\log_{10}(z)$"] + + fig2 = corner.corner( + nan_to_num(sign(arr) * log(abs(arr))), + labels=labels, + raster=True, + bins=50, + truths=truths, + show_titles=True, + title_kwargs={"fontsize": 12}, + label_kwargs={"fontsize": 13}, + fig=None if figs is None else figs[1], + plot_contours=False, + plot_density=False, + hist_kwargs=hist_kwargs, + **kw, + ) + fig2.suptitle("Samples") + + return fig1, fig2 + + +def log_prior(R: floating, r_limit: floating) -> floating: + """Log-Prior. + + Parameters + ---------- + R : float + r_limit : float + + Returns + ------- + float + """ + # outside + if r_limit is not None and R > r_limit: + return -inf + return 0.0 + + +def log_prob( + x: ndarray, /, pot: SCFPotential, rho0: u.Quantity, r_limit: Optional[floating] = None +) -> floating: + """Log-Probability. + + Parameters + ---------- + x : (3, ) array + Cartesian coordinates in kpc + pot : `galpy.potential.SCFPotential` + rho0 : Quantity + The central density. + r_limit : float + + Returns + ------- + float + """ + # Checks + if rho0 == 0: + raise ValueError("`mtot` cannot be 0.") + elif r_limit == 0: + raise ValueError("`r_limit` cannot be 0.") + + # convert Cartesian to Cylindrical coordinates + R = sqrt(sum(square(x))) + z = x[-1] + phi = arctan2(x[1], x[0]) + + # calculate log-prior + lp = log_prior(R, r_limit) + if not isfinite(lp): + return lp + + # the density as a likelihood + logrho0 = log(rho0.value) + dens = pot.dens(R, z, phi).to_value(rho0.unit) + + logdens = nan_to_num(log(dens), copy=False, nan=logrho0, posinf=logrho0) + ll = logdens - logrho0 # normalize the density + + return lp + ll diff --git a/tox.ini b/tox.ini index 9f2f175..f09cdea 100644 --- a/tox.ini +++ b/tox.ini @@ -13,6 +13,7 @@ isolated_build = true indexserver = NIGHTLY = https://pypi.anaconda.org/scipy-wheels-nightly/simple + [testenv] # Suppress display of matplotlib plots generated during docs build setenv = MPLBACKEND=agg @@ -69,6 +70,7 @@ commands = cov: pytest --pyargs sample_scf {toxinidir}/docs --cov sample_scf --cov-config={toxinidir}/setup.cfg {posargs} cov: coverage xml -o {toxinidir}/coverage.xml + [testenv:build_docs] changedir = docs description = invoke sphinx-build to build the HTML docs @@ -77,6 +79,7 @@ commands = pip freeze sphinx-build -W -b html . _build/html + [testenv:linkcheck] changedir = docs description = check the links in the HTML docs @@ -85,9 +88,17 @@ commands = pip freeze sphinx-build -W -b linkcheck . _build/html + [testenv:codestyle] skip_install = true changedir = . description = check code style, e.g. with flake8 deps = flake8 commands = flake8 sample_scf --count --max-line-length=100 + + +[flake8] +max-line-length = 100 +ignore = + E203, # space before colon + W503 # line break before binary operator