🐛 fix(potential): finite gradient & hessian at r=0 for radial potentials - #798
Merged
nstarman merged 2 commits intoAug 6, 2026
Merged
Conversation
`gradient` and `hessian` returned NaN at exactly r=0 for every potential written in terms of a spherical radius. `sqrt` has an infinite derivative where its argument vanishes, so the chain rule gives 0 * inf = NaN. Add `safe_sqrt`, which offsets the radicand by the smallest normal float (~1e-308): bitwise identical to `jnp.linalg.vector_norm` over 200k sampled vectors spanning 1e-6..1e6, but with a finite derivative at zero. Applied via `r_spherical` (12 modules) and at the ellipsoidal radii of TriaxialHernquist, Vogelsberger08TriaxialNFW, and LeeSutoTriaxialNFW. The offset must be applied to the primal, not just the tangent: the usual `jnp.where` and `custom_jvp` variants make the gradient finite but silently zero every Hessian at the origin. Recovering the correct Hessian of a cored profile needs Phi'(r)/r evaluated at a genuinely non-zero r. Also switch to `log1p` in the NFW, Burkert, Lee-Suto and Vogelsberger profiles. At r ~ 1e-155, `log(1 + x)` flushes to exactly 0, which made NFWPotential return -0 at the origin instead of -Gm/r_s; `log1p` is a precision fix at small r independently of this change. Plummer and Isochrone now give the exact analytic gradient and Hessian at the origin; cuspy profiles give zero gradient, which is the net force at the centre of a spherically symmetric system by symmetry. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Wrap `safe_sqrt(sum(square(x)))` in `safe_vector_norm` rather than expanding it at each call site (`r_spherical`, Vogelsberger08's `_r_tilde`, Lee-Suto's `potential`). The ellipsoidal radii keep calling `safe_sqrt` directly, since their quadratic form is not a plain norm. Also corrects a claim made in the previous commit: `safe_vector_norm` agrees with `jnp.linalg.vector_norm` to within a rounding ulp, NOT bitwise. The earlier check measured the unjitted expression; under `jax.jit` XLA fuses `sum(square(x))` differently from `vector_norm`'s scaled algorithm, and ~11% of sampled elements differ by 1 ulp (<=3e-16 relative). The offset itself is still a no-op at any meaningful magnitude. This also explains the ~5e-6 shift seen in the (already-failing) test_second_deriv reference comparison: 1-ulp input differences amplified through second-order autodiff of a chaotic 10k-step integration. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
gradientandhessianreturned NaN at exactlyr = 0for essentially everyradial potential. The cause is that a radius built as
sqrt(x² + y² + z²)has aninfinite derivative where its argument vanishes, so the chain rule produces
0 · inf = NaNfor any potential written in terms ofr—HernquistPotential,PlummerPotential,NFWPotential,LogarithmicPotential, and the rest.The fix
A single helper,
galax.potential._src.utils.safe_sqrt, offsets the radicand bythe smallest normal float:
A thin
safe_vector_normwrapssafe_sqrt(sum(square(x)))so call sites read asa norm.
r_sphericaluses it, covering the twelve modules that build a sphericalradius; the ellipsoidal radii in
TriaxialHernquistPotential,Vogelsberger08TriaxialNFWPotential, andLeeSutoTriaxialNFWPotentialcallsafe_sqrtdirectly, since their quadratic form is not a plain norm.The offset is ~1e-308, a no-op at any meaningful magnitude. Values agree with
jnp.linalg.vector_normto within a rounding ulp — not bitwise: underjax.jit, XLA fusessum(square(x))differently fromvector_norm's scaledalgorithm, and ~11% of sampled elements differ by 1 ulp (≤3e-16 relative).
safe_vector_normalso overflows above|x| ~ 1e154, whichvector_norm'sscaling avoids and no physical position reaches.
It has to be applied to the primal rather than only to the tangent. I tried the
usual
jnp.where"double-where" and acustom_jvpthat keeps the primal exact;both make the gradient finite but silently zero every Hessian at the origin,
and the
custom_jvpalso reports zero force at the centre of a Kepler pointmass. Recovering the correct Hessian of a cored profile needs
Φ'(r)/revaluated at a genuinely non-zero
r.Second cause found along the way
Making the radius differentiable exposed a
log(1 + x)underflow: atx ~ 1e-155,log(1 + x)flushes to exactly0, soNFWPotentialreturned-0at the origin instead of-Gm/r_s. Switched tolog1pin the NFW, Burkert,Lee–Suto, and Vogelsberger profiles — a genuine precision fix at small
rindependently of this PR.
Results at
r = 0PlummerPotential,IsochronePotentialGM/b³)HernquistPotential,TriaxialHernquistPotential,LogarithmicPotential,StoneOstriker15Potential,LM10Potential0— the net force at the centre of a spherically symmetric system, by symmetryNFWPotential,BurkertPotential,MilkyWayPotential,MilkyWayPotential2022,gNFWPotential-Gm/r_sCuspy profiles report a very large finite Hessian (~1e18) rather than NaN, which
reflects the genuine divergence of the tidal field at a
1/rcusp.Known limitation, pinned by a test
NFWPotentialandBurkertPotentialstill have a NaN gradient at theorigin, from a second and independent cause: their profile's
r-derivative isitself
0/0there (d/dr[log1p(x)/x] = [x/(1+x) − log1p(x)]/x²). Adifferentiable radius cannot help; those formulas need a small-
xseries. Theirvalues are now correct.
test_profile_formula_singularity_is_a_separate_issuepins this so that fixing the profiles trips the test.
Two genuinely singular potentials also change character:
KeplerPotentialandJaffePotentialdiverge atr = 0, and their value there goes from-infto alarge finite number. Kepler's gradient stays NaN. Happy to special-case these if
you'd rather they keep returning
-inf.Tests
tests/unit/potential/test_origin.py, coveringsafe_sqrtvalue-exactness andderivative finiteness, the exact analytic Hessian for cored profiles, zero
gradient for cuspy ones, and the known limitation above. Full suite passes; the
two
gNFWPotentialdoctests that asserted(nan, nan)at the origin wereupdated to the now-correct values.
CI note
tests/functional/test_mockstreamgenerator.py::test_second_derivfails on thisbranch, but it also fails identically on the base commit
d1a031bc— I ranit in a separate worktree to check:
d1a031bcThe committed
pytest-arraydiffreference is stale by ~1e-3 on darwin/arm64.This change moves the value by a further ~5e-6 relative: the 1-ulp norm
differences above, amplified through second-order autodiff of a chaotic
10,000-step integration.
test_first_deriv, same mechanism and samertol=1e-7, passes. I have deliberately not regenerated the referencefile.
The
mypypre-commit hook also reports 50 pre-existing errors in 23 files, anidentical count with and without this change; none are in the files touched here.
Full suite otherwise: 6047 passed, 182 skipped, 14 xfailed.