Source code for gala.potential.potential.symmetry

"""
Symmetry classes for gravitational potentials.

These classes define coordinate transformations between symmetry-specific
coordinates (e.g., spherical radius r, cylindrical (R, z)) and the internal
Cartesian representation used by potential calculations.
"""

from abc import ABC, abstractmethod

import numpy as np

__all__ = ["CylindricalSymmetry", "PotentialSymmetry", "SphericalSymmetry"]


[docs] class PotentialSymmetry(ABC): """ Base class for potential coordinate symmetries. This abstract base class defines the interface for converting between symmetry-specific coordinates and the Cartesian coordinates used internally by potential calculations. """ @property @abstractmethod def coord_names(self): """ Tuple of coordinate names for this symmetry. Returns ------- coord_names : tuple of str Names of the coordinates in this symmetry system. """
[docs] @abstractmethod def to_cartesian(self, **coords): """ Convert symmetry coordinates to Cartesian coordinates. Parameters ---------- **coords Coordinate values in the symmetry system. Keys must match the names in `coord_names`. Returns ------- xyz : `~astropy.units.Quantity` Cartesian coordinates with shape (3, n_points). If inputs are unitless, output will also be unitless. """
[docs] def validate_coords(self, **coords): """ Validate that the provided coordinates are appropriate for this symmetry. Parameters ---------- **coords Coordinate keyword arguments to validate. Raises ------ ValueError If the coordinates are invalid or incomplete. """ # Check for unexpected coordinates first - this gives a better error message extra = set(coords.keys()) - set(self.coord_names) if extra: raise ValueError( f"Invalid coordinate(s) for {self.__class__.__name__}: {extra}. " f"This symmetry only accepts: {self.coord_names}" ) # Check that all required coordinates are provided # For some symmetries (like cylindrical), certain coords may be optional # So we only check required ones exist required_coords = self.coord_names # Base class: all are required if hasattr(self, "_optional_coords"): required_coords = tuple( c for c in self.coord_names if c not in self._optional_coords ) missing_required = set(required_coords) - set(coords.keys()) if missing_required: raise ValueError( f"Missing required coordinate(s) for {self.__class__.__name__}: " f"{missing_required}. Required: {required_coords}" )
[docs] class SphericalSymmetry(PotentialSymmetry): """ Spherical symmetry for potentials with no angular dependence. This symmetry is appropriate for potentials that depend only on the spherical radius r = sqrt(x² + y² + z²). Examples -------- >>> import astropy.units as u >>> import numpy as np >>> from gala.potential import HernquistPotential >>> pot = HernquistPotential(m=1e10*u.Msun, c=1*u.kpc) >>> r = np.linspace(0.1, 10, 100) * u.kpc >>> energy = pot.energy(r=r) """ coord_names = ("r",)
[docs] def to_cartesian(self, r): """ Convert spherical radius to Cartesian coordinates. Parameters ---------- r : array-like, `~astropy.units.Quantity` Spherical radius values. Can be scalar or array. Returns ------- xyz : `~astropy.units.Quantity` or `~numpy.ndarray` Cartesian coordinates with shape (3, n_points). The x-component is set to r, while y and z are set to zero. Units are preserved if input has units. """ # Handle units has_units = hasattr(r, "unit") if has_units: unit = r.unit r = r.value else: unit = None # Handle scalar vs array r = np.asarray(r, dtype=np.float64) is_scalar = r.ndim == 0 if is_scalar: r = r.reshape(1) # Create Cartesian array: (x, y, z) = (r, 0, 0) xyz = np.zeros((3, r.size), dtype=np.float64) xyz[0] = r.ravel() # Reapply units if necessary if has_units: xyz = xyz * unit return xyz
[docs] def validate_coords(self, **coords): """ Validate spherical radius coordinate. Parameters ---------- **coords Coordinate keyword arguments. Must contain 'r'. Raises ------ ValueError If radius values are negative or invalid coordinates are provided. """ # Call parent validation first super().validate_coords(**coords) # Now validate the value r = coords["r"] r_val = r.value if hasattr(r, "value") else r if np.any(r_val < 0): raise ValueError("Spherical radius r must be non-negative")
[docs] class CylindricalSymmetry(PotentialSymmetry): """ Cylindrical (axisymmetric) symmetry for potentials with no azimuthal dependence. This symmetry is appropriate for potentials that depend only on the cylindrical radius R = sqrt(x² + y²) and height z, but not on the azimuthal angle phi. Examples -------- >>> import astropy.units as u >>> import numpy as np >>> from gala.potential import MiyamotoNagaiPotential >>> pot = MiyamotoNagaiPotential(m=1e11*u.Msun, a=3*u.kpc, b=0.3*u.kpc) >>> R = np.linspace(1, 15, 100) * u.kpc >>> z = np.zeros_like(R) >>> energy = pot.energy(R=R, z=z) >>> >>> # z can be omitted and defaults to zero >>> energy = pot.energy(R=R) """ coord_names = ("R", "z")
[docs] def to_cartesian(self, R, z=None): """ Convert cylindrical coordinates to Cartesian coordinates. Parameters ---------- R : array-like, `~astropy.units.Quantity` Cylindrical radius values. Can be scalar or array. z : array-like, `~astropy.units.Quantity`, optional Height above/below the midplane. If not provided, defaults to zero with the same shape as R. Must have the same shape as R if provided. Returns ------- xyz : `~astropy.units.Quantity` or `~numpy.ndarray` Cartesian coordinates with shape (3, n_points). The x-component is set to R, y to 0, and z to the provided z values. Units are preserved if input has units. Raises ------ ValueError If R and z have incompatible shapes. """ # Handle units for R has_units = hasattr(R, "unit") if has_units: unit = R.unit R = R.value else: unit = None # Ensure array and get shape R = np.atleast_1d(np.asarray(R, dtype=np.float64)) # Handle z coordinate if z is None: # Default to zeros with same shape as R z = np.zeros_like(R) else: # Extract units and values if hasattr(z, "unit"): if has_units and z.unit != unit: # Convert z to same units as R z = z.to(unit).value elif has_units: z = z.value else: # R has no units but z does - use z's units unit = z.unit z = z.value else: z = np.asarray(z, dtype=np.float64) z = np.atleast_1d(z) # Check shape compatibility if z.shape != R.shape: if z.size == 1: # Broadcast scalar z to match R z = np.full_like(R, z.item()) elif R.size == 1: # Broadcast scalar R to match z R = np.full_like(z, R.item()) else: raise ValueError( f"Incompatible shapes for R and z: R.shape={R.shape}, " f"z.shape={z.shape}. Shapes must match or one must be scalar." ) # Create Cartesian array: (x, y, z) = (R, 0, z) xyz = np.zeros((3, R.size), dtype=np.float64) xyz[0] = R.ravel() xyz[2] = z.ravel() # Reapply units if necessary if unit is not None: xyz = xyz * unit return xyz
[docs] def validate_coords(self, **coords): """ Validate cylindrical coordinates. Parameters ---------- **coords Coordinate keyword arguments. Must contain 'R', may contain 'z'. Raises ------ ValueError If R values are negative or invalid coordinates are provided. """ # Call parent validation first (handles checking for extra coords) # Mark 'z' as optional for this symmetry self._optional_coords = ("z",) super().validate_coords(**coords) # Now validate the values R = coords["R"] R_val = R.value if hasattr(R, "value") else R if np.any(R_val < 0): raise ValueError("Cylindrical radius R must be non-negative")