@@ -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+
123165def 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"
0 commit comments