"""Profile base class module.
This module provides the core base classes from which all other
profiles are constructed and includes skeletons for developers to
use when adding new profiles to the package.
"""
from abc import ABC, abstractmethod
from collections.abc import Callable
from functools import wraps
from typing import TYPE_CHECKING, Any, Literal
import sympy as sp
import unyt
from pisces._generic import RegistryMeta
from pisces._registries import __default_profile_registry__
from pisces.utilities.symbols import lambdify_expression
from ._exceptions import ProfileClassSetupError
if TYPE_CHECKING:
from pisces._generic import Registry
# ============================= #
# Core Structures #
# ============================= #
# These are the core structures for defining
# the behavior of profiles in Pisces across a
# variety of different scenarios.
def derived_profile(name: str | None = None) -> classmethod:
"""Mark a class method as a derived profile generator.
Derived profiles define secondary symbolic profiles (e.g., gradients, potentials)
associated with this class. When decorated, the method is automatically registered
at class construction and the profile can be instantiated on demand via
:meth:`~profiles.base.BaseProfile.get_derived_profile`.
The decorated method must be a ``@classmethod`` with the following signature:
.. code-block:: python
@classmethod
def my_derived(
cls, *variable_symbols, **parameter_symbols
):
return (
symbolic_func,
units_func,
variables,
parameters,
)
The method must return a tuple:
- ``symbolic_func(*variable_symbols, **parameter_symbols)`` — Callable generating a SymPy expression
- ``units_func(*variable_units, **parameter_units)`` — Callable computing units with unyt
- ``variables`` — List of str, independent variables for the derived profile
- ``parameters`` — Dict of str: default parameters for the derived profile
Parameters
----------
name : str, optional
A custom name for the derived profile. Defaults to the method name.
Returns
-------
classmethod
The decorated classmethod, with registration metadata.
Notes
-----
- Use :meth:`~profiles.base.BaseProfile.get_derived_profile` to access an instantiated version.
Raises
------
TypeError
If applied to a method that is not a @classmethod.
"""
def decorator(func):
"""Add the wrapper around the class expression."""
if not isinstance(func, classmethod):
raise TypeError("The @derived_profile decorator must be applied to a @classmethod.")
original_func = func.__func__ # Extract underlying function from classmethod
@wraps(original_func)
def wrapper(*args, **kwargs):
"""Wrap original function."""
return original_func(*args, **kwargs)
# Rewrap as a classmethod
wrapped_method = classmethod(wrapper)
# Attach metadata
wrapped_method.derived_profile = True
wrapped_method.expression_name = name or original_func.__name__
return wrapped_method
return decorator
class _ProfileMeta(RegistryMeta):
def __new__(mcs, name, bases, namespace, **kwargs):
"""Generate a new ProfileMeta class.
This procedure creates a generic
object subclass before performing the following 3 procedures:
1. Check for abstraction: if the class is abstract, we just return the
generic ``object`` baseclass.
2. Validate the class: Check for required profile structures.
3. Setup the class: Construct all of the relevant symbolics, etc.
"""
# --- Generate the generic class --- #
# Perform the standard operation to create an `object` descended
# base class which can then be altered as needed.
cls_object: type[BaseProfile] = super().__new__(mcs, name, bases, namespace, **kwargs)
# --- Check for abstraction --- #
_cls_is_abstract = getattr(cls_object, "__IS_ABSTRACT__", False)
if _cls_is_abstract:
# We do not process this class at all.
return cls_object
# --- Validate Class --- #
mcs.validate_profile_class(cls_object)
# -- Setup the Class -- #
# We now setup the class and register it.
mcs.__register_class__(cls_object)
if cls_object.__SETUP_AT__ == "import":
cls_object.__cls_setup__()
return cls_object
@staticmethod
def validate_profile_class(cls):
"""Validate a new profile class.
This includes determining the number of dimensions and ensuring
that bounds and coordinates are all accurate.
"""
# Check the new class for the required attributes that all classes should have.
__required_elements__ = [
"__VARIABLES__",
"__PARAMETERS__",
"__cls_var_symbols__",
"__cls_param_symbols__",
]
for _re_ in __required_elements__:
if not hasattr(cls, _re_):
raise ProfileClassSetupError(
f"Profile class {cls.__name__} does not define or inherit an expected class attribute: `{_re_}`."
)
# Ensure that we have specified axes and that they have the correct length.
# The AXES_BOUNDS need to be validated to ensure that they have the correct
# structure and only specify valid conventions for boundaries.
if cls.__VARIABLES__ is None:
raise ProfileClassSetupError(
f"Profile class {cls.__name__} does not define a set of variablesusing the `__VARIABLES__` attribute."
)
class BaseProfile(ABC, metaclass=_ProfileMeta):
"""Abstract base class for constructing symbolic profile functions.
:class:`~profiles.base.BaseProfile` provides the infrastructure for defining parameterized, symbolic expressions
that can be evaluated numerically with units. Subclasses define their behavior by specifying
independent variables, parameters, and symbolic expressions. Additional derived profiles,
such as gradients or potentials, can be attached via :func:`derived_profile`.
This class integrates:
- SymPy for symbolic construction
- unyt for unit-aware parameter handling
- Automated symbolic setup and expression management via a custom metaclass
- Optional serialization to and from minimal dictionaries
- Dynamic construction of secondary (derived) profiles
Intended Usage
--------------
Subclasses must define the following:
- `__VARIABLES__` : list of str
Independent variables of the profile.
- `__PARAMETERS__` : dict of str, default values
Named parameters for the profile function. These are provided at instantiation with units.
- `__function__` : abstractmethod
Returns the symbolic expression as a SymPy object.
- `__function_units__` : abstractmethod
Returns the dimensional units of the profile output.
Additional customization is possible using:
- `__IS_ABSTRACT__` : bool
Prevents symbolic setup and registration for base or template classes.
- `__REGISTER__` : bool
Controls whether the class is added to the global profile registry.
- `__SETUP_AT__` : "init" or "import"
Controls when symbolic setup occurs.
- `__DERIVED_BASE__` : type
Specifies the base class for any derived profiles.
Derived Profiles
----------------
Developers can attach secondary symbolic profiles using the `@derived_profile` decorator.
These are dynamically constructed as subclasses and accessed via `get_derived_profile`.
Example
-------
.. code-block:: python
class MyProfile(BaseProfile):
__IS_ABSTRACT__ = False
__VARIABLES__ = ["x"]
__PARAMETERS__ = {"a": 1.0}
def __function__(self, x, a):
return a * x**2
def __function_units__(self, x_unit, a_unit):
return a_unit * x_unit**2
@derived_profile()
@classmethod
def gradient(cls, x, a):
def func(x, a):
return 2 * a * x
def units(x_unit, a_unit):
return a_unit * x_unit
return func, units, ["x"], {"a": 1.0}
Calling Behavior
----------------
Instances of a profile are callable using ``profile(*variables, units=None, no_units=False)``. This evaluates the
profile's numerical expression at the given coordinate values with proper unit handling:
- If input variables have attached units (e.g., ``unyt_quantity``), units are extracted automatically.
- If input variables are scalars, unit-less evaluation is assumed.
- The ``units`` argument can be used to force the output to specific units (e.g., ``units="Msun/kpc**3"``).
- If ``no_units=True``, the raw numerical result is returned without units attached.
Notes
-----
- Subclasses must explicitly disable `__IS_ABSTRACT__` to trigger full setup.
- Symbolic variables and parameter symbols are generated during class setup.
- Parameters provided at initialization can include units and are stored internally as magnitude/unit pairs.
- Numerical evaluation supports unit-aware input and output via `__call__`.
"""
# =============================== #
# Class Level Flags #
# =============================== #
# Class flags for BaseProfile descendants
# allow developers to control and customize
# subclass behavior during metaclass processing,
# symbolic setup, and profile registration.
# CONVENTION: All class-level flags are defined
# with ALL_CAPS naming for clarity and consistency.
__IS_ABSTRACT__: bool = True
"""
bool : Marks this profile class as abstract.
If set to ``True``, the class is treated as an abstract template.
The metaclass will skip validation, symbolic setup, and registration steps
during class construction.
This flag is intended for base classes or incomplete implementations that
define a structural framework but should not be directly instantiated or
registered in the profile registry.
Notes
-----
- Concrete subclasses **must** set ``__IS_ABSTRACT__ = False`` to enable full setup.
- Failure to do so results in bypassing all symbolic infrastructure.
"""
__DEFAULT_REGISTRY__: "Registry" = __default_profile_registry__
__SETUP_AT__: Literal["init", "import"] = "init"
"""
str : Controls when symbolic setup occurs for the profile class.
Valid options are:
- ``"import"`` : Symbolic setup runs during class construction (at import time).
- ``"init"`` : Symbolic setup is deferred until the first instance is initialized.
Use ``"import"`` for:
- Profiles that require immediate symbolic availability
- Classes where derived profiles or symbolic methods depend on setup at import time
Use ``"init"`` for:
- Profiles that should minimize startup cost
- Cases where symbolic setup is only required on first use
Notes
-----
- Deferred setup with ``"init"`` is idempotent; setup only runs once, even across instances.
- Changing this flag does not affect already constructed classes.
"""
__DERIVED_BASE__: type["BaseProfile"] | None = None
"""
type or None : Specifies the base class for dynamically generated derived profiles.
When using the ``@derived_profile`` decorator, derived profiles are constructed
as subclasses of this type.
Defaults to ``BaseProfile`` if not specified, but developers can override to:
- Enforce specific behavior in derived profiles
- Introduce specialized methods or infrastructure
- Provide alternative symbolic backends
Examples
--------
To force all derived profiles of a custom base to inherit from a shared subclass:
.. code-block:: python
class MyBaseProfile(BaseProfile):
__IS_ABSTRACT__ = True
__DERIVED_BASE__ = MyDerivedBase
Notes
-----
- Derived profiles respect the ``__DERIVED_BASE__`` of the defining class,
not necessarily the base-most ancestor.
- The provided type must itself be a subclass of ``BaseProfile`` or compatible.
"""
# =============================== #
# Class Attributes #
# =============================== #
# These settings are used to determine how the
# profile will behave from a mathematical standpoint. These
# should be set in almost all subclasses to define the
# relevant variables, parameters, etc.
__VARIABLES__: list[str] = None
"""list of str: The independent variables of the profile function.
These are converted at instantiation to sympy variables in order to construct
the relevant profile.
"""
__PARAMETERS__: dict[str, Any] = {}
""" dict of str, Any: The parameters which define this profile and its
behavior. This should include any scale lengths, masses, etc. Each of the parameters
is set by specifying the corresponding kwarg when initializing the class.
"""
__VARIABLES_LATEX__: list[str] = None
"""list of str: LaTeX representations of each of the variables. This is an optional
parameter that will be filled with the actual variable strings if not provided.
"""
__PARAMETERS_LATEX__: dict[str, str] = None
"""dict of str,str: Optional LaTeX representations of each of the parameters in
the profile."""
# ============================== #
# Class Initialization Vars #
# ============================== #
# These components of the class should always be left as
# sentinels and then filled during the metaclass initialization.
# This is where sympy symbols are stored.
#
# This section also includes the methods accessed during
# class construction so that they can be overwritten as needed
# in subclasses.
__cls_var_symbols__: list[sp.Symbol] = None
__cls_param_symbols__: dict[str, sp.Symbol] = {}
__cls_derived_profiles__: dict[str, Any] = {}
__cls_is_setup_flag__: bool = False
@classmethod
def __cls_setup_symbols__(cls):
"""Create the symbols for the parameters and the variables of the class.
This is executed by the metaclass when the class is generated vis-a-vis
the ``__setup_class__`` method. It is the first step in class creation.
Requirements
------------
Whatever form this method takes, it must fill the ``__cls_var_symbols__``
and ``__cls_param_symbols__`` successfully with SymPy symbols representing
each of the relevant variables and parameters. ``__cls_var_symbols__`` should
be a list of symbols while ``__cls_param_symbols__`` should be a dictionary
with the string of the parameter and then the symbol as the value.
"""
# --- DEFAULT --- #
cls.__cls_var_symbols__ = [sp.Symbol(_var_) for _var_ in cls.__VARIABLES__]
cls.__cls_param_symbols__ = {_param_name_: sp.Symbol(_param_name_) for _param_name_ in cls.__PARAMETERS__}
@classmethod
def __cls_setup_implicit_symbolic_attributes__(cls):
"""Register all class-level symbolic expressions defined with @derived_profile.
Scans the method resolution order (MRO) of the class and identifies class methods
tagged as symbolic expressions.
Adds them to the `__cls_symbolic_attrs__` dictionary. The expressions are evaluated
on demand when requested via `get_derived_profile()`.
Notes
-----
This only registers the expression. Evaluation is deferred until the first access.
"""
# Set the derived profiles blank so that we do not
# get overlap between constructors / behavior between classes.
cls.__cls_derived_profiles__ = {}
# begin the iteration through the class __mro__ to find objects
# in the entire inheritance structure.
seen = set()
for base in reversed(cls.__mro__): # reversed to ensure subclass -> baseclass
# Check if we need to search this element of the __mro__. We only exit if we find
# `object` because it's not going to have any worthwhile symbolics.
if base is object:
continue
# Check this element of the __mro__ for any relevant elements that
# we might want to attach to this class.
for attr_name, method in base.__dict__.items():
# Check if we have any interest in processing these methods. If the method is already
# seen, then we skip it. Additionally, if the class expression is missing the correct
# attributes, we skip it.
if (base, attr_name) in seen:
continue
if (not isinstance(method, classmethod)) and not (
callable(method) and getattr(method, "derived_profile", False)
):
seen.add((base, attr_name))
continue
elif (isinstance(method, classmethod)) and not (
callable(method.__func__) and getattr(method, "derived_profile", False)
):
seen.add((base, attr_name)) # type: ignore
continue
seen.add((base, attr_name)) # type: ignore
# At this point, any remaining methods are relevant class expressions which should
# be registered. We create the dynamic class at this stage and then only instantiate
# when required by the user.
expression_name = getattr(method, "expression_name", attr_name)
_func, _ufunc, _vars, _params = getattr(cls, method.__name__)()
cls.__cls_derived_profiles__[expression_name] = build_dynamic_profile_class(
_func,
_ufunc,
variables=_vars,
parameters=_params,
base=cls.__DERIVED_BASE__,
)
@classmethod
def __cls_setup_latex__(cls):
# Setup the latex variables first. We need to
# ensure that we either copy them from __VARIABLES__ or
# that we have the correct number.
if cls.__VARIABLES_LATEX__ is not None:
if len(cls.__VARIABLES_LATEX__) != len(cls.__VARIABLES__):
raise ProfileClassSetupError(
"`__VARIABLES_LATEX__` has inconsistent number of symbols compared to `__VARIABLES__`."
)
else:
cls.__VARIABLES_LATEX__ = cls.__VARIABLES__[:]
# Now do the parameters.
cls.__PARAMETERS_LATEX__ = cls.__PARAMETERS_LATEX__ if cls.__PARAMETERS_LATEX__ is not None else {}
for parameter_name in cls.__PARAMETERS__:
if parameter_name not in cls.__PARAMETERS_LATEX__:
cls.__PARAMETERS_LATEX__[parameter_name] = str(parameter_name)
@classmethod
def __cls_setup__(cls):
"""Orchestrates the symbolic setup for a profile class.
This is the main entry point used during class construction. It performs the following steps:
1. Initializes variables and parameter symbols.
2. Builds explicit class symbols (things like the metric and metric density)
3. Registers class expressions.
4. Sets up internal flags to avoid re-processing.
Raises
------
CoordinateClassException
If any part of the symbolic setup fails (e.g., axes, metric, or expressions).
"""
# Check for abstraction or existing configuration. If either of these has
# occurred, the execution should stop.
if cls.__IS_ABSTRACT__:
raise TypeError(
f"CoordinateSystem class {cls.__name__} is abstract and cannot be instantiated or constructed."
)
if cls.__cls_is_setup_flag__:
return
# Step 1: Construct the symbols.
try:
cls.__cls_setup_symbols__()
except Exception as e:
raise ProfileClassSetupError(
f"Failed to setup the variable symbols for profile class {cls.__name__} due to an error: {e}."
) from e
# Step 2: Construct implicit symbolic attributes.
try:
cls.__cls_setup_implicit_symbolic_attributes__()
except Exception as e:
raise ProfileClassSetupError(
f"Failed to setup derived class expressions for profile class {cls.__name__} due to an error: {e}."
) from e
# Step 3: Configure the latex representation of the
# variables and the parameters. This is generically very
# simple.
cls.__cls_setup_latex__()
# ============================== #
# Initialization #
# ============================== #
def _setup_parameters(self, **kwargs):
# Create the storage buffers for the parameter
# values and units.
_pvalues, _punits = {}, {}
for _parameter_name in kwargs:
if _parameter_name not in self.__PARAMETERS__:
raise ValueError(
f"Parameter `{_parameter_name}` is not a recognized "
f"parameter of the {self.__class__.__name__} profile."
)
# Iterate through the parameters, strip the units,
# and assign.
for _parameter_name in self.__PARAMETERS__:
# Check that the parameter is valid.
if _parameter_name in kwargs:
_parameter_value = kwargs[_parameter_name]
else:
_parameter_value = self.__PARAMETERS__[_parameter_name]
# Now set them in
if hasattr(_parameter_value, "units"):
_pvalues[_parameter_name] = _parameter_value.value
_punits[_parameter_name] = _parameter_value.units
else:
_pvalues[_parameter_name] = _parameter_value
_punits[_parameter_name] = unyt.Unit("")
return _pvalues, _punits
@classmethod
@abstractmethod
def __function__(cls, *args, **kwargs) -> sp.Expr:
"""Return the symbolic profile expression.
Parameters
----------
*args : sympy.Symbol
Symbolic representations of the variables (e.g., r).
**kwargs : sympy.Symbol or numeric
Parameters as symbolic or numeric constants.
Returns
-------
sympy.Expr
Symbolic expression defining the profile.
"""
...
@classmethod
@abstractmethod
def __function_units__(cls, *arg_units, **param_units) -> unyt.Unit:
"""Compute the output units of the profile.
Parameters
----------
*arg_units : unyt.Unit
Units of the independent variables.
**param_units : unyt.Unit or numeric
Parameters, either as units (dimensional) or plain scalars (dimensionless).
Returns
-------
unyt.Unit
Resulting output units.
"""
...
def __init__(self, **kwargs):
"""Initialize a profile instance with specific parameter values.
This constructor sets up the symbolic and numerical infrastructure for the profile
by performing the following steps:
1. If the class has not already been set up, trigger symbolic construction of metric tensors,
symbols, and expressions.
2. Validate and store user-provided parameter values, overriding class defaults.
3. Substitute parameter values into symbolic expressions to produce instance-specific forms.
4. Lambdify key expressions (metric tensor, inverse metric, metric density) for numerical evaluation.
Parameters
----------
**kwargs : dict
Keyword arguments specifying values for profile parameters. Each key should match
a parameter name defined in ``__PARAMETERS__``. Any unspecified parameters will use the class-defined
default values.
Raises
------
ValueError
If a provided parameter name is not defined in the profile.
"""
# -- Class Initialization -- #
# For profiles with setup flags for 'init', it is necessary to process
# symbolics at this point if the class is not initialized.
self.__class__.__cls_setup__()
# -- Parameter Creation -- #
# The profile takes a set of kwargs (potentially empty) which specify
# the parameters of the profile. Each should be adapted into a self.__parameters__ dictionary.
self.__parameters__, self.__parameter_units__ = self._setup_parameters(**kwargs)
# -- Numerical Implementation -- #
# We now realize the symbolic version of this particular
# instance as well as the numerical version.
self.__symbolic_profile__ = self.__function__(*self.__cls_var_symbols__, **self.__cls_param_symbols__)
self.__numeric_profile__ = self.lambdify_expression(self.__symbolic_profile__)
# -- Expression Management -- #
self.__derived_profiles__: dict[str, Any] = {}
# =============================== #
# Dunder Methods #
# =============================== #
def __repr__(self):
return f"<{self.__class__.__name__} - Parameters={self.__parameters__}> "
def __str__(self):
return f"<{self.__class__.__name__}>"
def __hash__(self):
r"""Compute a hash value for the profile instance.
The hash is based on the class name and keyword arguments (``__parameters__``).
This ensures that two instances with the same class and initialization parameters produce the same hash.
Returns
-------
int
The hash value of the instance.
"""
# Create a parameter tuple with each parameter linked
# to the string of its unit in a tuple.
_parameter_linked_tuple = tuple(
(pname, p_value.value, str(p_value.units)) for pname, p_value in sorted(self.parameters.items())
)
return hash((self.__class__.__name__, _parameter_linked_tuple))
def __getitem__(self, item: str) -> unyt.unyt_quantity:
"""Return the value of a parameter with a given name.
Parameters
----------
item : str
The parameter to fetch.
Returns
-------
~unyt.array.unyt_quantity
The resulting parameter.
Raises
------
KeyError
If the parameter doesn't exist.
"""
return self.__parameters__[item] * self.__parameter_units__[item]
def __contains__(self, parameter: str) -> bool:
"""Check whether a given parameter exists for this profile.
Parameters
----------
parameter : str
The parameter name to check.
Returns
-------
bool
True if the parameter name is present; False otherwise.
"""
return parameter in self.__PARAMETERS__
def __copy__(self):
"""Create a shallow copy of the coordinate system.
Returns
-------
_CoordinateSystemBase
A new instance of the same class, initialized with the same parameters.
"""
# Shallow copy: re-init with same parameters
cls = self.__class__
new_obj = cls(**self.parameters)
return new_obj
def __call__(
self, *x, units: str | unyt.Unit | None = None, no_units: bool = False
) -> unyt.unyt_quantity | unyt.unyt_array:
# separate the x args from their units.
xnum, xunit = (
[_x.value if hasattr(_x, "units") else _x for _x in x],
[_x.units if hasattr(_x, "units") else unyt.Unit("") for _x in x],
)
# Evaluate the numerical function using the xnum
output_value = self.__numeric_profile__(*xnum)
output_units = self.__function_units__(*xunit, **self.parameters)
if no_units:
return output_value
elif units is None:
return output_value * output_units
else:
return (output_value * output_units).to(units)
def __call_no_units__(self, *x):
return self.__numeric_profile__(*x)
# =============================== #
# Properties #
# =============================== #
@property
def variables(self) -> list[str]:
"""The axes names present in this coordinate system."""
return self.__VARIABLES__[:]
@property
def parameters(self) -> dict[str, Any]:
"""The parameters of this coordinate system.
Note that modifications made to the returned dictionary
are not reflected in the class itself. To change a parameter value, the class must be re-instantiated.
"""
return {k: self.__parameters__[k] * self.__parameter_units__[k] for k in self.__parameters__}
@property
def variable_symbols(self) -> list[sp.Symbol]:
"""The symbols representing each of the coordinate axes in this coordinate system."""
return self.__class__.__cls_var_symbols__[:]
@property
def parameter_symbols(self):
"""Get the symbolic representations of the coordinate system parameters.
Returns
-------
dict of str, ~sympy.core.symbol.Symbol
A dictionary mapping parameter names to their corresponding SymPy symbols.
These symbols are used in all symbolic expressions defined by the coordinate system.
Notes
-----
- The returned dictionary is a copy, so modifying it will not affect the internal state.
- These symbols are created during class setup and correspond to keys in `self.parameters`.
"""
return self.__cls_param_symbols__.copy()
@property
def derived_profile_classes(self) -> dict[str, type["BaseProfile"]]:
"""Get the available derived profile classes for this instance.
Returns
-------
dict of str, :class:`~profiles.base.BaseProfile`
A dictionary mapping derived profile names to their dynamically
generated profile classes.
"""
return self.__class__.__cls_derived_profiles__.copy()
# ==================================== #
# Expressions Management #
# ==================================== #
def substitute_expression(self, expression: Any) -> Any:
"""Replace symbolic parameters with numerical values in an expression.
This method takes a symbolic expression that may include parameter symbols and
substitutes them with the numerical values assigned at instantiation.
Parameters
----------
expression : str or ~sympy.core.expr.Expr
The symbolic expression to substitute parameter values into.
Returns
-------
~sympy.core.expr.Expr
The expression with parameters replaced by their numeric values.
Notes
-----
- Only parameters defined in ``self.__parameters__`` are substituted.
- If an expression does not contain any parameters, it remains unchanged.
- This method is useful for obtaining instance-specific symbolic representations.
Example
-------
.. code-block:: python
from sympy import Symbol
expr = Symbol("a") * Symbol("x")
coords = MyCoordinateSystem(a=3)
print(coords.substitute_expression(expr))
3 * x
"""
# Substitute in each of the parameter values.
_params = self.__parameters__.copy()
return sp.simplify(sp.sympify(expression).subs(_params))
def lambdify_expression(self, expression: str | sp.Basic) -> Callable:
"""Convert a symbolic expression into a callable function.
Parameters
----------
expression : :py:class:`str` or ~sympy.core.basic.Basic
The symbolic expression to lambdify.
Returns
-------
Callable
A callable numerical function.
"""
return lambdify_expression(expression, self.__cls_var_symbols__, self.__parameters__)
def get_derived_profile(self, profile_name: str, **kwargs):
"""Access and instantiate a derived profile by name.
If the profile has already been instantiated and no additional kwargs
are provided, the cached version is returned.
Parameters
----------
profile_name : str
The name of the derived profile as registered via @derived_profile.
**kwargs : dict
Optional parameter overrides for the derived profile.
Returns
-------
~profiles.base.BaseProfile
An instance of the derived profile, initialized with inherited and/or overridden parameters.
Raises
------
KeyError
If the requested derived profile is not defined.
"""
if profile_name not in self.__class__.__cls_derived_profiles__:
raise KeyError(f"Derived profile '{profile_name}' not found for class {self.__class__.__name__}.")
# If already cached and no new kwargs, return cached version
if profile_name in self.__derived_profiles__ and not kwargs:
return self.__derived_profiles__[profile_name]
DerivedCls = self.__class__.__cls_derived_profiles__[profile_name]
# Combine inherited parameters with any overrides
init_params = DerivedCls.__PARAMETERS__.copy()
init_params.update({k: v for k, v in self.parameters.items() if k in init_params})
init_params.update(kwargs)
derived_instance = DerivedCls(**init_params)
self.__derived_profiles__[profile_name] = derived_instance
return derived_instance
def list_derived_profiles(self) -> list[str]:
"""List all available derived profiles for this instance.
Returns
-------
list of str
The names of all registered derived profiles.
"""
return list(self.__class__.__cls_derived_profiles__.keys())
def get_output_units(self, *argu) -> unyt.Unit:
"""Determine the output units of the operation given some set of input units.
Parameters
----------
argu:
The input units to propagate through the profile.
Returns
-------
~unyt.unit_object.Unit
The resulting output units.
"""
return self.__function_units__(*argu, **self.parameters)
# ==================================== #
# Utilities #
# ==================================== #
# -- LaTeX and Printing -- #
def get_expression_latex(self, substitute: bool = True) -> str:
"""Return the LaTeX representation of the profile's symbolic expression.
Parameters
----------
substitute : bool, default=True
If True, substitute parameter values into the expression and append units.
If False, retain symbolic parameter placeholders and omit units.
Returns
-------
str
The LaTeX-formatted string of the profile expression.
Notes
-----
- Uses ``__VARIABLES_LATEX__`` and ``__PARAMETERS_LATEX__`` for display-friendly names.
- Substitution inserts numerical parameter values if available.
- When substituting, the units are shown as a multiplicative LaTeX term at the end.
"""
# Start with the raw symbolic expression
expr = self.__symbolic_profile__
if substitute:
expr = self.substitute_expression(expr)
# Build display symbol replacements
symbol_map = {}
for sym, latex_str in zip(self.variable_symbols, self.__VARIABLES_LATEX__, strict=False):
symbol_map[sym] = sp.Symbol(latex_str)
for param_name, sym in self.parameter_symbols.items():
latex_str = self.__PARAMETERS_LATEX__.get(param_name, param_name)
symbol_map[sym] = sp.Symbol(latex_str)
# Substitute display-friendly names
display_expr = expr.subs(symbol_map)
# Generate LaTeX string
latex_str = sp.latex(display_expr)
# Append units if substitution is active
if substitute:
# Collect input units from variables
var_units = [_x.units if hasattr(_x, "units") else unyt.Unit("") for _x in self.variable_symbols]
# Compute total units using class's function_units
output_units = self.__function_units__(*var_units, **self.parameters)
if not output_units.is_dimensionless:
latex_str += r"\quad \left[" + output_units.latex_repr + r"\right]"
return latex_str
def get_parameters_latex(self) -> str:
"""Return a LaTeX table of the profile parameters.
Includes parameter names, LaTeX symbols, numerical values, and units.
Returns
-------
str
LaTeX-formatted tabular environment string.
"""
rows = []
for pname in self.__PARAMETERS__:
latex_name = self.__PARAMETERS_LATEX__.get(pname, pname)
value = self.__parameters__[pname]
unit = self.__parameter_units__[pname]
rows.append(f"{latex_name} & {value} & {unit.latex_repr} \\\\")
table = (
r"\begin{tabular}{l l l}" + "\n"
r"Parameter & Value & Unit \\" + "\n"
r"\hline" + "\n" + "\n".join(rows) + "\n"
r"\end{tabular}"
)
return table
# -- IO -- #
def to_dict(self) -> dict[str, Any]:
"""Serialize this profile to a minimal dictionary representation.
The dictionary contains:
- ``class`` : The profile class name (for reconstruction)
- ``parameters`` : A nested dictionary of parameters with explicit unit strings
Example output:
.. code-block:: python
{
"class": "MyProfile",
"parameters": {
"a": {"value": 3.0, "unit": "kpc"},
"b": {"value": 5.0, "unit": ""},
},
}
Returns
-------
dict
Serialized profile representation suitable for storage or reconstruction.
"""
return {
"class": self.__class__.__name__,
"parameters": {
pname: {
"value": self.__parameters__[pname],
"unit": str(self.__parameter_units__[pname]),
}
for pname in self.__parameters__
},
}
@classmethod
def from_dict(cls, data):
"""Reconstruct a profile instance from a dictionary.
Parameters
----------
data : dict
Dictionary containing ``parameters`` in the format produced by :meth:`to_dict`.
Returns
-------
~profiles.base.BaseProfile
A new instance of the profile with the specified parameters.
Raises
------
ValueError
If required keys are missing or invalid parameter names are provided.
"""
params = {
pname: unyt.unyt_quantity(pdata["value"], pdata["unit"])
for pname, pdata in data.get("parameters", {}).items()
}
return cls(**params)
def to_yaml(self, filepath: str, **kwargs):
"""Serialize the profile to a YAML file.
Parameters
----------
filepath : str
Path to the output YAML file.
kwargs : dict
Additional arguments passed to yaml.dump.
"""
import yaml
with open(filepath, "w") as f:
yaml.dump(self.to_dict(), f, **kwargs)
def to_json(self, filepath: str, **kwargs):
"""Serialize the profile to a JSON file.
Parameters
----------
filepath : str
Path to the output JSON file.
kwargs : dict
Additional arguments passed to json.dump.
"""
import json
with open(filepath, "w") as f:
json.dump(self.to_dict(), f, indent=2, **kwargs)
@classmethod
def from_yaml(cls, filepath: str) -> "BaseProfile":
"""Reconstruct a profile from a YAML file.
Parameters
----------
filepath : str
Path to the YAML file.
Returns
-------
~profiles.base.BaseProfile
Reconstructed profile instance.
"""
import yaml
with open(filepath) as f:
data = yaml.safe_load(f)
return profile_from_dict(data)
@classmethod
def from_json(cls, filepath: str) -> "BaseProfile":
"""Reconstruct a profile from a JSON file.
Parameters
----------
filepath : str
Path to the JSON file.
Returns
-------
~profiles.base.BaseProfile
Reconstructed profile instance.
"""
import json
with open(filepath) as f:
data = json.load(f)
return profile_from_dict(data)
def to_hdf5(self, h5obj, name=None):
"""Store profile metadata into an HDF5 object as attributes.
Parameters
----------
h5obj : ~h5py.File, ~h5py.Group, or ~h5py.Dataset
The HDF5 container to attach metadata to. All of the metadata
representing this profile is attached with `name` prefixed.
name : str, optional
Namespace prefix for attribute keys. Defaults to none, in which
case parameters are given no prefix. This can be unsafe if saving
multiple profiles to the same object.
Notes
-----
Only metadata is stored. This does not save large arrays or derived results.
"""
data = self.to_dict()
if name is not None:
prefix = f"{name}."
else:
prefix = ""
h5obj.attrs[f"{prefix}class"] = data["class"]
for pname, pinfo in data["parameters"].items():
h5obj.attrs[f"{prefix}param.{pname}.value"] = pinfo["value"]
h5obj.attrs[f"{prefix}param.{pname}.unit"] = pinfo["unit"]
@classmethod
def from_hdf5(cls, h5obj, name=None) -> "BaseProfile":
"""Reconstruct a profile from HDF5 attributes.
Parameters
----------
h5obj : ~h5py.File, ~h5py.Group, or ~h5py.Dataset
HDF5 object containing profile metadata as attributes.
name : str, optional
Namespace prefix for attribute keys. If None, parameters are read without a prefix.
Returns
-------
~profiles.base.BaseProfile
Reconstructed profile instance.
Raises
------
ValueError
If required metadata is missing or incomplete.
"""
if name is not None:
prefix = f"{name}."
else:
prefix = ""
# Validate required class entry
if f"{prefix}class" not in h5obj.attrs:
raise ValueError(f"HDF5 object does not contain profile metadata with prefix '{prefix}'.")
cls_name = h5obj.attrs[f"{prefix}class"]
# Extract parameter values and units
params = {}
for attr in h5obj.attrs:
if attr.startswith(f"{prefix}param.") and attr.endswith(".value"):
pname = attr[len(f"{prefix}param.") : -len(".value")]
pval = h5obj.attrs[attr]
punit = h5obj.attrs.get(f"{prefix}param.{pname}.unit", "")
params[pname] = unyt.unyt_quantity(pval, punit)
return profile_from_dict(
{
"class": cls_name,
"parameters": {
pname: {"value": param.value, "unit": str(param.units)} for pname, param in params.items()
},
}
)
# ============================= #
# Special Case Subclasses #
# ============================= #
# Special subclasses with general purpose structures.
class BaseSphericalRadialProfile(BaseProfile, ABC):
"""Abstract base class for spherically symmetric, 1D radial profiles.
This class provides standard conventions for radial profiles, defining:
- A single independent variable ``r`` (radius)
- Automatic symbolic setup for ``r``
- A derived profile for the radial derivative
Subclasses define specific profile behavior by implementing:
- ``__function__`` : returns the symbolic expression as a SymPy object
- ``__function_units__`` : returns the dimensional units of the profile
Notes
-----
- This class is abstract. Concrete subclasses must set ``__IS_ABSTRACT__ = False``.
- Units for ``r`` are propagated through the derivative profile.
- The radial derivative is accessible via :meth:`get_derived_profile`.
Example
-------
.. code-block:: python
class DensityProfile(BaseRadialProfile):
__IS_ABSTRACT__ = False
__PARAMETERS__ = {"rho0": 1.0}
def __function__(self, r, rho0):
return rho0 / (r**2)
def __function_units__(
self, r_unit, rho0_unit
):
return rho0_unit / r_unit**2
"""
__IS_ABSTRACT__ = True
__VARIABLES__ = ["r"]
@derived_profile("derivative")
@classmethod
def _radial_derivative(cls):
"""Construct the symbolic radial derivative of the profile."""
# define the new function as the sp.diff of the
# class profile.
def _func(_r, **_params):
return sp.diff(cls.__function__(_r, **_params), _r)
# Define the unit propagation function as
# a simple extension of the class's unit function.
def _unit_func(_r_unit, **_param_units):
return cls.__function_units__(_r_unit, **_param_units) / _r_unit
return _func, _unit_func, ["r"], cls.__PARAMETERS__.copy()
class BaseCylindricalDiskProfile(BaseProfile, ABC):
r"""Abstract base class for axisymmetric cylindrical disk profiles.
This class provides standard infrastructure for two-dimensional disk-like profiles
expressed in cylindrical coordinates, defining:
- Independent variables ``r`` (cylindrical radius) and ``z`` (vertical coordinate)
- Automated symbolic setup for both variables
- Derived profiles for radial and vertical derivatives, accessible via:
- ``radial_derivative`` : :math:`\frac{\partial f}{\partial r}`
- ``vertical_derivative`` : :math:`\frac{\partial f}{\partial z}`
Subclasses define specific profile behavior by implementing:
- ``__function__`` : Returns the symbolic expression as a SymPy object
- ``__function_units__`` : Returns the dimensional units of the profile output
Notes
-----
- This class is abstract. Concrete subclasses must set ``__IS_ABSTRACT__ = False``.
- Independent variables are:
- ``r`` : Cylindrical radius
- ``z`` : Vertical coordinate
- Units for ``r`` and ``z`` are automatically propagated in derived expressions.
- Radial and vertical derivatives are implemented via :func:`~profiles.base.derived_profile`.
Example
-------
.. code-block:: python
class ExponentialDiskProfile(
BaseCylindricalDiskProfile
):
__IS_ABSTRACT__ = False
__PARAMETERS__ = {"Sigma_0": 1.0, "h": 1.0}
@classmethod
def __function__(cls, r, z, Sigma_0, h):
return (
Sigma_0
* sp.exp(-r / h)
* sp.exp(-abs(z) / h)
)
@classmethod
def __function_units__(
cls, r_unit, z_unit, Sigma_0_unit, h_unit
):
return Sigma_0_unit
disk = ExponentialDiskProfile(Sigma_0=1.0, h=2.0)
radial_grad = disk.get_derived_profile(
"radial_derivative"
)
print(radial_grad(5.0, 0.0))
See Also
--------
BaseSphericalRadialProfile : Base class for spherically symmetric profiles
"""
__IS_ABSTRACT__ = True
__VARIABLES__ = ["r", "z"]
@derived_profile("radial_derivative")
@classmethod
def _radial_derivative(cls):
r"""Construct the symbolic radial derivative :math:`\frac{\partial f}{\partial r}` of the profile.
Returns
-------
tuple
A tuple ``(func, unit_func, variables, parameters)`` where:
- ``func`` : Callable for the symbolic radial derivative
- ``unit_func`` : Callable to compute output units, propagating radial units
- ``variables`` : List of variable names ``["r", "z"]``
- ``parameters`` : Dictionary of parameters from the profile
"""
def _func(r, z, **params):
return sp.diff(cls.__function__(r, z, **params), r)
def _unit_func(r_unit, z_unit, **param_units):
return cls.__function_units__(r_unit, z_unit, **param_units) / r_unit
return _func, _unit_func, ["r", "z"], cls.__PARAMETERS__.copy()
@derived_profile("vertical_derivative")
@classmethod
def _vertical_derivative(cls):
r"""Construct the symbolic vertical derivative :math:`\frac{\partial f}{\partial z}` of the profile.
Returns
-------
tuple
A tuple ``(func, unit_func, variables, parameters)`` where:
- ``func`` : Callable for the symbolic vertical derivative
- ``unit_func`` : Callable to compute output units, propagating vertical units
- ``variables`` : List of variable names ``["r", "z"]``
- ``parameters`` : Dictionary of parameters from the profile
"""
def _func(r, z, **params):
return sp.diff(cls.__function__(r, z, **params), z)
def _unit_func(r_unit, z_unit, **param_units):
return cls.__function_units__(r_unit, z_unit, **param_units) / z_unit
return _func, _unit_func, ["r", "z"], cls.__PARAMETERS__.copy()
# ============================= #
# Utility Functions #
# ============================= #
def profile_from_dict(data: dict[str, Any], registry: dict[str, type[BaseProfile]] | None = None) -> BaseProfile:
"""Reconstruct a profile instance from a dictionary with optional registry support.
Parameters
----------
data : dict
Dictionary with ``class`` and ``parameters`` fields, as produced by :meth:`~profiles.base.BaseProfile.to_dict`.
registry : dict, optional
Mapping of class names to profile classes. Defaults to the global ``__default_profile_registry__``.
Returns
-------
~profiles.base.BaseProfile
The reconstructed profile instance.
Raises
------
ValueError
If the class name is missing or the class cannot be resolved.
"""
if registry is None:
registry = __default_profile_registry__
cls_name = data.get("class")
if cls_name is None:
raise ValueError("Serialized profile is missing the 'class' field.")
if cls_name not in registry:
raise ValueError(f"Profile class '{cls_name}' not found in registry.")
cls = registry[cls_name]
return cls.from_dict(data)
def build_dynamic_profile_class(
func: Callable,
unit_func: Callable,
variables: list[str],
parameters: dict[str, str] | None = None,
base: type[BaseProfile] | None = None,
) -> type[BaseProfile]:
"""Dynamically construct a new profile class from a symbolic function and associated metadata.
This utility allows users to define fully functional profile classes at runtime without writing
boilerplate class definitions. The constructed class inherits from the specified base and provides
a complete, ready-to-use implementation for models requiring analytical expressions.
Parameters
----------
func : Callable
Callable that generates the symbolic expression for the profile.
Must have signature ``func(*variables, **parameters)`` and return a
valid :class:`~sympy.core.expr.Expr` or compatible symbolic object defining the profile.
unit_func : Callable
Callable that determines the units of the derived profile expression.
Must have signature ``unit_func(*variable_units, **parameter_units)`` and return a
:class:`unyt.unit_object.Unit` instance representing the result's units.
variables : list of str
Ordered list of the independent variables for the profile, provided as strings.
This list defines the argument order for both `func` and `unit_func`, and must
match the symbolic variables used in the expression.
parameters : dict of str, float, optional
Dictionary mapping parameter names (as strings) to their default values.
These parameters define required inputs for the derived profile and are exposed as class attributes.
Defaults to an empty dictionary if no parameters are required. Derived profiles do **not** inherit
parameters from the base class automatically; all parameters must be explicitly defined here.
base : type, optional
The base class to inherit from when constructing the profile.
Defaults to :class:`~profiles.base.BaseProfile`. Alternative base classes can be specified for advanced
subclassing
behavior, as long as they follow the expected profile interface.
Returns
-------
type
A new dynamically constructed class inheriting from `base`, with all necessary attributes and methods
to function as a profile within modeling frameworks.
Notes
-----
- The generated profile class defines class attributes:
- ``__VARIABLES__`` : list of str — The symbolic variables for the expression.
- ``__PARAMETERS__`` : dict of str, float — Parameters and their defaults.
- ``__function__`` : Callable — Class method returning the symbolic expression.
- ``__function_units__`` : Callable — Class method returning the units.
- Parameters defined in `parameters` are automatically added to the derived profile, but are **not** inherited
from the base class unless explicitly included.
- The argument order in `variables` must exactly match the order expected by both `func` and `unit_func`.
- The derived class will **not** register globally and is intended for localized or temporary use unless
manually registered in a broader framework.
Examples
--------
As an example, one can define a gaussian profile in either a dynamic
framework or a static framework:
.. tab-set::
.. tab-item:: Dynamic Construction
.. code-block:: python
from sympy import symbols, exp
from unyt import Unit
from your_module.profiles import (
build_dynamic_profile_class,
)
r, sigma = symbols("r sigma")
def gaussian_expr(r, sigma):
return exp(-(r**2) / (2 * sigma**2))
def gaussian_units(r_unit, sigma_unit):
return Unit("dimensionless")
GaussianProfile = build_dynamic_profile_class(
func=gaussian_expr,
unit_func=gaussian_units,
variables=["r"],
parameters={"sigma": 1.0},
)
profile = GaussianProfile()
expr = profile.__function__(r, sigma=2)
units = profile.__function_units__(
Unit("kpc"), sigma_unit=Unit("kpc")
)
.. tab-item:: Hard-Coded Class
.. code-block:: python
from sympy import symbols, exp
from unyt import Unit
from your_module.profiles import BaseProfile
r, sigma = symbols("r sigma")
class GaussianProfile(BaseProfile):
__IS_ABSTRACT__ = False
__REGISTER__ = False
__VARIABLES__ = ["r"]
__PARAMETERS__ = {"sigma": 1.0}
@classmethod
def __function__(cls, r, sigma):
return exp(-(r**2) / (2 * sigma**2))
@classmethod
def __function_units__(
cls, r_unit, sigma_unit
):
return Unit("dimensionless")
profile = GaussianProfile()
expr = profile.__function__(r, sigma=2)
units = profile.__function_units__(
Unit("kpc"), sigma_unit=Unit("kpc")
)
"""
# Determine the base class to use for the
# profile class. By default, this is the
# base class.
if base is None:
base = BaseProfile
# Fix the parameters to be a
# fully realized dictionary.
if parameters is None:
parameters = {}
# Generate the dynamic class using the provided
# data.
class _DynamicProfile(base):
__IS_ABSTRACT__ = False
__REGISTER__ = False # Derived profiles should not register globally
__SETUP_AT__ = "init"
__VARIABLES__ = variables
__PARAMETERS__ = parameters
@classmethod
def __function__(cls, *args, **kwargs) -> Any:
return func(*args, **kwargs)
@classmethod
def __function_units__(cls, *argu, **kwargu) -> unyt.Unit:
return unit_func(*argu, **kwargu)
return _DynamicProfile