Skip to content

Commit 2876d13

Browse files
jobovyclaude
andcommitted
Address MultipoleExpansionPotential review comments
- Remove k parameter from public API (BPoly sets degree, old docstring misleading) - Skip sin integral computation for m=0 (never used, zero bytes reach C) - Enhance SCF test to verify only l=0,1 coefficients are non-zero - Add C coverage tests for below-grid log branch (line 255), d²R (lines 286-288), and non-axi potential evaluation Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 0936575 commit 2876d13

5 files changed

Lines changed: 154 additions & 61 deletions

File tree

galpy/potential/MultipoleExpansionPotential.py

Lines changed: 17 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -45,16 +45,17 @@ class MultipoleExpansionPotential(Potential, SphericalHarmonicPotentialMixin):
4545
def __init__(
4646
self,
4747
amp=1.0,
48-
dens=lambda R, z: 1.0
49-
/ (2.0 * numpy.pi)
50-
/ numpy.sqrt(R**2 + z**2)
51-
/ (1 + numpy.sqrt(R**2 + z**2)) ** 3,
48+
dens=lambda R, z: (
49+
1.0
50+
/ (2.0 * numpy.pi)
51+
/ numpy.sqrt(R**2 + z**2)
52+
/ (1 + numpy.sqrt(R**2 + z**2)) ** 3
53+
),
5254
L=6,
5355
rgrid=numpy.geomspace(1e-3, 30, 1_001),
5456
symmetry=None,
5557
costheta_order=None,
5658
phi_order=None,
57-
k=3,
5859
normalize=False,
5960
ro=None,
6061
vo=None,
@@ -78,8 +79,6 @@ def __init__(
7879
Gauss-Legendre quadrature order for theta. Default: ``max(20, L+1)``.
7980
phi_order : int, optional
8081
Number of uniform phi points for trapezoidal rule. Default: ``max(20, 2*L+1)``.
81-
k : int, optional
82-
Spline interpolation degree for radial functions (default: 3). Use k=5 for smoother second derivatives.
8382
normalize : bool or float, optional
8483
If True, normalize such that vc(1.,0.)=1., or, if given as a number, such that the force is this fraction of the force necessary to make vc(1.,0.)=1.
8584
ro : float or Quantity, optional
@@ -95,7 +94,7 @@ def __init__(
9594
# Parse density function
9695
dens_func = self._parse_density(dens)
9796
self._rgrid = rgrid
98-
self._k = k
97+
self._k = 3
9998
self._L = L
10099
# Set M based on symmetry
101100
if symmetry is not None and symmetry.startswith("spher"):
@@ -136,7 +135,7 @@ def __init__(
136135
self._rho_cos_splines = [
137136
[
138137
InterpolatedUnivariateSpline(
139-
rgrid, beta_lm[l, m] * self._rho_cos[:, l, m], k=k
138+
rgrid, beta_lm[l, m] * self._rho_cos[:, l, m], k=self._k
140139
)
141140
for m in range(M)
142141
]
@@ -145,7 +144,7 @@ def __init__(
145144
self._rho_sin_splines = [
146145
[
147146
InterpolatedUnivariateSpline(
148-
rgrid, beta_lm[l, m] * self._rho_sin[:, l, m], k=k
147+
rgrid, beta_lm[l, m] * self._rho_sin[:, l, m], k=self._k
149148
)
150149
for m in range(M)
151150
]
@@ -374,18 +373,14 @@ def _precompute_radial_integrals(self, beta_lm):
374373
pref = -4.0 * numpy.pi / (2 * l + 1)
375374
for m in range(min(l + 1, M)):
376375
pref_blm = pref * beta_lm[l, m]
377-
for rho_arr, I_inner_store, I_outer_store in [
378-
(
379-
self._rho_cos[:, l, m],
380-
self._I_inner_cos,
381-
self._I_outer_cos,
382-
),
383-
(
384-
self._rho_sin[:, l, m],
385-
self._I_inner_sin,
386-
self._I_outer_sin,
387-
),
388-
]:
376+
# sin(m*phi) = 0 for m=0, so sin integrals are identically zero
377+
# and never accessed; skip their computation.
378+
pairs = [(self._rho_cos[:, l, m], self._I_inner_cos, self._I_outer_cos)]
379+
if m > 0:
380+
pairs.append(
381+
(self._rho_sin[:, l, m], self._I_inner_sin, self._I_outer_sin)
382+
)
383+
for rho_arr, I_inner_store, I_outer_store in pairs:
389384
# Inner integral: integrand = r^{l+2} * rho_lm(r)
390385
f_inner = rgrid ** (l + 2) * rho_arr
391386
# Use spline integration for higher accuracy than trapezoid

tests/test_MultipoleExpansionPotential.py

Lines changed: 107 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,48 @@ def test_scf_potential_cross_validation():
120120
)
121121

122122

123+
def test_scf_only_l0_l1_nonzero():
124+
"""For a density with only l=0 and l=1 harmonics, verify the multipole expansion
125+
has negligible l>=2 coefficients and correct l=0, l=1 radial functions."""
126+
Acos = numpy.zeros((3, 3, 1))
127+
Acos[0, 0, 0] = 1.0
128+
Acos[1, 0, 0] = 0.1
129+
Acos[0, 1, 0] = 0.05
130+
scf = SCFPotential(Acos=Acos, a=1.0)
131+
mp = MultipoleExpansionPotential(
132+
dens=scf, L=6, symmetry="axisymmetric", rgrid=_FINE_RGRID
133+
)
134+
# Only l=0 and l=1 should have non-negligible raw coefficients
135+
max_l0 = numpy.max(numpy.abs(mp._rho_cos[:, 0, 0]))
136+
max_l1 = numpy.max(numpy.abs(mp._rho_cos[:, 1, 0]))
137+
assert max_l0 > 0, "l=0 coefficient must be non-zero"
138+
assert max_l1 > 0, "l=1 coefficient must be non-zero"
139+
for l in range(2, mp._L):
140+
max_lx = numpy.max(numpy.abs(mp._rho_cos[:, l, 0]))
141+
assert max_lx < 1e-6 * max_l0, (
142+
f"l={l} coefficient should be negligible: {max_lx:.2e} vs l=0 max {max_l0:.2e}"
143+
)
144+
# Verify l=0 radial function: at z=0, P_1^0(0) = 0 so only l=0 contributes to density.
145+
# Therefore mp.dens(R, 0) should equal scf.dens(R, 0) to high precision.
146+
for R in [0.5, 1.0, 2.0]:
147+
d_mp = mp.dens(R, 0.0, use_physical=False)
148+
d_scf = scf.dens(R, 0.0, use_physical=False)
149+
assert abs(d_mp - d_scf) / abs(d_scf) < 1e-5, (
150+
f"l=0 radial function mismatch at midplane R={R}: mp={d_mp}, scf={d_scf}"
151+
)
152+
# Verify l=1 radial function: the l=1 term breaks z-symmetry (cos θ changes sign).
153+
# The difference dens(R,z) - dens(R,-z) isolates the odd-l (here l=1) contribution.
154+
for R, z in [(1.0, 1.0), (0.5, 1.0), (2.0, 0.5)]:
155+
diff_mp = mp.dens(R, z, use_physical=False) - mp.dens(R, -z, use_physical=False)
156+
diff_scf = scf.dens(R, z, use_physical=False) - scf.dens(
157+
R, -z, use_physical=False
158+
)
159+
assert abs(diff_scf) > 0, "SCF l=1 term should give z-asymmetric density"
160+
assert abs(diff_mp - diff_scf) / abs(diff_scf) < 1e-4, (
161+
f"l=1 radial function mismatch at R={R}, z={z}: diff_mp={diff_mp}, diff_scf={diff_scf}"
162+
)
163+
164+
123165
def test_scf_density_cross_validation():
124166
Acos = numpy.zeros((3, 3, 1))
125167
Acos[0, 0, 0] = 1.0
@@ -227,9 +269,9 @@ def test_2arg_lambda_input():
227269
# rho = amp/(4*pi) * a / (r * (r+a)^3) for HernquistPotential(amp=2, a=1)
228270
coeff = 1.0 / (2.0 * numpy.pi)
229271
mp = MultipoleExpansionPotential(
230-
dens=lambda R, z: coeff
231-
/ numpy.sqrt(R**2 + z**2)
232-
/ (1 + numpy.sqrt(R**2 + z**2)) ** 3,
272+
dens=lambda R, z: (
273+
coeff / numpy.sqrt(R**2 + z**2) / (1 + numpy.sqrt(R**2 + z**2)) ** 3
274+
),
233275
L=2,
234276
symmetry="spherical",
235277
rgrid=_FINE_RGRID,
@@ -358,9 +400,9 @@ def test_3arg_callable_density_input():
358400
"""Test that a 3-argument callable density (R, z, phi) without units works."""
359401
coeff = 1.0 / (2.0 * numpy.pi)
360402
mp = MultipoleExpansionPotential(
361-
dens=lambda R, z, phi: coeff
362-
/ numpy.sqrt(R**2 + z**2)
363-
/ (1 + numpy.sqrt(R**2 + z**2)) ** 3,
403+
dens=lambda R, z, phi: (
404+
coeff / numpy.sqrt(R**2 + z**2) / (1 + numpy.sqrt(R**2 + z**2)) ** 3
405+
),
364406
L=4,
365407
symmetry=None,
366408
rgrid=_DEFAULT_RGRID,
@@ -422,10 +464,12 @@ def test_2nd_derivs_on_z_axis():
422464
where dP/d(costheta) diverges for m>0, triggering the pole clamping."""
423465
coeff = 1.0 / (2.0 * numpy.pi)
424466
mp = MultipoleExpansionPotential(
425-
dens=lambda R, z, phi: coeff
426-
/ numpy.sqrt(R**2 + z**2)
427-
/ (1 + numpy.sqrt(R**2 + z**2)) ** 3
428-
* (1.0 + 0.1 * numpy.cos(2 * phi)),
467+
dens=lambda R, z, phi: (
468+
coeff
469+
/ numpy.sqrt(R**2 + z**2)
470+
/ (1 + numpy.sqrt(R**2 + z**2)) ** 3
471+
* (1.0 + 0.1 * numpy.cos(2 * phi))
472+
),
429473
L=6,
430474
symmetry=None,
431475
rgrid=_FINE_RGRID,
@@ -478,21 +522,14 @@ def test_spherical_2nd_derivs_match_hernquist():
478522
)
479523

480524

481-
def test_spline_degree_k_parameter():
482-
"""Test that the k parameter is passed through to splines."""
525+
def test_internal_spline_degree():
526+
"""Test that the internal spline degree is set to 3."""
483527
hp = HernquistPotential(amp=2.0, a=1.0)
484-
mp3 = MultipoleExpansionPotential(
485-
dens=hp, L=2, symmetry="spherical", rgrid=_FINE_RGRID, k=3
486-
)
487-
mp5 = MultipoleExpansionPotential(
488-
dens=hp, L=2, symmetry="spherical", rgrid=_FINE_RGRID, k=5
528+
mp = MultipoleExpansionPotential(
529+
dens=hp, L=2, symmetry="spherical", rgrid=_FINE_RGRID
489530
)
490-
assert mp3._k == 3
491-
assert mp5._k == 5
492-
# Both should give reasonable results
493-
for mp in [mp3, mp5]:
494-
val = mp.R2deriv(1.0, 0.5, use_physical=False)
495-
assert numpy.isfinite(val)
531+
assert mp._k == 3
532+
assert numpy.isfinite(mp.R2deriv(1.0, 0.5, use_physical=False))
496533

497534

498535
# --- Below/above grid extrapolation tests ---
@@ -581,3 +618,50 @@ def test_below_grid_density_clamped():
581618
assert d_below == d_at_rmin, (
582619
f"Density below grid should be clamped to rmin value: {d_below} != {d_at_rmin}"
583620
)
621+
622+
623+
# --- C code coverage ---
624+
625+
626+
def test_c_orbit_below_grid_l2():
627+
"""Cover C code line 255 (l=2 below-grid log formula) via orbit integration in C.
628+
Orbit integration uses Rforce/zforce in C, which calls below_grid_integrals
629+
for r < rmin. With L=4, l=2 is included and triggers the log-branch."""
630+
from galpy.orbit import Orbit
631+
632+
hp = HernquistPotential(amp=2.0, a=1.0)
633+
mp = MultipoleExpansionPotential(
634+
dens=hp,
635+
L=4, # includes l=0,1,2,3; l=2 hits the log branch in below_grid_integrals
636+
symmetry="axisymmetric",
637+
rgrid=numpy.geomspace(2.0, 20.0, 201), # rmin=2 > orbit's minimum r
638+
)
639+
# Orbit starting at R=1.0 < rmin=2.0 so forces are evaluated below the grid
640+
o = Orbit([1.0, 0.1, 1.0, 0.0, 0.1, 0.0])
641+
ts = numpy.linspace(0, 10, 101)
642+
o.integrate(ts, mp, method="leapfrog_c")
643+
assert numpy.all(numpy.isfinite(o.R(ts))), "Orbit R should be finite"
644+
assert numpy.all(numpy.isfinite(o.z(ts))), "Orbit z should be finite"
645+
646+
647+
def test_c_planar_liouville_below_grid_d2R():
648+
"""Cover C code lines 286-288 (d²R below-grid via EVAL_DERIV2 mode).
649+
A planar orbit.integrate_dxdv with a C integrator calls
650+
integratePlanarOrbit_dxdv in C, which calls MultipoleExpansionPotential-
651+
PlanarR2deriv → compute_multipole_spher_2nd_derivs → eval_radial_lm with
652+
EVAL_DERIV2. With rmin=2 > orbit radius the below-grid branch is hit."""
653+
from galpy.orbit import Orbit
654+
655+
hp = HernquistPotential(amp=2.0, a=1.0)
656+
mp = MultipoleExpansionPotential(
657+
dens=hp,
658+
L=4,
659+
symmetry="axisymmetric",
660+
rgrid=numpy.geomspace(2.0, 20.0, 201), # rmin=2 > orbit's R=1.0
661+
)
662+
# Planar orbit [R, vR, vT, phi] at R=1.0 < rmin=2.0
663+
o = Orbit([1.0, 0.1, 1.0, 0.5])
664+
ts = numpy.linspace(0, 5, 51)
665+
o.integrate_dxdv([1.0, 0.0, 0.0, 0.0], ts, mp, method="dopr54_c")
666+
result = o.getOrbit_dxdv()
667+
assert numpy.all(numpy.isfinite(result)), "integrate_dxdv result should be finite"

tests/test_actionAngle.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3552,6 +3552,15 @@ def test_actionAngleStaeckel_wSpherical_conserved_actions_c():
35523552
symmetry="spherical",
35533553
normalize=1.0,
35543554
)
3555+
mep_nonaxi = potential.MultipoleExpansionPotential(
3556+
dens=lambda R, z, phi: potential.HernquistPotential(normalize=1.0).dens(
3557+
R, z, phi
3558+
)
3559+
* (1.0 + 1e-9 * numpy.cos(phi)),
3560+
L=2,
3561+
symmetry=None,
3562+
normalize=1.0,
3563+
)
35553564
pots = [
35563565
lp,
35573566
lpb,
@@ -3579,6 +3588,7 @@ def test_actionAngleStaeckel_wSpherical_conserved_actions_c():
35793588
tpsp,
35803589
tpsp_beta3,
35813590
mep,
3591+
mep_nonaxi,
35823592
]
35833593
for pot in pots:
35843594
aAS = actionAngleStaeckel(pot=pot, c=True, delta=0.01)

tests/test_dynamfric.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -290,10 +290,10 @@ def test_dynamfric_c():
290290
normalize=1.0,
291291
),
292292
potential.MultipoleExpansionPotential(
293-
dens=lambda R, z, phi: potential.HernquistPotential(
294-
normalize=1.0, a=3.5
295-
).dens(R, z)
296-
* (1 + 0.01 * (numpy.cos(phi) + numpy.sin(phi))),
293+
dens=lambda R, z, phi: (
294+
potential.HernquistPotential(normalize=1.0, a=3.5).dens(R, z)
295+
* (1 + 0.01 * (numpy.cos(phi) + numpy.sin(phi)))
296+
),
297297
L=2,
298298
normalize=1.0,
299299
),

tests/test_quantity.py

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10324,12 +10324,14 @@ def test_potential_paramunits():
1032410324
)
1032510325
# MultipoleExpansionPotential with density in units (2-arg)
1032610326
pot = potential.MultipoleExpansionPotential(
10327-
dens=lambda R, z: coeff
10328-
/ numpy.sqrt(R**2 + z**2)
10329-
/ (1 + numpy.sqrt(R**2 + z**2)) ** 3
10330-
* dens_scale
10331-
* units.Msun
10332-
/ units.pc**3,
10327+
dens=lambda R, z: (
10328+
coeff
10329+
/ numpy.sqrt(R**2 + z**2)
10330+
/ (1 + numpy.sqrt(R**2 + z**2)) ** 3
10331+
* dens_scale
10332+
* units.Msun
10333+
/ units.pc**3
10334+
),
1033310335
symmetry="spherical",
1033410336
rgrid=numpy.geomspace(1e-2, 20, 201),
1033510337
ro=ro,
@@ -10346,12 +10348,14 @@ def test_potential_paramunits():
1034610348
)
1034710349
# MultipoleExpansionPotential with density in units (3-arg)
1034810350
pot = potential.MultipoleExpansionPotential(
10349-
dens=lambda R, z, phi: coeff
10350-
/ numpy.sqrt(R**2 + z**2)
10351-
/ (1 + numpy.sqrt(R**2 + z**2)) ** 3
10352-
* dens_scale
10353-
* units.Msun
10354-
/ units.pc**3,
10351+
dens=lambda R, z, phi: (
10352+
coeff
10353+
/ numpy.sqrt(R**2 + z**2)
10354+
/ (1 + numpy.sqrt(R**2 + z**2)) ** 3
10355+
* dens_scale
10356+
* units.Msun
10357+
/ units.pc**3
10358+
),
1035510359
L=4,
1035610360
symmetry=None,
1035710361
rgrid=numpy.geomspace(1e-2, 20, 201),

0 commit comments

Comments
 (0)