Skip to content

Commit e06ec61

Browse files
Fix spherical DF sampling for divergent potentials/densities (#812)
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent 9c858bf commit e06ec61

5 files changed

Lines changed: 361 additions & 31 deletions

File tree

galpy/df/constantbetadf.py

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from ..potential.Potential import _evaluatePotentials
99
from ..util import conversion, quadpack
1010
from ..util._optional_deps import _JAX_LOADED
11-
from .sphericaldf import anisotropicsphericaldf, sphericaldf
11+
from .sphericaldf import _handle_rmin, anisotropicsphericaldf, sphericaldf
1212

1313
if _JAX_LOADED:
1414
from jax import grad, vmap
@@ -156,6 +156,7 @@ def __init__(
156156
beta=0.0,
157157
twobeta=None,
158158
rmax=None,
159+
rmin=None,
159160
scale=None,
160161
ro=None,
161162
vo=None,
@@ -175,6 +176,8 @@ def __init__(
175176
twice the anisotropy parameter (useful for \beta = half-integer, which is a special case); has priority over beta
176177
rmax : float or Quantity, optional
177178
maximum radius to consider; DF is cut off at E = Phi(rmax)
179+
rmin : float or Quantity, optional
180+
Minimum radius to consider; the distribution function is cut off at E = Phi(rmin). For potentials that diverge at r=0 (e.g., PowerSphericalPotential with alpha > 2), this is automatically set to a small value if not specified.
178181
scale : float or Quantity, optional
179182
Characteristic scale radius to aid sampling calculations. Optional and will also be overridden by value from pot if available.
180183
ro : float or Quantity, optional
@@ -190,7 +193,7 @@ def __init__(
190193
if not _JAX_LOADED: # pragma: no cover
191194
raise ImportError("galpy.df.constantbetadf requires the google/jax library")
192195
# Parse twobeta
193-
if not twobeta is None:
196+
if twobeta is not None:
194197
beta = twobeta / 2.0
195198
else:
196199
twobeta = 2.0 * beta
@@ -210,6 +213,11 @@ def __init__(
210213
ro=ro,
211214
vo=vo,
212215
)
216+
# Handle rmin for divergent potentials
217+
self._rmin = _handle_rmin(
218+
rmin, self._pot, self._denspot, self._scale, self._ro, "constantbetadf"
219+
)
220+
213221
self._twobeta = twobeta
214222
self._halfint = False
215223
if isinstance(self._twobeta, int) and self._twobeta % 2 == 1:
@@ -252,9 +260,11 @@ def __init__(
252260
self._gradfunc = vmap(func)
253261
# Min and max energy
254262
self._potInf = _evaluatePotentials(self._pot, self._rmax, 0)
255-
self._Emin = _evaluatePotentials(self._pot, 0.0, 0)
256-
# Build interpolator r(pot)
257-
self._rphi = self._setup_rphi_interpolator()
263+
self._Emin = _evaluatePotentials(self._pot, self._rmin, 0)
264+
# Build interpolator r(pot), starting at rmin for divergent potentials
265+
self._rphi = self._setup_rphi_interpolator(
266+
r_a_min=max(1e-6, self._rmin / self._scale)
267+
)
258268
# Build interpolator for the lower limit of the integration (near the
259269
# 1/(Phi-E)^alpha divergence; at the end, we slightly adjust it up
260270
# to be sure to be above the point where things go haywire...
@@ -283,9 +293,19 @@ def __init__(
283293
Es, numpy.log10(startt) + 10.0 / 3.0 * (1.0 - self._alpha), k=3
284294
)
285295

286-
def sample(self, R=None, z=None, phi=None, n=1, return_orbit=True, rmin=0.0):
296+
def sample(self, R=None, z=None, phi=None, n=1, return_orbit=True, rmin=None):
287297
# Slight over-write of superclass method to first build f(E) interp
288298
# No docstring so superclass' is used
299+
# Use self._rmin as default if rmin is not specified
300+
if rmin is None:
301+
rmin = self._rmin
302+
self._ensure_fE_interp()
303+
return sphericaldf.sample(
304+
self, R=R, z=z, phi=phi, n=n, return_orbit=return_orbit, rmin=rmin
305+
)
306+
307+
def _ensure_fE_interp(self):
308+
"""Build the f(E) interpolator if not already built."""
289309
if not hasattr(self, "_fE_interp"):
290310
Es4interp = numpy.hstack(
291311
(
@@ -299,9 +319,6 @@ def sample(self, R=None, z=None, phi=None, n=1, return_orbit=True, rmin=0.0):
299319
self._fE_interp = interpolate.InterpolatedUnivariateSpline(
300320
Es4interp[iindx], fE4interp[iindx], k=3, ext=3
301321
)
302-
return sphericaldf.sample(
303-
self, R=R, z=z, phi=phi, n=n, return_orbit=return_orbit, rmin=rmin
304-
)
305322

306323
def fE(self, E):
307324
"""

galpy/df/eddingtondf.py

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from ..potential import CompositePotential, evaluateR2derivs
77
from ..potential.Potential import _evaluatePotentials, _evaluateRforces
88
from ..util import conversion
9-
from .sphericaldf import isotropicsphericaldf, sphericaldf
9+
from .sphericaldf import _handle_rmin, isotropicsphericaldf, sphericaldf
1010

1111

1212
class eddingtondf(isotropicsphericaldf):
@@ -19,7 +19,9 @@ class eddingtondf(isotropicsphericaldf):
1919
where :math:`\\Psi = -\\Phi+\\Phi(\\infty)` is the relative potential, :math:`\\mathcal{E} = \\Psi-v^2/2` is the relative (binding) energy, and :math:`\\rho` is the density of the tracer population (not necessarily the density corresponding to :math:`\\Psi` according to the Poisson equation). Note that the second term on the right-hand side is currently assumed to be zero in the code.
2020
"""
2121

22-
def __init__(self, pot=None, denspot=None, rmax=1e4, scale=None, ro=None, vo=None):
22+
def __init__(
23+
self, pot=None, denspot=None, rmax=1e4, rmin=None, scale=None, ro=None, vo=None
24+
):
2325
"""
2426
Initialize an isotropic distribution function computed using the Eddington inversion.
2527
@@ -31,6 +33,10 @@ def __init__(self, pot=None, denspot=None, rmax=1e4, scale=None, ro=None, vo=Non
3133
Represents the density of the tracers (assumed to be spherical; if None, set equal to pot).
3234
rmax : float or Quantity, optional
3335
Maximum radius to consider. DF is cut off at E = Phi(rmax).
36+
rmin : float or Quantity, optional
37+
Minimum radius to consider. For divergent potentials (Phi(0) = -inf),
38+
this sets the inner boundary for the energy range. Auto-detected if
39+
not specified.
3440
scale : float or Quantity, optional
3541
Characteristic scale radius to aid sampling calculations. Optional and will also be overridden by value from pot if available.
3642
ro : float or Quantity, optional
@@ -46,6 +52,12 @@ def __init__(self, pot=None, denspot=None, rmax=1e4, scale=None, ro=None, vo=Non
4652
isotropicsphericaldf.__init__(
4753
self, pot=pot, denspot=denspot, rmax=rmax, scale=scale, ro=ro, vo=vo
4854
)
55+
56+
# Handle rmin for divergent potentials
57+
self._rmin = _handle_rmin(
58+
rmin, self._pot, self._denspot, self._scale, self._ro, "eddingtondf"
59+
)
60+
4961
self._dnudr = (
5062
self._denspot._ddensdr
5163
if not isinstance(self._denspot, CompositePotential)
@@ -57,7 +69,7 @@ def __init__(self, pot=None, denspot=None, rmax=1e4, scale=None, ro=None, vo=Non
5769
else lambda r: numpy.sum([p._d2densdr2(r) for p in self._denspot])
5870
)
5971
self._potInf = _evaluatePotentials(pot, self._rmax, 0)
60-
self._Emin = _evaluatePotentials(pot, 0.0, 0)
72+
self._Emin = _evaluatePotentials(pot, self._rmin, 0)
6173
# Current calculation of the boundary term uses r -> inf limit
6274
try:
6375
self._rInf = (
@@ -68,12 +80,23 @@ def __init__(self, pot=None, denspot=None, rmax=1e4, scale=None, ro=None, vo=Non
6880
)
6981
except ZeroDivisionError:
7082
self._rInf = 1e12
71-
# Build interpolator r(pot)
72-
self._rphi = self._setup_rphi_interpolator()
83+
# Build interpolator r(pot), starting at rmin for divergent potentials
84+
self._rphi = self._setup_rphi_interpolator(
85+
r_a_min=max(1e-6, self._rmin / self._scale)
86+
)
7387

74-
def sample(self, R=None, z=None, phi=None, n=1, return_orbit=True, rmin=0.0):
88+
def sample(self, R=None, z=None, phi=None, n=1, return_orbit=True, rmin=None):
7589
# Slight over-write of superclass method to first build f(E) interp
7690
# No docstring so superclass' is used
91+
if rmin is None:
92+
rmin = self._rmin
93+
self._ensure_fE_interp()
94+
return sphericaldf.sample(
95+
self, R=R, z=z, phi=phi, n=n, return_orbit=return_orbit, rmin=rmin
96+
)
97+
98+
def _ensure_fE_interp(self):
99+
"""Build the f(E) interpolator if not already built."""
77100
if not hasattr(self, "_fE_interp"):
78101
Es4interp = numpy.hstack(
79102
(
@@ -87,9 +110,6 @@ def sample(self, R=None, z=None, phi=None, n=1, return_orbit=True, rmin=0.0):
87110
self._fE_interp = interpolate.InterpolatedUnivariateSpline(
88111
Es4interp[iindx], fE4interp[iindx], k=3, ext=3
89112
)
90-
return sphericaldf.sample(
91-
self, R=R, z=z, phi=phi, n=n, return_orbit=return_orbit, rmin=rmin
92-
)
93113

94114
def fE(self, E):
95115
"""

galpy/df/osipkovmerrittdf.py

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,15 @@ class osipkovmerrittdf(_osipkovmerrittdf):
200200
"""
201201

202202
def __init__(
203-
self, pot=None, denspot=None, ra=1.4, rmax=1e4, scale=None, ro=None, vo=None
203+
self,
204+
pot=None,
205+
denspot=None,
206+
ra=1.4,
207+
rmax=1e4,
208+
rmin=None,
209+
scale=None,
210+
ro=None,
211+
vo=None,
204212
):
205213
"""
206214
Initialize a DF with Osipkov-Merritt anisotropy.
@@ -215,6 +223,10 @@ def __init__(
215223
Anisotropy radius. Default: 1.4
216224
rmax : float or Quantity, optional
217225
Maximum radius to consider; DF is cut off at E = Phi(rmax). Default: None
226+
rmin : float or Quantity, optional
227+
Minimum radius to consider. For divergent potentials (Phi(0) = -inf),
228+
this sets the inner boundary for the energy range. Auto-detected if
229+
not specified.
218230
scale : float or Quantity, optional
219231
Characteristic scale radius to aid sampling calculations. Not necessary, and will also be overridden by value from pot if available. Default: None
220232
ro : float or Quantity, optional
@@ -233,8 +245,16 @@ def __init__(
233245
# using the augmented density rawdensx(1+r^2/ra^2), we use a helper
234246
# eddingtondf to do this integral, hacked to use the augmented density
235247
self._edf = eddingtondf(
236-
pot=self._pot, denspot=self._denspot, scale=scale, rmax=rmax, ro=ro, vo=vo
248+
pot=self._pot,
249+
denspot=self._denspot,
250+
scale=scale,
251+
rmax=rmax,
252+
rmin=rmin,
253+
ro=ro,
254+
vo=vo,
237255
)
256+
# Copy rmin from the internal eddingtondf
257+
self._rmin = self._edf._rmin
238258
self._edf._dnudr = (
239259
(
240260
lambda r: self._denspot._ddensdr(r) * (1.0 + r**2.0 / self._ra2)
@@ -270,9 +290,18 @@ def __init__(
270290
)
271291
)
272292

273-
def sample(self, R=None, z=None, phi=None, n=1, return_orbit=True, rmin=0.0):
293+
def sample(self, R=None, z=None, phi=None, n=1, return_orbit=True, rmin=None):
274294
# Slight over-write of superclass method to first build f(Q) interp
275295
# No docstring so superclass' is used
296+
if rmin is None:
297+
rmin = self._rmin
298+
self._ensure_fQ_interp()
299+
return sphericaldf.sample(
300+
self, R=R, z=z, phi=phi, n=n, return_orbit=return_orbit, rmin=rmin
301+
)
302+
303+
def _ensure_fQ_interp(self):
304+
"""Build the f(Q) interpolator if not already built."""
276305
if not hasattr(self, "_logfQ_interp"):
277306
Qs4interp = numpy.hstack(
278307
(
@@ -288,9 +317,6 @@ def sample(self, R=None, z=None, phi=None, n=1, return_orbit=True, rmin=0.0):
288317
self._logfQ_interp = interpolate.InterpolatedUnivariateSpline(
289318
Qs4interp[iindx], fQ4interp[iindx], k=3, ext=3
290319
)
291-
return sphericaldf.sample(
292-
self, R=R, z=z, phi=phi, n=n, return_orbit=return_orbit, rmin=rmin
293-
)
294320

295321
def fQ(self, Q):
296322
"""

0 commit comments

Comments
 (0)