Add Gaussian, TriaxialGaussian, and AxisymmetricGaussian potentials - #796
Conversation
There was a problem hiding this comment.
Pull request overview
Adds a new Gaussian-density potential family to galax.potential, including spherical, triaxial, and axisymmetric variants, and introduces unit tests that cross-check against galpy for validation.
Changes:
- Added
GaussianPotentialwith closed-form potential/mass/density helpers. - Added
TriaxialGaussianPotential(ellipsoidal quadrature) andAxisymmetricGaussianPotential(q1=1 specialization). - Added galpy cross-check helpers and new unit tests for all three potentials.
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/unit/potential/builtin/test_triaxialgaussian.py | Adds unit tests for TriaxialGaussianPotential, including galpy equivalence checks. |
| tests/unit/potential/builtin/test_gaussian.py | Adds unit tests for GaussianPotential, including galpy equivalence checks. |
| tests/unit/potential/builtin/test_axisymmetricgaussian.py | Adds unit tests for AxisymmetricGaussianPotential, including galpy equivalence checks. |
| tests/unit/potential/builtin/test_common.py | Adds a shared helper to compare Gaussian(-family) potentials against galpy. |
| src/galax/potential/_src/builtin/gaussian/base.py | Implements spherical GaussianPotential and closed-form helper functions. |
| src/galax/potential/_src/builtin/gaussian/triaxial.py | Implements TriaxialGaussianPotential via Gauss–Legendre quadrature. |
| src/galax/potential/_src/builtin/gaussian/axisymmetric.py | Implements AxisymmetricGaussianPotential as the q1=1 quadrature specialization. |
| src/galax/potential/_src/builtin/gaussian/init.py | Exposes the Gaussian potential classes from the gaussian package. |
| src/galax/potential/_src/builtin/init.py | Re-exports the new Gaussian potential classes from the builtin collection. |
| src/galax/potential/init.py | Re-exports the new Gaussian potential classes at the top-level galax.potential API. |
Suppressed comments (2)
src/galax/potential/_src/builtin/gaussian/triaxial.py:66
mis documented as the “Total mass”, but the implementation (viarho0 = m / ((2π)^{3/2} r_s^3)and the ellipsoidal coordinates) implies the true total mass scales with shape: M_tot = m * q1 * q2 (also reflected in the galpy cross-check helper). This should be clarified to avoid users passing a total mass and getting a different normalization.
dimensions="mass", doc="Total mass of the potential."
src/galax/potential/_src/builtin/gaussian/axisymmetric.py:83
mis documented as the “Total mass”, but (as in the triaxial class) the normalization implied byrho0 = m / ((2π)^{3/2} r_s^3)means the true total mass depends on flattening: M_tot = m * q2 for the axisymmetric case. Clarifying this will prevent accidental mis-normalization by callers.
dimensions="mass", doc="Total mass of the potential."
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…Gaussian potentials Rename `m` to `m_tot` across GaussianPotential/TriaxialGaussianPotential/ AxisymmetricGaussianPotential, and fix the triaxial/axisymmetric density normalization to divide by q1*q2 (resp. q2) so m_tot is the true total mass for any flattening, not just in the spherical case -- previously the parameter silently meant "mass scale" (matching the infinite-mass NFW convention), which is misleading for a profile with finite mass. Also: - Fix the 0/0 NaN in GaussianPotential.potential() at r=0 by substituting the analytic limit explicitly. - Fix a stray G in the TriaxialGaussianPotential/AxisymmetricGaussianPotential density docstrings (copy/paste from the potential formula). - Consolidate the GaussLegendreIntegrator, previously duplicated across nfw/triaxial.py and the two new Gaussian classes, into the shared builtin/../utils.py alongside the other cross-potential helpers. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
Thanks for the thorough review! Pushed two follow-up commits addressing the feedback: Mass parameter (@nstarman, Copilot):
Stray
Duplicated
All existing + updated tests pass (1026 passed locally). |
|
@prashjet if you rebase on main it should fix the Format CI job. |
…Gaussian potentials Rename `m` to `m_tot` across GaussianPotential/TriaxialGaussianPotential/ AxisymmetricGaussianPotential, and fix the triaxial/axisymmetric density normalization to divide by q1*q2 (resp. q2) so m_tot is the true total mass for any flattening, not just in the spherical case -- previously the parameter silently meant "mass scale" (matching the infinite-mass NFW convention), which is misleading for a profile with finite mass. Also: - Fix the 0/0 NaN in GaussianPotential.potential() at r=0 by substituting the analytic limit explicitly. - Fix a stray G in the TriaxialGaussianPotential/AxisymmetricGaussianPotential density docstrings (copy/paste from the potential formula). - Consolidate the GaussLegendreIntegrator, previously duplicated across nfw/triaxial.py and the two new Gaussian classes, into the shared builtin/../utils.py alongside the other cross-potential helpers. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2fbe925 to
4bac560
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 12 out of 12 changed files in this pull request and generated 1 comment.
Suppressed comments (1)
src/galax/potential/_src/builtin/gaussian/base.py:104
- The density expression uses an undefined symbol
u; every other Gaussian density formula in this module (e.g. the standalonedensityfunction docstring and the class docstring) expresses the exponent asr^2 / (2 r_s^2). Usinguhere is inconsistent and, sinceuis also the importedunxtalias in this file, potentially confusing.
\rho(r) = \rho_0 \exp\left(-\frac{u^2}{2}\right)
The mypy hook's additional_dependencies deliberately excludes quax, quaxed, unxt, coordinax, and jax to keep the isolated hook environment fast -- everything they touch resolves to Any there. That's why galax.utils._jax.quaxify wrapped quax.quaxify in an explicit cast: without quax installed, decorating with it directly makes the decorated function untyped, which is what #796's review comment ran into. Installing quax specifically (not the rest -- that pulls in the whole JAX stack and defeats the point of the minimal hook) lets mypy follow real types through every quax-touched call chain for the first time. That surfaced 191 pre-existing errors across 60 files that were previously masked, mostly: - `no-any-return` from jax's own functions (jax.jit, jax.grad, jax.lax.scan, .min()/.max()) being untyped under this project's ignore_missing_imports policy for jax.* -- fixed with `# type: ignore[no-any-return]`, matching the one existing precedent in this codebase (coordinates/_src/base.py). - redundant casts that predated quax being resolvable, now removed, including the galax.utils._jax.quaxify wrapper itself. - `override`/`misc` from equinox's AbstractVar/AbstractClassVar + property pattern and plum's multiple-dispatch redefinitions -- ignored with the matching error code. - two real fixes: _error_if_not_all_constant_parameters in the galpy interop is now generic so it preserves the caller's specific potential subtype instead of widening to AbstractPotential; and NBodyField._call used `self.eps.ustrip(...)`/`self.masses.ustrip(...)` as method calls, which would crash if a bare Array (not a Quantity) were passed for either field -- switched to the polymorphic `u.ustrip(AllowValue, ...)` free function already used two lines above for the same purpose. - `integrate_field`'s `@ft.partial(eqx.filter_jit)` decorator carried zero bound arguments, making it a no-op wrapper that also breaks mypy's ability to see through functools.partial's ParamSpec; changed to bare `@eqx.filter_jit`, matching the pattern already used elsewhere in this codebase. Verified with the actual pre-commit hook (not just a local mypy invocation): `pre-commit run --all-files` is clean, the full test suite passes, and doctests on every touched file pass. Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
…est, ruff) - Add the three new Gaussian potential classes to the smoke test's expected_all list for galax.potential -- this test wasn't covered by the unit test runs used during development. - Fix a ruff UP035/I001 finding in test_gaussian.py (typing_extensions import consolidated into typing, now that Python 3.12+ is required). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds GaussianPotential (spherical Gaussian density, closed-form via erf) and TriaxialGaussianPotential (Chandrasekhar ellipsoidal quadrature, mirroring the NFW/TriaxialNFW structure). Cross-checked against galpy's TriaxialGaussianPotential in tests.
Adds the q1=1 special case of TriaxialGaussianPotential as its own class (single shape parameter q2), for API clarity in the common oblate/prolate case. Cross-checked against galpy and against TriaxialGaussianPotential(q1=1) in tests. Profiling showed no measurable speedup over TriaxialGaussianPotential with q1=1 fixed, despite the simpler quadrature integrand -- this class earns its keep on API ergonomics alone, not performance.
…Gaussian potentials Rename `m` to `m_tot` across GaussianPotential/TriaxialGaussianPotential/ AxisymmetricGaussianPotential, and fix the triaxial/axisymmetric density normalization to divide by q1*q2 (resp. q2) so m_tot is the true total mass for any flattening, not just in the spherical case -- previously the parameter silently meant "mass scale" (matching the infinite-mass NFW convention), which is misleading for a profile with finite mass. Also: - Fix the 0/0 NaN in GaussianPotential.potential() at r=0 by substituting the analytic limit explicitly. - Fix a stray G in the TriaxialGaussianPotential/AxisymmetricGaussianPotential density docstrings (copy/paste from the potential formula). - Consolidate the GaussLegendreIntegrator, previously duplicated across nfw/triaxial.py and the two new Gaussian classes, into the shared builtin/../utils.py alongside the other cross-potential helpers. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The integration_order field and __post_init__ leggauss setup were still duplicated across TriaxialNFWPotential and the two new Gaussian classes even after sharing GaussLegendreIntegrator itself. Add a GaussLegendreIntegrator.for_order() factory in utils.py so each class's __post_init__ is a one-liner. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…est, ruff) - Add the three new Gaussian potential classes to the smoke test's expected_all list for galax.potential -- this test wasn't covered by the unit test runs used during development. - Fix a ruff UP035/I001 finding in test_gaussian.py (typing_extensions import consolidated into typing, now that Python 3.12+ is required). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…docstring The exponent used an undefined u, inconsistent with the rest of the module (and confusable with the unxt u alias imported in this file). Match the standalone density() function's r^2 / (2 r_s^2) notation. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Rebasing onto the current upstream main (post GalacticDynamics#799/GalacticDynamics#804/GalacticDynamics#805) surfaced a few things the rebase itself couldn't fix, since these files are new on this branch and had no conflicting upstream counterpart to merge against: - galax._custom_types -> galax.potential.custom_types and galax._interop.optional_deps -> galax.interop.optional_deps, matching the renamed modules. - Added # type: ignore[no-any-return] to match the stricter mypy config that landed alongside the refactor, mirroring the existing NFW files. - Dropped a now-unused # type: ignore[misc] on GaussLegendreIntegrator. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
9d0006b to
c25e5af
Compare
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #796 +/- ##
==========================================
+ Coverage 96.00% 96.14% +0.13%
==========================================
Files 153 157 +4
Lines 6079 6298 +219
==========================================
+ Hits 5836 6055 +219
Misses 243 243 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 13 out of 13 changed files in this pull request and generated no new comments.
Suppressed comments (2)
src/galax/potential/_src/builtin/gaussian/base.py:192
- This doctest is missing the blank line between the "A quick sanity check:" prose and the
>>>prompt, unlikerho0_of_mjust above (lines 169-171). In reStructuredText a doctest block must be preceded by a blank line, so as written the>>>will render as part of the preceding paragraph rather than as an interactive example. Add the blank line for consistency and correct rendering.
A quick sanity check:
>>> m_of_rho0({"rho0": 1.0, "r_s": 1.0}) - (2 * jnp.pi) ** 1.5
tests/unit/potential/builtin/test_axisymmetricgaussian.py:106
- The PR description states that
AxisymmetricGaussianPotentialis "additionally cross-checked againstTriaxialGaussianPotential(q1=1)", but no such equivalence test is present in this file (or anywhere in the test suite). The committed tests only compare against hard-coded expected values and against galpy; andTestTriaxialGaussianPotentialusesq1=1.1, so the two are never exercised at the equal configuration. Since this class is justified purely as theq1=1special case ofTriaxialGaussianPotential, an explicit equivalence test would be the most valuable guard against regressions. Either add that cross-check test or update the PR description to match what is committed.
@pytest.mark.skipif(not OptDeps.GALPY.installed, reason="requires galpy")
def test_method_galpy(
self, pot: gp.AxisymmetricGaussianPotential, x: gt.QuSz3
) -> None:
"""Test the equivalence of potential/density between galpy and galax."""
assert_gaussian_matches_galpy(pot, x)
Adds three new potential classes:
GaussianPotential: spherical Gaussian density profile, closed-form via erf.TriaxialGaussianPotential: Chandrasekhar ellipsoidal quadrature, mirroring the NFW/TriaxialNFW structure.AxisymmetricGaussianPotential: the q1=1 special case ofTriaxialGaussianPotentialas its own class (single shape parameter q2), for API clarity in the common oblate/prolate case.All three are cross-checked against galpy in tests;
AxisymmetricGaussianPotentialis additionally cross-checked againstTriaxialGaussianPotential(q1=1). Profiling showed no measurable speedup for the axisymmetric class over the triaxial one with q1 fixed — it earns its keep on API ergonomics alone.Note: this overlaps with #756, which adds a spherical
GaussianPotential. This PR additionally adds triaxial and axisymmetric variants under agaussian/package rather than a single module. Happy to rebase on #756 or coordinate however maintainers prefer.