Source code for gala.potential.potential.builtin.core

""" Built-in potentials implemented in Cython """

# HACK: This hack brought to you by a bug in cython, and a solution from here:
# https://stackoverflow.com/questions/57138496/class-level-classmethod-can-only-be-called-on-a-method-descriptor-or-instance
try:
    myclassmethod = __builtins__.classmethod
except AttributeError:
    myclassmethod = __builtins__["classmethod"]

# Third-party
import astropy.units as u
import numpy as np
from astropy.constants import G

from gala.potential.common import PotentialParameter
from gala.potential.potential.builtin.cybuiltin import (
    BurkertWrapper,
    CylSplineWrapper,
    FlattenedNFWWrapper,
    HenonHeilesWrapper,
    HernquistWrapper,
    IsochroneWrapper,
    JaffeWrapper,
    KeplerWrapper,
    KuzminWrapper,
    LeeSutoTriaxialNFWWrapper,
    LogarithmicWrapper,
    LongMuraliBarWrapper,
    MiyamotoNagaiWrapper,
    MN3ExponentialDiskWrapper,
    MultipoleWrapper,
    NullWrapper,
    PlummerWrapper,
    PowerLawCutoffWrapper,
    SatohWrapper,
    SphericalNFWWrapper,
    StoneWrapper,
    TriaxialNFWWrapper,
)

# Project
from ..core import PotentialBase, _potential_docstring
from ..cpotential import CPotentialBase
from ..util import format_doc, sympy_wrap

__all__ = [
    "NullPotential",
    "HenonHeilesPotential",
    "KeplerPotential",
    "HernquistPotential",
    "IsochronePotential",
    "PlummerPotential",
    "JaffePotential",
    "StonePotential",
    "PowerLawCutoffPotential",
    "SatohPotential",
    "KuzminPotential",
    "MiyamotoNagaiPotential",
    "MN3ExponentialDiskPotential",
    "NFWPotential",
    "LeeSutoTriaxialNFWPotential",
    "LogarithmicPotential",
    "LongMuraliBarPotential",
    "MultipolePotential",
    "CylSplinePotential",
    "BurkertPotential",
]


def __getattr__(name):
    if name in __all__ and name in globals():
        return globals()[name]

    if not (name.startswith("MultipolePotentialLmax")):
        raise AttributeError(f"Module {__name__!r} has no attribute {name!r}.")

    if name in mp_cache:
        return mp_cache[name]

    else:
        try:
            lmax = int(name.split("Lmax")[1])
        except Exception:
            raise ImportError("Invalid")  # shouldn't ever get here!

        return make_multipole_cls(lmax, timedep="TimeDependent" in name)


