"""
Time-interpolated potential wrapper for Gala.
This module provides the TimeInterpolatedPotential class that allows interpolating
potential parameters, origin, and rotation over time using GSL splines.
"""
import copy
import numpy as np
from ....integrate.timespec import parse_time_specification
from ...common import PotentialParameter
from ..cpotential import CPotentialBase
from .cytimeinterp import TimeInterpolatedWrapper
__all__ = ["TimeInterpolatedPotential"]
_unsupported_cls = [
"EXPPotential",
"HenonHeilesPotential",
"NullPotential",
"MultipolePotential", # TODO?
"SphericalSplinePotential", # TODO
"CylSplinePotential", # TODO
]
[docs]
class TimeInterpolatedPotential(CPotentialBase, GSL_only=True):
"""
A time-interpolated wrapper for any potential class.
This class allows any PotentialBase subclass to have time-varying parameters,
origin, and rotation by interpolating between values specified at discrete
time knots using GSL splines.
Parameters
----------
potential_cls : PotentialBase subclass
The potential class to wrap.
time_knots : array_like
Array of time values for interpolation knots. Must be monotonically increasing.
interpolation_method : str, optional
Interpolation type. Any GSL interpolation type is supported:
https://www.gnu.org/software/gsl/doc/html/interp.html
Common options are:
- 'linear': Linear interpolation
- 'cspline': Cubic spline interpolation (default)
- 'akima': Akima spline interpolation. This avoids unphysical wiggles in
regions where the second derivative in the underlying curve is rapidly
changing, however it does not have a continuous second derivative.
- 'steffen': Steffen spline interpolation. This guarantees monotonicity of the
interpolating function between the given data points. Therefore, minima and
maxima can only occur exactly at the data points, and there can never be
spurious oscillations between data points.
units : UnitSystem, optional
Unit system for the potential
origin : array_like, optional
Either a constant origin vector, or an array of origin vectors with shape
(n_knots, n_dim).
R : array_like, optional
Either a constant rotation matrix, or an array of rotation matrices with
shape (n_knots, n_dim, n_dim).
**kwargs
Potential parameters. Each parameter can be either a constant value, or an array with shape (n_knots, *parameter_shape) for a time-varying parameter.
Examples
--------
Create a Kepler potential with time-varying mass:
>>> import astropy.units as u
>>> from gala.potential import KeplerPotential
>>> from gala.units import galactic
>>>
>>> # Time knots in Myr
>>> times = np.linspace(0, 100, 11) * u.Myr
>>> # Mass growing linearly with time
>>> masses = np.linspace(1e10, 2e10, 11) * u.Msun
>>>
>>> pot = TimeInterpolatedPotential(
... KeplerPotential, times, m=masses, units=galactic
... )
>>> pot.energy([1., 0, 0] * u.pc, t=0*u.Myr)
<Quantity [-44.98502151] kpc2 / Myr2>
>>> pot.energy([1., 0, 0] * u.pc, t=50*u.Myr)
<Quantity [-67.47753227] kpc2 / Myr2>
Create a potential with a time-varying rotation:
>>> # Rotation matrices for 90 degree rotation over 1 Gyr
>>> R_times = np.linspace(0, 1, 11) * u.Gyr
>>> angles = np.linspace(0, np.pi / 2, 11)
>>> Rs = np.array([R.from_rotvec([0, 0, angle]).as_matrix() for angle in angles])
>>> pot = gp.TimeInterpolatedPotential(
... gp.LongMuraliBarPotential,
... R_times,
... m=1e10 * u.Msun,
... a=3 * u.kpc,
... b=1 * u.kpc,
... c=0.5 * u.kpc,
... R=Rs,
... units=galactic,
... )
>>> pot.gradient([5., 0, 0] * u.kpc, t=0.*u.Gyr)[0, 0]
<Quantity 0.00207787 kpc / Myr2>
>>> pot.gradient([5., 0, 0] * u.kpc, t=0.5*u.Gyr)[0, 0]
<Quantity 0.0015879 kpc / Myr2>
"""
potential_cls = PotentialParameter(
"potential_cls", physical_type=None, python_only=True, convert=None
)
time_knots = PotentialParameter(
"time_knots", ndim=1, physical_type="time", python_only=True
)
interpolation_method = PotentialParameter(
"interpolation_method",
physical_type=None,
default="cspline",
python_only=True,
convert=str,
)
def __init__(
self,
*args,
units=None,
origin=None,
R=None,
**kwargs,
):
tmp, _ = self._parse_parameter_values(*args, strict=False, **kwargs)
if tmp["potential_cls"].__name__ in _unsupported_cls:
raise NotImplementedError(
f"TimeInterpolatedPotential does not currently support "
f"{tmp['potential_cls'].__name__}. Raise an issue on GitHub if "
f"you would like this to be implemented:"
"https://github.com/adrn/gala/issues"
)
# HACK: ._parameters exists on the class, not the instance, but this makes a
# *copy* exist on this instance...
self._parameters = copy.deepcopy(self._parameters)
# Copy parameter definitions from the wrapped potential class so the base class
# knows what parameters to expect in kwargs
self._potential_param_names = []
for attr_name in tmp["potential_cls"]._parameters:
attr = getattr(tmp["potential_cls"], attr_name)
if isinstance(attr, PotentialParameter):
setattr(self, attr_name, attr)
self._parameters[attr_name] = copy.copy(attr)
self._potential_param_names.append(attr_name)
# Extract extra init kwargs for wrapped potentials that need C parameter
# precomputation (e.g., MN3ExponentialDiskPotential's positive_density,
# sech2_z). These are popped from kwargs so they don't get passed to
# super().__init__().
_potential_cls = tmp["potential_cls"]
extra_defaults = getattr(_potential_cls, "_extra_parameter_defaults", {})
self._extra_wrapped_kwargs = {}
for k, default in extra_defaults.items():
self._extra_wrapped_kwargs[k] = kwargs.pop(k, default)
# Validate interpolation method vs number of knots
n_knots = len(tmp["time_knots"])
interp_method = tmp["interpolation_method"]
min_knots_required = {
"linear": 2,
"cspline": 3,
"akima": 5,
"steffen": 3,
}
if interp_method not in min_knots_required:
raise ValueError(
f"Interpolation method '{interp_method}' is not recognized. "
f"Supported methods are: {list(min_knots_required.keys())}"
)
min_required = min_knots_required.get(interp_method)
if n_knots < min_required:
raise ValueError(
f"Interpolation method '{interp_method}' requires at least "
f"{min_required} time knots, but only {n_knots} were provided. "
f"Either provide more time knots or use 'linear' interpolation."
)
# Determine dimensionality from potential class
self.ndim = (
tmp["potential_cls"].ndim if hasattr(tmp["potential_cls"], "ndim") else 3
)
# Determine which parameters have an extra ndim over expectation
self._interp_params = []
for param_name in self._potential_param_names:
pp = self._parameters[param_name]
if param_name not in kwargs:
if pp.default is None:
raise ValueError(
f"You must specify a value for potential parameter {param_name}"
)
continue
tmp = np.asanyarray(kwargs[param_name])
if tmp.ndim == (pp.ndim + 1):
# Validate that the first dimension matches the number of time knots
if tmp.shape[0] != n_knots:
raise ValueError(
f"Parameter '{param_name}' has shape {tmp.shape} but there are "
f"{n_knots} time knots. For time-interpolated parameters, the first "
f"dimension must match the number of time knots. If you intended this "
f"to be a constant parameter, pass a scalar value instead of a "
f"length-{tmp.shape[0]} array."
)
self._interp_params.append(param_name)
# increase ndim for validation
self._parameters[param_name].ndim += 1
# # Validate rotation matrices are orthogonal
# for i, rot_matrix in enumerate(rotation_matrices):
# if not self._is_orthogonal(rot_matrix):
# raise ValueError(f"Rotation matrix at index {i} is not orthogonal")
super().__init__(
*args,
units=units,
origin=origin,
R=R,
**kwargs,
)
# Additional validation of input:
if not np.all(np.diff(self.parameters["time_knots"]) > 0):
raise ValueError(
"time_knots must be monotonically increasing (and no duplicate times)"
)
def _setup_wrapper(self, **_):
"""Set up the time interpolation wrapper."""
# This is needed because we need to pass a dummy c_instance just to get the C
# functions for that potential.
# TODO: there may be a better way to pass the C functions...
potential_cls = self.parameters["potential_cls"]
extra_kwargs = getattr(self, "_extra_wrapped_kwargs", {})
wrapped_potential = potential_cls(
units=self.units,
**{
k: (
self.parameters[k][0]
if k in self._interp_params
else self.parameters[k]
)
for k in self._potential_param_names
},
**extra_kwargs,
)
origin_arrays = (
np.atleast_2d(self.origin)
if self.origin is not None
else np.zeros(self.ndim)[np.newaxis]
)
assert origin_arrays.ndim == 2
if self.R is not None:
R_arrays = self.R if self.R.ndim == 3 else self.R[np.newaxis]
else:
R_arrays = np.eye(3)[np.newaxis]
assert R_arrays.ndim == 3
# For potentials with non-trivial parameter transforms (e.g.,
# MN3ExponentialDiskPotential), the user-facing parameters are transformed into
# a different set of C parameters at the Python lyer. We handle these by
# instantiating the potential at each time knot to get the preprocessed C
# parameters, then interpolating those directly.
if getattr(potential_cls, "_requires_c_param_precompute", False):
n_knots = len(self.parameters["time_knots"])
# Determine how many elements come from user-facing parameters so we can
# strip them — _setup_wrapper appends them to c_parameters but the C
# function only uses the c_only (derived) portion.
n_user_param_elements = sum(
np.atleast_1d(wrapped_potential.parameters[k].value).size
for k in self._potential_param_names
)
n_c_only = len(wrapped_potential.c_parameters) - n_user_param_elements
c_params_list = []
for i in range(n_knots):
knot_kwargs = {
k: (
self.parameters[k][i]
if k in self._interp_params
else self.parameters[k]
)
for k in self._potential_param_names
}
knot_pot = potential_cls(
units=self.units, **knot_kwargs, **extra_kwargs
)
c_params_list.append(knot_pot.c_parameters[:n_c_only])
c_params_array = np.array(c_params_list) # shape: (n_knots, n_c_params)
n_c_params = c_params_array.shape[1]
# Build a synthetic named entry per C parameter element so the Cython layer
# sets up one spline per element in the correct positional order.
param_arrays = {}
param_element_counts = {}
synth_interp_params = []
any_interp = len(self._interp_params) > 0
for j in range(n_c_params):
name = f"_c{j}"
param_arrays[name] = c_params_array[:, j]
param_element_counts[name] = 1
if any_interp:
synth_interp_params.append(name)
self.c_instance = TimeInterpolatedWrapper(
self.G,
wrapped_potential.c_instance,
self.parameters["time_knots"].value,
synth_interp_params,
param_arrays,
param_element_counts,
np.array([]),
origins=origin_arrays,
rotation_matrices=R_arrays,
interpolation_method=self.parameters["interpolation_method"],
)
return
# Prepare parameter arrays for the C wrapper
# For multi-dimensional parameters that are time-interpolated,
# reshape them from (n_knots, d1, d2, ...) to (n_knots, d1*d2*...)
param_arrays = {}
param_element_counts = {} # Track how many elements each parameter has
# Calculate how many c_only parameters exist (e.g., nmax, lmax for SCF)
# These are prepended to c_parameters but not in the regular parameters dict
# TODO: need to detect potential parameters that aren't array type, like
# SphericalSplinePotential's spline_value_type
total_regular_param_size = 0
for k in self._potential_param_names:
param_val = np.atleast_1d(wrapped_potential.parameters[k].value)
total_regular_param_size += param_val.size
n_c_only_params = len(wrapped_potential.c_parameters) - total_regular_param_size
# Extract c_only parameters (they're constant, so just take from wrapped_potential)
if n_c_only_params > 0:
c_only_params = wrapped_potential.c_parameters[:n_c_only_params]
else:
c_only_params = np.array([])
for k in self._potential_param_names:
param_val = np.atleast_1d(self.parameters[k].value)
# If this is a time-interpolated multi-dimensional parameter,
# flatten the extra dimensions
if k in self._interp_params and param_val.ndim > 1:
n_knots = len(self.parameters["time_knots"])
# Reshape from (n_knots, d1, d2, ...) to (n_knots, d1*d2*...)
param_reshaped = param_val.reshape(n_knots, -1)
n_elements = param_reshaped.shape[1]
param_element_counts[k] = n_elements
param_arrays[k] = param_reshaped.ravel() # Flatten to 1D row-major
# For constant parameters, flatten if multi-dimensional
elif param_val.ndim > 1:
param_arrays[k] = param_val.ravel()
param_element_counts[k] = param_val.size
else:
param_arrays[k] = param_val
param_element_counts[k] = 1
self.c_instance = TimeInterpolatedWrapper(
self.G,
wrapped_potential.c_instance,
self.parameters["time_knots"].value,
self._interp_params,
param_arrays,
param_element_counts,
c_only_params,
origins=origin_arrays,
rotation_matrices=R_arrays,
interpolation_method=self.parameters["interpolation_method"],
)
@staticmethod
def _is_orthogonal(matrix, rtol=1e-5, atol=1e-8):
"""Check if a matrix is orthogonal."""
return np.allclose(
matrix @ matrix.T, np.eye(matrix.shape[0]), rtol=rtol, atol=atol
)
[docs]
def replicate(self, **kwargs):
"""Create a copy of this potential with possibly different parameters."""
# TODO: update this
# Extract current parameters
new_kwargs = {}
# Copy time-varying parameters
for param_name, param_array in self._param_arrays.items():
new_kwargs[param_name] = kwargs.pop(param_name, param_array)
# Copy other parameters
new_kwargs.update(kwargs)
return self.__class__(
self._potential_cls,
self._time_knots,
interpolation_method=self._interpolation_method,
units=self.units,
origin=self._origin_arrays,
R=self._rotation_matrices,
**new_kwargs,
)
[docs]
def integrate_orbit(
self,
w0,
Integrator=None,
Integrator_kwargs=None,
cython_if_possible=True,
save_all=True,
**time_spec,
):
"""
Integrate an orbit in the current potential using the integrator class
provided. Uses same time specification as `Integrator()` -- see
the documentation for `gala.integrate` for more information.
Parameters
----------
w0 : `~gala.dynamics.PhaseSpacePosition`, array_like
Initial conditions.
Integrator : `~gala.integrate.Integrator` (optional)
Integrator class to use.
Integrator_kwargs : dict (optional)
Any extra keyword arguments to pass to the integrator class
when initializing. Only works in non-Cython mode.
cython_if_possible : bool (optional)
If there is a Cython version of the integrator implemented,
and the potential object has a C instance, using Cython
will be *much* faster.
save_all : bool (optional)
Controls whether to store the phase-space position at all intermediate
timesteps. Set to False to store only the final values (i.e. the
phase-space position(s) at the final timestep). Default is True.
**time_spec
Specification of how long to integrate. See documentation
for `~gala.integrate.parse_time_specification`.
Returns
-------
orbit : `~gala.dynamics.Orbit`
"""
if Integrator_kwargs is None:
Integrator_kwargs = {}
t = parse_time_specification(self.units, **time_spec)
# ensure timesteps are within the range of time_knots
knot_times = self.parameters["time_knots"].decompose(self.units).value
t_min, t_max = knot_times.min(), knot_times.max()
if np.any(t < t_min) or np.any(t > t_max):
raise ValueError(
"Integration times must be within the range of the Potential's interpolation range "
f"that you defined: [{t_min}, {t_max}] {self.units['time']}, "
f"your orbit integration range is [{min(t)}, {max(t)}] {self.units['time']}"
)
return super().integrate_orbit(
w0,
Integrator=Integrator,
Integrator_kwargs=Integrator_kwargs,
cython_if_possible=cython_if_possible,
save_all=save_all,
t=t,
)
def __repr__(self):
return (
f"<{self.__class__.__name__}: "
f"{self.parameters['potential_cls'].__name__} "
f"interpolation_method='{self.parameters['interpolation_method']}')>"
)