[docs] @format_doc(common_doc=_potential_docstring) class HenonHeilesPotential(CPotentialBase): r""" The Hénon-Heiles potential. Parameters ---------- {common_doc} """ ndim = 2 Wrapper = HenonHeilesWrapper
[docs] @myclassmethod @sympy_wrap(var="x y") def to_sympy(cls, v, p): expr = ( 1.0 / 2 * ( v["x"] ** 2 + v["y"] ** 2 + 2 * v["x"] ** 2 * v["y"] - 2.0 / 3 * v["y"] ** 3 ) ) return expr, v, p
# ============================================================================ # Spherical models #
[docs] @format_doc(common_doc=_potential_docstring) class KeplerPotential(CPotentialBase): r""" The Kepler potential for a point mass. Parameters ---------- m : :class:`~astropy.units.Quantity`, numeric [mass] Point mass value. {common_doc} """ m = PotentialParameter("m", physical_type="mass") Wrapper = KeplerWrapper
[docs] @myclassmethod @sympy_wrap def to_sympy(cls, v, p): import sympy as sy r = sy.sqrt(v["x"] ** 2 + v["y"] ** 2 + v["z"] ** 2) expr = -p["G"] * p["m"] / r return expr, v, p
[docs] @format_doc(common_doc=_potential_docstring) class IsochronePotential(CPotentialBase): r""" The Isochrone potential. Parameters ---------- m : :class:`~astropy.units.Quantity`, numeric [mass] Mass. b : :class:`~astropy.units.Quantity`, numeric [length] Core concentration. {common_doc} """ m = PotentialParameter("m", physical_type="mass") b = PotentialParameter("b", physical_type="length") Wrapper = IsochroneWrapper
[docs] @myclassmethod @sympy_wrap def to_sympy(cls, v, p): import sympy as sy r = sy.sqrt(v["x"] ** 2 + v["y"] ** 2 + v["z"] ** 2) expr = -p["G"] * p["m"] / (sy.sqrt(r**2 + p["b"] ** 2) + p["b"]) return expr, v, p
[docs] def action_angle(self, w): """ Transform the input cartesian position and velocity to action-angle coordinates the Isochrone potential. See Section 3.5.2 in Binney & Tremaine (2008), and be aware of the errata entry for Eq. 3.225. This transformation is analytic and can be used as a "toy potential" in the Sanders & Binney 2014 formalism for computing action-angle coordinates in _any_ potential. Adapted from Jason Sanders' code `here <https://github.com/jlsanders/genfunc>`_. Parameters ---------- w : :class:`gala.dynamics.PhaseSpacePosition`, :class:`gala.dynamics.Orbit` The positions or orbit to compute the actions, angles, and frequencies at. """ from gala.dynamics.actionangle import isochrone_xv_to_aa return isochrone_xv_to_aa(w, self)
[docs] @format_doc(common_doc=_potential_docstring) class HernquistPotential(CPotentialBase): r""" Hernquist potential for a spheroid. See: http://adsabs.harvard.edu/abs/1990ApJ...356..359H Parameters ---------- m : :class:`~astropy.units.Quantity`, numeric [mass] Mass. c : :class:`~astropy.units.Quantity`, numeric [length] Core concentration. {common_doc} """ m = PotentialParameter("m", physical_type="mass") c = PotentialParameter("c", physical_type="length") Wrapper = HernquistWrapper
[docs] @myclassmethod @sympy_wrap def to_sympy(cls, v, p): import sympy as sy r = sy.sqrt(v["x"] ** 2 + v["y"] ** 2 + v["z"] ** 2) expr = -p["G"] * p["m"] / (r + p["c"]) return expr, v, p
[docs] @format_doc(common_doc=_potential_docstring) class PlummerPotential(CPotentialBase): r""" Plummer potential for a spheroid. Parameters ---------- m : :class:`~astropy.units.Quantity`, numeric [mass] Mass. b : :class:`~astropy.units.Quantity`, numeric [length] Core concentration. {common_doc} """ m = PotentialParameter("m", physical_type="mass") b = PotentialParameter("b", physical_type="length") Wrapper = PlummerWrapper
[docs] @myclassmethod @sympy_wrap def to_sympy(cls, v, p): import sympy as sy r = sy.sqrt(v["x"] ** 2 + v["y"] ** 2 + v["z"] ** 2) expr = -p["G"] * p["m"] / sy.sqrt(r**2 + p["b"] ** 2) return expr, v, p
[docs] @format_doc(common_doc=_potential_docstring) class JaffePotential(CPotentialBase): r""" Jaffe potential for a spheroid. Parameters ---------- m : :class:`~astropy.units.Quantity`, numeric [mass] Mass. c : :class:`~astropy.units.Quantity`, numeric [length] Core concentration. {common_doc} """ m = PotentialParameter("m", physical_type="mass") c = PotentialParameter("c", physical_type="length") Wrapper = JaffeWrapper
[docs] @myclassmethod @sympy_wrap def to_sympy(cls, v, p): import sympy as sy r = sy.sqrt(v["x"] ** 2 + v["y"] ** 2 + v["z"] ** 2) expr = p["G"] * p["m"] / p["c"] * sy.log(r / (r + p["c"])) return expr, v, p
[docs] @format_doc(common_doc=_potential_docstring) class StonePotential(CPotentialBase): r""" StonePotential(m, r_c, r_h, units=None, origin=None, R=None) Stone potential from `Stone & Ostriker (2015) <http://dx.doi.org/10.1088/2041-8205/806/2/L28>`_. Parameters ---------- m_tot : :class:`~astropy.units.Quantity`, numeric [mass] Total mass. r_c : :class:`~astropy.units.Quantity`, numeric [length] Core radius. r_h : :class:`~astropy.units.Quantity`, numeric [length] Halo radius. {common_doc} """ m = PotentialParameter("m", physical_type="mass") r_c = PotentialParameter("r_c", physical_type="length") r_h = PotentialParameter("r_h", physical_type="length") Wrapper = StoneWrapper
[docs] @myclassmethod @sympy_wrap def to_sympy(cls, v, p): import sympy as sy r = sy.sqrt(v["x"] ** 2 + v["y"] ** 2 + v["z"] ** 2) A = -2 * p["G"] * p["m"] / (np.pi * (p["r_h"] - p["r_c"])) expr = A * ( p["r_h"] / r * sy.atan(r / p["r_h"]) - p["r_c"] / r * sy.atan(r / p["r_c"]) + 1.0 / 2 * sy.log((r**2 + p["r_h"] ** 2) / (r**2 + p["r_c"] ** 2)) ) return expr, v, p
[docs] @format_doc(common_doc=_potential_docstring) class PowerLawCutoffPotential(CPotentialBase, GSL_only=True): r""" A spherical power-law density profile with an exponential cutoff. The power law index must be ``0 <= alpha < 3``. .. note:: This potential requires GSL to be installed, and Gala must have been built and installed with GSL support enaled (the default behavior). See http://gala.adrian.pw/en/latest/install.html for more information. Parameters ---------- m : :class:`~astropy.units.Quantity`, numeric [mass] Total mass. alpha : numeric Power law index. Must satisfy: ``alpha < 3`` r_c : :class:`~astropy.units.Quantity`, numeric [length] Cutoff radius. {common_doc} """ m = PotentialParameter("m", physical_type="mass") alpha = PotentialParameter("alpha", physical_type="dimensionless") r_c = PotentialParameter("r_c", physical_type="length") Wrapper = PowerLawCutoffWrapper
[docs] @myclassmethod @sympy_wrap def to_sympy(cls, v, p): import sympy as sy G = p["G"] m = p["m"] alpha = p["alpha"] r_c = p["r_c"] r = sy.sqrt(v["x"] ** 2 + v["y"] ** 2 + v["z"] ** 2) expr = ( G * alpha * m * sy.lowergamma(3.0 / 2 - alpha / 2, r**2 / r_c**2) / (2 * r * sy.gamma(5.0 / 2 - alpha / 2)) + G * m * sy.lowergamma(1 - alpha / 2, r**2 / r_c**2) / (r_c * sy.gamma(3.0 / 2 - alpha / 2)) - 3 * G * m * sy.lowergamma(3.0 / 2 - alpha / 2, r**2 / r_c**2) / (2 * r * sy.gamma(5.0 / 2 - alpha / 2)) ) return expr, v, p
[docs] @format_doc(common_doc=_potential_docstring) class BurkertPotential(CPotentialBase): r""" The Burkert potential that well-matches the rotation curve of dwarf galaxies. See https://iopscience.iop.org/article/10.1086/309140/fulltext/50172.text.html Parameters ---------- rho : :class:`~astropy.units.Quantity`, numeric [mass density] Central mass density. r0 : :class:`~astropy.units.Quantity`, numeric [length] The core radius. {common_doc} """ rho = PotentialParameter("rho", physical_type="mass density") r0 = PotentialParameter("r0", physical_type="length") Wrapper = BurkertWrapper
[docs] @classmethod def from_r0(cls, r0, units=None): r""" from_r0(r0, units=None) Initialize a Burkert potential from the core radius, ``r0``. See Equations 4 and 5 of Mori & Burkert. Parameters ---------- r0 : :class:`~astropy.units.Quantity`, numeric [length] The core radius of the Burkert potential. """ a = 0.021572405792749372 * u.Msun / u.pc**3 # converted: 1.46e-24 g/cm**3 rho_d0 = a * (r0 / (3.07 * u.kpc))**(-2/3) return cls(rho=rho_d0, r0=r0, units=units)
# ============================================================================ # Flattened, axisymmetric models #
[docs] @format_doc(common_doc=_potential_docstring) class SatohPotential(CPotentialBase): r""" SatohPotential(m, a, b, units=None, origin=None, R=None) Satoh potential for a flattened mass distribution. Parameters ---------- m : :class:`~astropy.units.Quantity`, numeric [mass] Mass. a : :class:`~astropy.units.Quantity`, numeric [length] Scale length. b : :class:`~astropy.units.Quantity`, numeric [length] Scale height. {common_doc} """ m = PotentialParameter("m", physical_type="mass") a = PotentialParameter("a", physical_type="length") b = PotentialParameter("b", physical_type="length") Wrapper = SatohWrapper
[docs] @myclassmethod @sympy_wrap def to_sympy(cls, v, p): import sympy as sy R = sy.sqrt(v["x"] ** 2 + v["y"] ** 2) z = v["z"] term = R**2 + z**2 + p["a"] * (p["a"] + 2 * sy.sqrt(z**2 + p["b"] ** 2)) expr = -p["G"] * p["m"] / sy.sqrt(term) return expr, v, p
[docs] @format_doc(common_doc=_potential_docstring) class KuzminPotential(CPotentialBase): r""" KuzminPotential(m, a, units=None, origin=None, R=None) Kuzmin potential for a flattened mass distribution. Parameters ---------- m : :class:`~astropy.units.Quantity`, numeric [mass] Mass. a : :class:`~astropy.units.Quantity`, numeric [length] Flattening parameter. {common_doc} """ m = PotentialParameter("m", physical_type="mass") a = PotentialParameter("a", physical_type="length") Wrapper = KuzminWrapper
[docs] @myclassmethod @sympy_wrap def to_sympy(cls, v, p): import sympy as sy denom = sy.sqrt(v["x"] ** 2 + v["y"] ** 2 + (p["a"] + sy.Abs(v["z"])) ** 2) expr = -p["G"] * p["m"] / denom return expr, v, p
[docs] @format_doc(common_doc=_potential_docstring) class MiyamotoNagaiPotential(CPotentialBase): r""" MiyamotoNagaiPotential(m, a, b, units=None, origin=None, R=None) Miyamoto-Nagai potential for a flattened mass distribution. See: http://adsabs.harvard.edu/abs/1975PASJ...27..533M Parameters ---------- m : :class:`~astropy.units.Quantity`, numeric [mass] Mass. a : :class:`~astropy.units.Quantity`, numeric [length] Scale length. b : :class:`~astropy.units.Quantity`, numeric [length] Scale height. {common_doc} """ m = PotentialParameter("m", physical_type="mass") a = PotentialParameter("a", physical_type="length") b = PotentialParameter("b", physical_type="length") Wrapper = MiyamotoNagaiWrapper
[docs] @myclassmethod @sympy_wrap def to_sympy(cls, v, p): import sympy as sy R = sy.sqrt(v["x"] ** 2 + v["y"] ** 2) z = v["z"] term = R**2 + (p["a"] + sy.sqrt(z**2 + p["b"] ** 2)) ** 2 expr = -p["G"] * p["m"] / sy.sqrt(term) return expr, v, p
[docs] @format_doc(common_doc=_potential_docstring) class MN3ExponentialDiskPotential(CPotentialBase): """ MN3ExponentialDiskPotential(m, h_R, h_z, positive_density=True, sech2_z=True, units=None, origin=None, R=None) A sum of three Miyamoto-Nagai disk potentials that approximate the potential generated by a double exponential disk. This model is taken from `Smith et al. (2015) <https://ui.adsabs.harvard.edu/abs/2015MNRAS.448.2934S/abstract>`_ - if you use this potential class, please also cite that work. As described in the above reference, this approximation has two options: (1) with the ``positive_density=True`` argument set, this density will be positive everywhere, but is only a good approximation of the exponential density within about 5 disk scale lengths, and (2) with ``positive_density=False``, this density will be negative in some regions, but is a better approximation out to about 7 or 8 disk scale lengths. Parameters ---------- m : :class:`~astropy.units.Quantity`, numeric [mass] Mass. h_R : :class:`~astropy.units.Quantity`, numeric [length] Radial (exponential) scale length. h_z : :class:`~astropy.units.Quantity`, numeric [length] If ``sech2_z=True``, this is the scale height in a sech^2 vertical profile. If ``sech2_z=False``, this is an exponential scale height. {common_doc} """ m = PotentialParameter("m", physical_type="mass") h_R = PotentialParameter("h_R", physical_type="length") h_z = PotentialParameter("h_z", physical_type="length") Wrapper = MN3ExponentialDiskWrapper _K_pos_dens = np.array( [ [0.0036, -0.0330, 0.1117, -0.1335, 0.1749], [-0.0131, 0.1090, -0.3035, 0.2921, -5.7976], [-0.0048, 0.0454, -0.1425, 0.1012, 6.7120], [-0.0158, 0.0993, -0.2070, -0.7089, 0.6445], [-0.0319, 0.1514, -0.1279, -0.9325, 2.6836], [-0.0326, 0.1816, -0.2943, -0.6329, 2.3193], ] ) _K_neg_dens = np.array( [ [-0.0090, 0.0640, -0.1653, 0.1164, 1.9487], [0.0173, -0.0903, 0.0877, 0.2029, -1.3077], [-0.0051, 0.0287, -0.0361, -0.0544, 0.2242], [-0.0358, 0.2610, -0.6987, -0.1193, 2.0074], [-0.0830, 0.4992, -0.7967, -1.2966, 4.4441], [-0.0247, 0.1718, -0.4124, -0.5944, 0.7333], ] ) def __init__( self, *args, units=None, origin=None, R=None, positive_density=True, sech2_z=True, **kwargs, ): PotentialBase.__init__(self, *args, units=units, origin=origin, R=R, **kwargs) hzR = (self.parameters["h_z"] / self.parameters["h_R"]).decompose() if positive_density: K = self._K_pos_dens else: K = self._K_neg_dens # get b / h_R if sech2_z: b_hR = -0.033 * hzR**3 + 0.262 * hzR**2 + 0.659 * hzR else: b_hR = -0.269 * hzR**3 + 1.08 * hzR**2 + 1.092 * hzR self.positive_density = positive_density self.sech2_z = sech2_z x = np.vander([b_hR], N=5)[0] param_vec = K @ x self._ms = param_vec[:3] * self.parameters["m"].value self._as = param_vec[3:] * self.parameters["h_R"].value self._b = b_hR * self.parameters["h_R"] c_only = {} for i in range(3): c_only[f"m{i+1}"] = self._ms[i] c_only[f"a{i+1}"] = self._as[i] c_only[f"b{i+1}"] = self._b.value self._setup_wrapper(c_only)
[docs] def get_three_potentials(self): """ Return three MiyamotoNagaiPotential instances that represent the three internal components of this MN3 potential model """ pots = {} for i in range(3): name = f"disk{i+1}" pots[name] = MiyamotoNagaiPotential( m=self._ms[i], a=self._as[i], b=self._b, units=self.units ) return pots
# ============================================================================ # Triaxial models #
[docs] @format_doc(common_doc=_potential_docstring) class NFWPotential(CPotentialBase): r""" NFWPotential(m, r_s, a=1, b=1, c=1, units=None, origin=None, R=None) General Navarro-Frenk-White potential. Supports spherical, flattened, and triaxiality but the flattening is introduced into the potential, not the density, and can therefore lead to unphysical mass distributions. For a triaxial NFW potential that supports flattening in the density, see :class:`gala.potential.LeeSutoTriaxialNFWPotential`. See also the alternate initializers: `NFWPotential.from_circular_velocity` and `NFWPotential.from_M200_c` Parameters ---------- m : :class:`~astropy.units.Quantity`, numeric [mass] Scale mass. r_s : :class:`~astropy.units.Quantity`, numeric [length] Scale radius. a : numeric Major axis scale. b : numeric Intermediate axis scale. c : numeric Minor axis scale. {common_doc} """ m = PotentialParameter("m", physical_type="mass") r_s = PotentialParameter("r_s", physical_type="length") a = PotentialParameter("a", physical_type="dimensionless", default=1.0) b = PotentialParameter("b", physical_type="dimensionless", default=1.0) c = PotentialParameter("c", physical_type="dimensionless", default=1.0) def _setup_potential(self, parameters, origin=None, R=None, units=None): super()._setup_potential(parameters, origin=origin, R=R, units=units) a = self.parameters["a"] b = self.parameters["b"] c = self.parameters["c"] if np.allclose([a, b, c], 1.0): self.Wrapper = SphericalNFWWrapper elif np.allclose([a, b], 1.0): self.Wrapper = FlattenedNFWWrapper else: self.Wrapper = TriaxialNFWWrapper
[docs] @myclassmethod @sympy_wrap def to_sympy(cls, v, p): import sympy as sy uu = ( sy.sqrt( (v["x"] / p["a"]) ** 2 + (v["y"] / p["b"]) ** 2 + (v["z"] / p["c"]) ** 2 ) / p["r_s"] ) v_h2 = p["G"] * p["m"] / p["r_s"] expr = -v_h2 * sy.log(1 + uu) / uu return expr, v, p
[docs] @staticmethod def from_M200_c(M200, c, rho_c=None, units=None, origin=None, R=None): r""" from_M200_c(M200, c, rho_c=None, units=None, origin=None, R=None) Initialize an NFW potential from a virial mass, ``M200``, and a concentration, ``c``. Parameters ---------- M200 : :class:`~astropy.units.Quantity`, numeric [mass] Virial mass, or mass at 200 times the critical density, ``rho_c``. c : numeric NFW halo concentration. rho_c : :class:`~astropy.units.Quantity`, numeric [density] Critical density at z=0. If not specified, uses the default astropy cosmology to obtain this, `~astropy.cosmology.default_cosmology`. """ if rho_c is None: from astropy.constants import G from astropy.cosmology import default_cosmology cosmo = default_cosmology.get() rho_c = (3 * cosmo.H(0.0) ** 2 / (8 * np.pi * G)).to(u.Msun / u.kpc**3) Rvir = np.cbrt(M200 / (200 * rho_c) / (4.0 / 3 * np.pi)).to(u.kpc) r_s = Rvir / c A_NFW = np.log(1 + c) - c / (1 + c) m = M200 / A_NFW return NFWPotential( m=m, r_s=r_s, a=1.0, b=1.0, c=1.0, units=units, origin=origin, R=R )
[docs] @staticmethod def from_circular_velocity( v_c, r_s, a=1.0, b=1.0, c=1.0, r_ref=None, units=None, origin=None, R=None, ): r""" Initialize an NFW potential from a circular velocity, scale radius, and reference radius for the circular velocity. For scale mass :math:`m_s`, scale radius :math:`r_s`, scaled reference radius :math:`u_{\rm ref} = r_{\rm ref}/r_s`: .. math:: \frac{G\,m_s}{r_s} = \frac{v_c^2}{u_{\rm ref}} \, \left[\frac{u_{\rm ref}}{1+u_{\rm ref}} - \frac{\ln(1+u_{\rm ref})}{u_{\rm ref}^2} \right]^{-1} Parameters ---------- v_c : :class:`~astropy.units.Quantity`, numeric [velocity] Circular velocity at the reference radius ``r_ref`` (see below). r_s : :class:`~astropy.units.Quantity`, numeric [length] Scale radius. a : numeric Major axis scale. b : numeric Intermediate axis scale. c : numeric Minor axis scale. r_ref : :class:`~astropy.units.Quantity`, numeric [length] (optional) Reference radius at which the circular velocity is given. By default, this is assumed to be the scale radius, ``r_s``. """ if not hasattr(v_c, "unit"): v_c = v_c * units["length"] / units["time"] if not hasattr(r_s, "unit"): r_s = r_s * units["length"] if r_ref is None: r_ref = r_s m = NFWPotential._vc_rs_rref_to_m(v_c, r_s, r_ref) m = m.to(units["mass"]) return NFWPotential( m=m, r_s=r_s, a=a, b=b, c=c, units=units, origin=origin, R=R )
@staticmethod def _vc_rs_rref_to_m(v_c, r_s, r_ref): uu = r_ref / r_s vs2 = v_c**2 / uu / (np.log(1 + uu) / uu**2 - 1 / (uu * (1 + uu))) return r_s * vs2 / G
[docs] @format_doc(common_doc=_potential_docstring) class LogarithmicPotential(CPotentialBase): r""" LogarithmicPotential(v_c, r_h, q1, q2, q3, phi=0, theta=0, psi=0, units=None, origin=None, R=None) Triaxial logarithmic potential. Parameters ---------- v_c : :class:`~astropy.units.Quantity`, numeric [velocity] Circular velocity. r_h : :class:`~astropy.units.Quantity`, numeric [length] Scale radius. q1 : numeric Flattening in X. q2 : numeric Flattening in Y. q3 : numeric Flattening in Z. phi : `~astropy.units.Quantity`, numeric First euler angle in the z-x-z convention. {common_doc} """ v_c = PotentialParameter("v_c", physical_type="speed") r_h = PotentialParameter("r_h", physical_type="length") q1 = PotentialParameter("q1", physical_type="dimensionless", default=1.0) q2 = PotentialParameter("q2", physical_type="dimensionless", default=1.0) q3 = PotentialParameter("q3", physical_type="dimensionless", default=1.0) phi = PotentialParameter("phi", physical_type="angle", default=0.0) Wrapper = LogarithmicWrapper
[docs] @myclassmethod @sympy_wrap def to_sympy(cls, v, p): import sympy as sy r2 = (v["x"] / p["q1"]) ** 2 + (v["y"] / p["q2"]) ** 2 + (v["z"] / p["q3"]) ** 2 expr = 1.0 / 2 * p["v_c"] ** 2 * sy.log(p["r_h"] ** 2 + r2) return expr, v, p
[docs] @format_doc(common_doc=_potential_docstring) class LeeSutoTriaxialNFWPotential(CPotentialBase): r""" LeeSutoTriaxialNFWPotential(v_c, r_s, a, b, c, units=None, origin=None, R=None) Approximation of a Triaxial NFW Potential with the flattening in the density, not the potential. See `Lee & Suto (2003) <http://adsabs.harvard.edu/abs/2003ApJ...585..151L>`_ for details. Parameters ---------- v_c : `~astropy.units.Quantity`, numeric [velocity] Circular velocity at the scale radius. r_h : `~astropy.units.Quantity`, numeric [length] Scale radius. a : numeric Major axis. b : numeric Intermediate axis. c : numeric Minor axis. {common_doc} """ v_c = PotentialParameter("v_c", physical_type="speed") r_s = PotentialParameter("r_s", physical_type="length") a = PotentialParameter("a", physical_type="dimensionless", default=1.0) b = PotentialParameter("b", physical_type="dimensionless", default=1.0) c = PotentialParameter("c", physical_type="dimensionless", default=1.0) Wrapper = LeeSutoTriaxialNFWWrapper
# TODO: implement to_sympy()
[docs] @format_doc(common_doc=_potential_docstring) class LongMuraliBarPotential(CPotentialBase): r""" LongMuraliBarPotential(m, a, b, c, alpha=0, units=None, origin=None, R=None) A simple, triaxial model for a galaxy bar. This is a softened "needle" density distribution with an analytic potential form. See `Long & Murali (1992) <http://adsabs.harvard.edu/abs/1992ApJ...397...44L>`_ for details. Parameters ---------- m : `~astropy.units.Quantity`, numeric [mass] Mass scale. a : `~astropy.units.Quantity`, numeric [length] Bar half-length. b : `~astropy.units.Quantity`, numeric [length] Like the Miyamoto-Nagai ``b`` parameter. c : `~astropy.units.Quantity`, numeric [length] Like the Miyamoto-Nagai ``c`` parameter. {common_doc} """ m = PotentialParameter("m", physical_type="mass") a = PotentialParameter("a", physical_type="length") b = PotentialParameter("b", physical_type="length") c = PotentialParameter("c", physical_type="length") alpha = PotentialParameter("alpha", physical_type="angle", default=0) Wrapper = LongMuraliBarWrapper
[docs] @myclassmethod @sympy_wrap def to_sympy(cls, v, p): import sympy as sy x = v["x"] * sy.cos(p["alpha"]) + v["y"] * sy.sin(p["alpha"]) y = -v["x"] * sy.sin(p["alpha"]) + v["y"] * sy.cos(p["alpha"]) z = v["z"] Tm = sy.sqrt( (p["a"] - x) ** 2 + y**2 + (p["b"] + sy.sqrt(p["c"] ** 2 + z**2)) ** 2 ) Tp = sy.sqrt( (p["a"] + x) ** 2 + y**2 + (p["b"] + sy.sqrt(p["c"] ** 2 + z**2)) ** 2 ) expr = ( p["G"] * p["m"] / (2 * p["a"]) * sy.log((x - p["a"] + Tm) / (x + p["a"] + Tp)) ) return expr, v, p
# ============================================================================== # Special #
[docs] @format_doc(common_doc=_potential_docstring) class NullPotential(CPotentialBase): r""" NullPotential(units=None, origin=None, R=None) A null potential with 0 mass. Does nothing. Parameters ---------- {common_doc} """ Wrapper = NullWrapper
# ============================================================================== # Multipole and flexible potential models # mp_cache = {} def make_multipole_cls(lmax, timedep=False): """Create a MultipolePotential or MultipoleTimeDependentPotential class (not an instance!) with the specified value of lmax. Parameters: ----------- lmax : int timedep : bool """ if timedep: raise NotImplementedError("Time dependent potential coming soon!") # cls = MultipoleTimeDependentPotential # param_default = [0.] else: cls = MultipolePotential param_default = 0.0 cls_name = f"{cls.__name__}Lmax{lmax}" if cls_name in mp_cache: return mp_cache[cls_name] parameters = { "_lmax": lmax, "inner": PotentialParameter("inner", default=False), "m": PotentialParameter("m", physical_type="mass", default=1.0), "r_s": PotentialParameter("r_s", physical_type="length", default=1.0), } doc_lines = [] ab_callsig = [] for l in range(lmax + 1): for m in range(0, l + 1): if timedep: a = f"alpha{l}{m}" b = f"beta{l}{m}" dtype = "array-like" else: a = f"S{l}{m}" b = f"T{l}{m}" dtype = "float" parameters[a] = PotentialParameter( a, physical_type="dimensionless", default=param_default ) parameters[b] = PotentialParameter( b, physical_type="dimensionless", default=param_default ) doc_lines.append(f"{a} : {dtype}\n{b} : {dtype}") ab_callsig.append(f"{a}, {b}") ab_callsig = ", ".join(ab_callsig) call_signature = f"{cls.__name__}(m, r_s, {ab_callsig})" parameters["__doc__"] = call_signature + cls.__doc__ + "\n".join(doc_lines) # https://stackoverflow.com/a/58716798/623453 parameters["__module__"] = __name__ # Create a new SkyOffsetFrame subclass for this frame class. potential_cls = type(cls_name, (cls,), parameters) mp_cache[cls_name] = potential_cls return mp_cache[cls_name]
[docs] class MultipolePotential(CPotentialBase, GSL_only=True): r""" A perturbing potential represented by a multipole expansion. Inner: .. math:: \Phi^l_\mathrm{max}(r,\theta,\phi) = \sum_{l=1}^{l=l_\mathrm{max}}\sum_{m=0}^{m=l} r^l \, (S_{lm} \, \cos{m\,\phi} + T_{lm} \, \sin{m\,\phi}) \, P_l^m(\cos\theta) Outer: .. math:: \Phi^l_\mathrm{max}(r,\theta,\phi) = \sum_{l=1}^{l=l_\mathrm{max}}\sum_{m=0}^{m=l} r^{-(l+1)} \, (S_{lm} \, \cos{m\,\phi} + T_{lm} \, \sin{m\,\phi}) \, P_l^m(\cos\theta) The allowed coefficient parameter names will depend on how you set ``lmax``, and the default value for all coefficient parameter values is 0. Parameters ---------- m : numeric Scale mass. r_s : numeric Scale length. lmax : int The maximum ``l`` order. inner : bool (optional) Controls whether to use the inner expansion, or the outer expansion (see above). Default value = ``False``. S00 : float (optional) S10 : float (optional) S11 : float (optional) T11 : float (optional) etc. Examples -------- To create a potential object with only a dipole: >>> pot = MultipolePotential(lmax=1, S10=5.) """ Wrapper = MultipoleWrapper def __init__(self, *args, units=None, origin=None, R=None, **kwargs): kwargs.pop("lmax", None) PotentialBase.__init__(self, *args, units=units, origin=origin, R=R, **kwargs) self._setup_wrapper( {"lmax": self._lmax, "n_coeffs": sum(range(self._lmax + 2))} ) def __new__(cls, *args, **kwargs): # We don't want to call this method if we've already set up # an skyoffset frame for this class. if not (issubclass(cls, MultipolePotential) and cls is not MultipolePotential): try: lmax = kwargs["lmax"] except KeyError: raise TypeError( "Can't initialize a MultipolePotential without specifying " "the `lmax` keyword argument." ) newcls = make_multipole_cls(lmax) return newcls.__new__(newcls, *args, **kwargs) if super().__new__ is object.__new__: return super().__new__(cls) return super().__new__(cls, *args, **kwargs)
[docs] @format_doc(common_doc=_potential_docstring) class CylSplinePotential(CPotentialBase): r""" A flexible potential model that uses spline interpolation over a 2D grid in cylindrical R-z coordinates. Parameters ---------- grid_R : `~astropy.units.Quantity`, numeric [length] A 1D grid of cylindrical radius R values. This should start at 0. grid_z : `~astropy.units.Quantity`, numeric [length] A 1D grid of cylindrical z values. This should start at 0. grid_Phi : `~astropy.units.Quantity`, numeric [specific energy] A 2D grid of potential values, evaluated at all R,z locations. {common_doc} """ grid_R = PotentialParameter("grid_R", physical_type="length") grid_z = PotentialParameter("grid_z", physical_type="length") grid_Phi = PotentialParameter("grid_Phi", physical_type="specific energy") Wrapper = CylSplineWrapper
[docs] @classmethod def from_file(cls, filename, **kwargs): """Load a potential instance from an Agama export file. Parameters ---------- filename : path-like The path to the Agama expoirt file, either as a string or ``pathlib.Path`` object. **kwargs Other keyword arguments are passed to the initializer. """ with open(filename, "r") as f: raw_lines = f.readlines() start = r"#R(row)\z(col)" Phi_lines = [] for i, line in enumerate(raw_lines): if line.startswith(start): Phi_lines.append( [np.nan] + [float(y) for y in line[len(start) :].strip().split("\t")] ) break Phi_lines.extend( [[float(y) for y in x.strip().split("\t")] for x in raw_lines[i + 1 :]] ) Phi_lines = np.array(Phi_lines) gridR = Phi_lines[1:, 0] * u.kpc gridz = Phi_lines[0, 1:] * u.kpc gridPhi = Phi_lines[1:, 1:] * (u.km / u.s) ** 2 return cls(gridR, gridz, gridPhi, **kwargs)
def __init__(self, *args, units=None, origin=None, R=None, **kwargs): PotentialBase.__init__(self, *args, units=units, origin=origin, R=R, **kwargs) grid_R = self.parameters["grid_R"] grid_z = self.parameters["grid_z"] grid_Phi = self.parameters["grid_Phi"] Phi0 = grid_Phi[0, 0] # potential at R=0,z=0 self._multipole_pot = self._fit_asympt(grid_R, grid_z, grid_Phi) Phi_Rmax = self._multipole_pot.energy([1.0, 0, 0] * grid_R.max()) Mtot = -Phi_Rmax[0] * grid_R.max() if Phi0 < 0 and Mtot > 0: # assign Rscale so that it approximately equals -Mtotal/Phi(r=0), # i.e. would equal the scale radius of a Plummer potential Rscale = (-Mtot / Phi0).to(self.units["length"]) else: Rscale = grid_R[len(grid_R) // 2] # "rather arbitrary" # APW: assumed / enforced mmax=0 - different from Agama sizeR = len(grid_R) # grid in z assumed to only cover half-space z>=0; the density is assumed # to be z-reflection symmetric: sizez_orig = len(grid_z) grid_z = np.concatenate((-grid_z[::-1], grid_z[1:])) sizez = len(grid_z) # transform the grid to log-scaled coordinates grid_R_asinh = np.arcsinh((grid_R / Rscale).decompose().value) grid_z_asinh = np.arcsinh((grid_z / Rscale).decompose().value) logScaling = np.all(grid_Phi < 0) # temporary containers of scaled potential and derivatives used to # construct 2d splines if grid_Phi.shape[0] != sizeR or grid_Phi.shape[1] != sizez_orig: raise ValueError("CylSpline: incorrect coefs array size") grid_Phi_full = np.zeros((sizeR, sizez)) grid_Phi_full[:, : sizez_orig - 1] = grid_Phi[:, :0:-1] grid_Phi_full[:, sizez_orig - 1 :] = grid_Phi if logScaling: grid_Phi_full = np.log(-grid_Phi_full) else: grid_Phi_full = grid_Phi_full from scipy.interpolate import RectBivariateSpline self.spl = RectBivariateSpline(grid_R_asinh, grid_z_asinh, grid_Phi_full) # Note: if MultipolePotential parameter order changes, this needs to be updated! multipole_pars = np.concatenate( [ [ self.G, self._multipole_pot._lmax, sum(range(self._multipole_pot._lmax + 2)), ], [x.value for x in self._multipole_pot.parameters.values()], ] ) self._c_only = { "log_scaling": logScaling, "Rscale": Rscale.value, "sizeR": sizeR, "sizez": sizez, "grid_R_trans": grid_R_asinh, "grid_z_trans": grid_z_asinh, "grid_Phi_trans": grid_Phi_full.T, "multipole_pars": multipole_pars, } self._setup_wrapper(self._c_only) def _fit_asympt(self, grid_R, grid_z, grid_Phi, lmax_fit=8): """ Assumes z reflection symmetry lmax_fit : int Number of meridional harmonics to fit - don't set too large """ from scipy.special import sph_harm sizeR = len(grid_R) sizez = len(grid_z) # assemble the boundary points and their indices assert grid_Phi.shape == (sizeR, sizez) maxz = np.max(grid_z.value) # first run along R at the max-z and min-z edges points = np.concatenate( ([[R, maxz] for R in grid_R.value], [[R, -maxz] for R in grid_R.value]) ) Phis = np.concatenate( (grid_Phi[:, np.argmax(grid_z)].value, grid_Phi[:, np.argmax(grid_z)].value) ) maxR = np.max(grid_R.value) points = np.concatenate( ( points, [[maxR, z] for z in grid_z.value], [[maxR, -z] for z in grid_z.value], ) ) Phis = np.concatenate( ( Phis, grid_Phi[np.argmax(grid_R), :].value, grid_Phi[np.argmax(grid_R), :].value, ) ) npoints = len(points) # ncoefs = lmax_fit + 1 r0 = min(np.max(grid_R), np.max(grid_z)) i, j = len(grid_R) // 2, len(grid_z) // 2 rr = np.sqrt(grid_R[i] ** 2 + grid_z[j] ** 2) m = np.abs(grid_Phi[i, j] * rr / G).to(self.units["mass"]) scale = (G * m / r0).decompose(self.units).value # find values of spherical harmonic coefficients # that best match the potential at the array of boundary points # for m-th harmonic, we may have lmax-m+1 different l-terms matr = np.zeros((npoints, lmax_fit + 1)) # The linear system to solve in the least-square sense is M_{p,l} * S_l = R_p, # where R_p = Phi at p-th boundary point (0<=p<npoints), # M_{l,p} = value of l-th harmonic coefficient at p-th boundary point, # S_l = the amplitude of l-th coefficient to be determined. r = np.sqrt(points[:, 0] ** 2 + points[:, 1] ** 2) theta = np.arctan2(points[:, 0], points[:, 1]) ls = np.arange(lmax_fit + 1) Pl0 = np.stack([sph_harm(0, l, 0.0, theta).real for l in ls]).T matr = (r[:, None] / r0.value) ** -(ls[None] + 1) * Pl0 y = Phis / scale sol, resid, rank, s = np.linalg.lstsq(matr, y, rcond=None) pars = {f"S{l}0": sol[l].real for l in ls} return MultipolePotential( lmax=lmax_fit, m=m, r_s=r0, inner=False, units=self.units, **pars )