Source code for differential_geometry.dependence

"""
Symbolic operational dependence manager classes.

This module contains `SymPy <https://docs.sympy.org/latest/index.html>`__ based handlers
for keeping track of dependence in differential operations.
"""
import operator
from abc import ABC, abstractmethod
from typing import (
    TYPE_CHECKING,
    Any,
    Callable,
    Generic,
    List,
    Literal,
    Optional,
    Protocol,
    Sequence,
    Tuple,
    TypeVar,
    Union,
    runtime_checkable,
)

import numpy as np
import sympy as sp

from pymetric.differential_geometry.symbolic import (
    adjust_tensor_signature,
    compute_divergence,
    compute_gradient,
    compute_laplacian,
    compute_tensor_gradient,
    compute_tensor_laplacian,
    lower_index,
    raise_index,
)

# =================== Typing Utilities ================== #
# These are utilities for handling typing in this module. They should
# not need modification in most cases and add little to the
# understanding of the code.
if TYPE_CHECKING:
    from pymetric.coordinates.base import _CoordinateSystemBase

# Define a generic field type so that we can shorten type hints.
_GenericFieldType = Union[sp.Function, sp.MutableDenseNDimArray, sp.Basic]


# noinspection PyMissingOrEmptyDocstring
@runtime_checkable
class _TensorDependenceProto(Protocol):
    # data attributes the mixin touches
    coordinate_system: "_CoordinateSystemBase"
    rank: int
    symbolic_proxy: _GenericFieldType

    def to_symbolic_proxy(self) -> _GenericFieldType:
        ...

    @classmethod
    def from_symbolic_proxy(
        cls,
        coordinate_system: "_CoordinateSystemBase",
        symbolic_proxy: _GenericFieldType,
    ) -> "DependenceObject":
        ...


# noinspection PyMissingOrEmptyDocstring
@runtime_checkable
class _OperatorsDependenceProto(Protocol):
    # data attributes the mixin touches
    coordinate_system: "_CoordinateSystemBase"
    shape: Tuple[int, ...]
    is_scalar: bool
    symbolic_proxy: _GenericFieldType

    def to_symbolic_proxy(self) -> _GenericFieldType:
        ...

    @classmethod
    def from_symbolic_proxy(
        cls,
        coordinate_system: "_CoordinateSystemBase",
        symbolic_proxy: _GenericFieldType,
    ) -> "DependenceObject":
        ...


_DepT = TypeVar("_DepT", bound=_TensorDependenceProto)
_DepA = TypeVar("_DepA", bound=_OperatorsDependenceProto)


# ============= Tensor Support Mixins =================== #
# These mixin classes allow us to support operations on different
# dependence structures. The OperatorsMixin supports operations
# on generic arrays / fields and TensorDependenceMixin allows support
# on tensor fields.
[docs] class OperatorsMixin(Generic[_DepA]): """ Mixin class providing basic arithmetic operations and differential operators for symbolic dependence objects in a coordinate system. Supports elementwise binary operations (e.g., addition, subtraction, multiplication, division) with other compatible dependence objects or scalars. Also provides methods to compute symbolic gradients and Laplacians on each component. Intended to be used with classes that define a symbolic proxy, coordinate system, and a `from_symbolic_proxy` constructor method. """ # @@ Basic Operation Support @@ # # These methods provide mixin support for basic operations # like addition, subtraction, etc. def __binary_operation__( self: _DepA, other: Union[int, float, complex, np.ndarray, sp.Basic, _DepA], op: Callable[[Any, Any], Any], op_name: str, ): """ Perform an elementwise binary operation between this object and another operand. At it's core, this method performs the underlying operation on this object's symbolic proxy and the other objects symbolic proxy. If the other object doesn't have a symbolic proxy, we attempt to apply it directly. Furthermore, the __is_binary_op_compatible__ method allows for other objects with symbolic proxies to be checked for compatibility. """ if hasattr(other, "symbolic_proxy"): # This object has a symbolic proxy so we need to # check for compatibility and then pass the proxy forward. if not self.__is_binary_op_compatible__(other, op_name): raise ValueError( f"Cannot perform {op_name}: incompatible objects: {self}, {other})" ) # Now simply apply the operation between the two elements try: result_exp = op(self.symbolic_proxy, other.__symbolic_proxy__) except Exception as e: raise ValueError( f"Failed to perform {op_name} between" f" {type(self).__name__} and {type(other).__name__}: {e}" ) from e else: # We don't need to validate. We can simply be direct and try to # perform the operation. try: result_exp = op(self.symbolic_proxy, other) except Exception as e: raise ValueError( f"Failed to perform {op_name} between" f" {type(self).__name__} and {type(other).__name__}: {e}" ) from e # Now attempt to convert the result expression into a new # object using the symbolic proxy. try: return self.from_symbolic_proxy(self.coordinate_system, result_exp) except Exception as e: raise ValueError( f"Failed to perform {op_name} between" f" {type(self).__name__} and {type(other).__name__} due" f" to failure in proxy reconstruction." ) from e def __is_binary_op_compatible__(self: _DepA, other: Any, op_name: str) -> bool: """ Check whether a binary operation with `other` is valid. Compatibility is defined by matching coordinate systems and shape. Parameters ---------- other : Any The second operand in the binary operation. op_name : str The name of the operation being attempted (e.g., '__add__'). Returns ------- bool True if the operation is allowed; False otherwise. """ # Ensure the other object has necessary attributes if not hasattr(other, "coordinate_system") or not hasattr(other, "shape"): return False return ( self.coordinate_system == other.coordinate_system and self.shape == other.shape ) def __add__(self, other): """Add two dependence objects.""" return self.__binary_operation__(other, operator.add, "__add__") def __sub__(self, other): """Subtract two dependence objects.""" return self.__binary_operation__(other, operator.sub, "__sub__") def __mul__(self, other): """Multiply two dependence objects.""" return self.__binary_operation__(other, operator.mul, "__mul__") def __truediv__(self, other): """Divide two dependence objects.""" return self.__binary_operation__(other, operator.truediv, "__truediv__") # @@ Element Wise Differential Operations @@ # # These are standard operations that are available to all array structured # dependence classes for computing gradients and # @@ Element-Wise Differential Operations @@ # # These operations compute symbolic derivatives for each component # of a scalar or tensor field over a coordinate system.
[docs] def element_wise_gradient( self: _DepA, *, basis: Literal["covariant", "contravariant"] = "covariant", as_field: bool = False, ): """ Compute the component-wise gradient of the field. Parameters ---------- basis : {"covariant", "contravariant"}, optional Whether to compute the gradient in the covariant or contravariant basis. as_field : bool, optional If True, return the raw symbolic expression. If False (default), wrap the result in a dependence object. Returns ------- Expr or _DepA Symbolic gradient expression or a new dependence object. """ inverse_metric = ( self.coordinate_system.get_expression("inverse_metric_tensor") if basis == "contravariant" else None ) if hasattr(self.symbolic_proxy, "shape"): # We have a shape, so this needs the tensorial # treatment. grad = compute_tensor_gradient( self.symbolic_proxy, self.coordinate_system.axes_symbols, basis=basis, inverse_metric=inverse_metric, ) else: grad = compute_gradient( self.symbolic_proxy, self.coordinate_system.axes_symbols, basis=basis, inverse_metric=inverse_metric, ) return ( grad if as_field else self.from_symbolic_proxy(self.coordinate_system, grad) )
[docs] def element_wise_laplacian(self: _DepA, *, as_field: bool = False): """ Compute the component-wise Laplacian of the field. For scalar fields, computes the scalar Laplacian. For tensor fields, applies the Laplace–Beltrami operator to each component. Parameters ---------- as_field : bool, optional If True, return the raw symbolic expression. If False (default), wrap the result in a dependence object. Returns ------- Expr or _DepA Symbolic Laplacian expression or a new dependence object. """ inverse_metric = self.coordinate_system.get_expression("inverse_metric_tensor") metric_density = self.coordinate_system.get_expression("metric_density") if len(self.shape) == 0: lap = compute_laplacian( self.symbolic_proxy, self.coordinate_system.axes_symbols, inverse_metric=inverse_metric, metric_density=metric_density, ) else: lap = compute_tensor_laplacian( self.symbolic_proxy, self.coordinate_system.axes_symbols, inverse_metric=inverse_metric, metric_density=metric_density, ) return ( lap if as_field else self.from_symbolic_proxy(self.coordinate_system, lap) )
[docs] class TensorDependenceMixin(OperatorsMixin, Generic[_DepT]): """ Mixin class providing tensor-specific operations for symbolic dependence objects. This mixin extends `OperatorsMixin` by adding operations that rely on tensor structure, such as raising/lowering indices, adjusting variance signatures, and computing divergence in addition to the general-purpose gradient and Laplacian. It assumes the object exposes: - `coordinate_system`: a coordinate system with symbolic metric data, - `rank`: an integer rank for the tensor (0 = scalar, 1 = vector, etc.), - `symbolic_proxy`: a SymPy scalar or array-like expression, - `from_symbolic_proxy`: a method to reconstruct a new instance from a proxy. Notes ----- Unlike `OperatorsMixin`, which applies differential operators to each component independently, this mixin enables true tensorial operations that respect index variance (covariant/contravariant behavior). """
[docs] def raise_index(self: _DepT, axis: int, /, *, as_field: bool = False): """ Raise a tensor index along the specified axis using the inverse metric tensor. Parameters ---------- axis : int The index axis to raise. as_field : bool, optional If True, return a symbolic expression. If False, return a new dependence object. Returns ------- _GenericFieldType or _DepT Symbolic tensor with raised index or a new dependence object. Raises ------ ValueError If the tensor is scalar (rank 0). """ if self.rank == 0: raise ValueError("Cannot raise an index on a scalar field.") proxy = raise_index( self.symbolic_proxy, self.coordinate_system.get_expression("inverse_metric_tensor"), axis=axis, ) return ( proxy if as_field else self.from_symbolic_proxy(self.coordinate_system, proxy) )
[docs] def lower_index(self: _DepT, axis: int, /, *, as_field: bool = False): """ Lower a tensor index along the specified axis using the metric tensor. Parameters ---------- axis : int The index axis to lower. as_field : bool, optional If True, return a symbolic expression. If False, return a new dependence object. Returns ------- _GenericFieldType or _DepT Symbolic tensor with lowered index or a new dependence object. Raises ------ ValueError If the tensor is scalar (rank 0). """ if self.rank == 0: raise ValueError("Cannot lower an index on a scalar field.") proxy = lower_index( self.symbolic_proxy, self.coordinate_system.get_expression("metric_tensor"), axis=axis, ) return ( proxy if as_field else self.from_symbolic_proxy(self.coordinate_system, proxy) )
[docs] def adjust_tensor_signature( self: _DepT, variance_in: Sequence[int], variance_out: Sequence[int], /, *, as_field: bool = False, ): """ Adjust the tensor variance (covariant/contravariant) of each index. Parameters ---------- variance_in : Sequence[int] The current variance of each index (0 = covariant, 1 = contravariant). variance_out : Sequence[int] The target variance signature to convert to. as_field : bool, optional If True, return the symbolic expression. If False, return a new dependence object. Returns ------- _GenericFieldType or _DepT Symbolic tensor with adjusted signature or a new dependence object. Raises ------ ValueError If the variance vector lengths do not match the tensor rank. """ if len(variance_in) != self.rank or len(variance_out) != self.rank: raise ValueError("Variance vectors must match tensor rank.") metric = self.coordinate_system.get_expression("metric_tensor") inv_metric = self.coordinate_system.get_expression("inverse_metric_tensor") transformed = adjust_tensor_signature( self.symbolic_proxy, variance_in, variance_out, metric=metric, inverse_metric=inv_metric, ) return ( transformed if as_field else self.from_symbolic_proxy(self.coordinate_system, transformed) )
[docs] def gradient( self: _DepT, *, basis: Literal["covariant", "contravariant"] = "covariant", as_field: bool = False, ): """ Compute the elementwise gradient of the tensor field. This delegates to `element_wise_gradient()` from `OperatorsMixin`, treating each tensor component as an independent scalar function. Parameters ---------- basis : {"covariant", "contravariant"}, optional Whether to return the result in the covariant or contravariant basis. as_field : bool, optional Whether to return the raw symbolic result or wrap it as a dependence object. Returns ------- _GenericFieldType or _DepT The gradient expression or new dependence object. """ return self.element_wise_gradient(basis=basis, as_field=as_field)
[docs] def divergence( self: _DepT, *, basis: Literal["covariant", "contravariant"] = "contravariant", as_field: bool = False, ): """ Compute the divergence of a rank-1 tensor field. This is a true tensorial divergence that contracts the covariant derivative with the appropriate metric volume form and raises indices if needed. Parameters ---------- basis : {"covariant", "contravariant"}, optional Basis in which to compute the divergence. as_field : bool, optional Whether to return the raw symbolic result or wrap it as a dependence object. Returns ------- _GenericFieldType or _DepT The divergence expression or new dependence object. Raises ------ ValueError If the tensor rank is not 1 (i.e., not a vector field). """ if self.rank != 1: raise ValueError("Divergence only defined for rank‑1 tensors.") inv_metric = self.coordinate_system.get_expression("inverse_metric_tensor") metric_density = self.coordinate_system.get_expression("metric_density") div = compute_divergence( self.symbolic_proxy, self.coordinate_system.axes_symbols, basis=basis, inverse_metric=inv_metric, metric_density=metric_density, ) return ( div if as_field else self.from_symbolic_proxy(self.coordinate_system, div) )
[docs] def laplacian(self: _DepT, *, as_field: bool = False): """ Compute the Laplacian of the tensor field (component-wise). This calls `element_wise_laplacian()` from `OperatorsMixin`, applying the Laplace–Beltrami operator to each component independently. Parameters ---------- as_field : bool, optional Whether to return the raw symbolic result or wrap it as a dependence object. Returns ------- _GenericFieldType or _DepT The Laplacian expression or new dependence object. """ return self.element_wise_laplacian(as_field=as_field)
# ============= Dependence Tensor Generators ============ # # This section of the code provides a couple of methods for converting # common tensor conventions (dense, sparse, etc.) to symbolic models # that reflect the correct tensor dependence.
[docs] class DependenceObject(ABC): """ Base class for representing symbolic dependence of tensors on coordinates. This class serves as the foundation for modeling whether a scalar or tensor field depends on specific coordinate axes in a given coordinate system. It provides interfaces to construct a symbolic proxy of the tensor and to reconstruct a dependence object from a symbolic expression. Subclasses must implement: - :meth:`to_symbolic_proxy`: Create a dummy symbolic tensor/field to represent dependence. - :meth:`from_symbolic_proxy`: Reconstruct a dependence object from an existing symbolic proxy. Parameters ---------- coordinate_system : _CoordinateSystemBase The coordinate system in which the dependence is defined. """ # @@ Initialization @@ #
[docs] def __init__(self, coordinate_system: "_CoordinateSystemBase") -> None: """ Initialize the base dependence object with a coordinate system. This constructor sets up the foundational context for tensor dependence by storing the coordinate system and deferring construction of the symbolic proxy until it is explicitly requested via `symbolic_proxy`. Subclasses should extend this constructor to store additional structural properties (e.g., shape, dependent axes, tensor rank), but **must always** call `super().__init__(coordinate_system)` to ensure proper initialization. Parameters ---------- coordinate_system : _CoordinateSystemBase The coordinate system that defines the geometric context for symbolic dependence. """ self.__cs__: "_CoordinateSystemBase" = coordinate_system self.__symbolic_proxy__: Optional[_GenericFieldType] = None
def __repr__(self) -> str: """Return a developer-friendly string representation of the object.""" return f"<{self.__class__.__name__} | {self.coordinate_system}>" def __str__(self) -> str: """Return a str version of the object.""" return self.__repr__() # @@ Universal Properties @@ # # These are universal properties of all # DependenceObjects, but may be supplemented in # subclasses. @property def coordinate_system(self) -> "_CoordinateSystemBase": """ The coordinate system associated with this dependence object. Returns ------- _CoordinateSystemBase The coordinate system in which the tensor is defined. """ return self.__cs__ @property def coordinates_ndim(self) -> int: """ Number of coordinate dimensions in the associated coordinate system. Returns ------- int Dimensionality of the coordinate system. """ return self.__cs__.ndim @property def symbolic_proxy(self) -> _GenericFieldType: """ A symbolic proxy field or tensor that represents coordinate dependence. This property lazily constructs a symbolic representation of the field or tensor that depends on specific coordinates, useful for symbolic differential operations or analysis of tensor structure. Returns ------- _GenericFieldType A SymPy function or tensor representing the tensor's coordinate dependence. """ if self.__symbolic_proxy__ is None: self.__symbolic_proxy__ = self.to_symbolic_proxy() return self.__symbolic_proxy__ # @@ Abstract Methods @@ # # These two methods are critical for all subclasses and # are used to convert symbolic proxies to / from the base class.
[docs] @abstractmethod def to_symbolic_proxy(self) -> _GenericFieldType: """ Construct a symbolic proxy representing this dependence. This method should return a dummy symbolic field or tensor using SymPy, where the coordinate axes on which the tensor depends are expressed via symbolic function arguments. Returns ------- _GenericFieldType A symbolic field or tensor constructed using SymPy. """ ...
[docs] @classmethod @abstractmethod def from_symbolic_proxy( cls, coordinate_system: "_CoordinateSystemBase", symbolic_proxy: _GenericFieldType, ) -> "DependenceObject": """ Construct a dependence object by analyzing a symbolic proxy. This method should parse a symbolic expression and infer the shape, rank, and axes of dependence in order to reconstruct a subclass instance. Parameters ---------- coordinate_system : _CoordinateSystemBase The coordinate system in which the symbolic expression is defined. symbolic_proxy : _GenericFieldType A symbolic representation (scalar or tensor) of the field. Returns ------- DependenceObject An instance of the subclass representing the symbolic dependence. """ ...
[docs] class DenseDependenceObject(DependenceObject, OperatorsMixin): """ Represents a dense symbolic dependence on coordinate axes within a coordinate system. This class models a scalar or tensor field where every component shares the same symbolic dependence on one or more coordinate axes. In a dense dependence object, the entire field is treated uniformly: all components are assumed to depend on the same subset of coordinate axes (e.g., ["r", "theta"]). This contrasts with sparse or component-wise symbolic representations where each component could depend on different variables. DenseDependenceObject supports: - Uniform shape and rank definitions for the symbolic field. - Construction of full symbolic proxies (scalars or tensors). - Element-wise symbolic operations and differential operators. - Dependence introspection and shape-level comparison. It builds on the abstract `DependenceObject` base class by adding tensor shape and axis metadata, and it mixes in `OperatorsMixin` to enable symbolic arithmetic and differential operations. Notes ----- The symbolic proxy is lazily constructed using SymPy. For tensor fields, a `MutableDenseNDimArray` of symbolic functions is used, with each component named based on its index. Examples -------- >>> from pymetric.coordinates import SphericalCoordinateSystem >>> from pymetric.utilities.logging import pg_log >>> pg_log.disabled = True >>> >>> u = SphericalCoordinateSystem() # doctest: +ELLIPSIS >>> >>> # Construct the dependence >>> # object. >>> obj = DenseDependenceObject(u, (3,), dependent_axes=["r", "theta"]) >>> obj.shape (3,) >>> obj.depends_on("r") True >>> obj.symbolic_proxy # Returns a symbolic vector field [T_r(r, theta), T_theta(r, theta), T_phi(r, theta)] """ # @@ Initialization @@ # # The __init__ here builds on that of DependenceObject # to clarify structure for dense objects.
[docs] def __init__( self, coordinate_system: "_CoordinateSystemBase", shape: Sequence[int], /, *, dependent_axes: Optional[Union[str, Sequence[str]]] = None, ) -> None: """ Initialize a dense dependence object with shape and dependent axes. This constructor defines the tensor shape and determines which coordinate axes the symbolic expression should depend on. If `dependent_axes` is not provided, it defaults to full dependence on all coordinate axes. Parameters ---------- coordinate_system : _CoordinateSystemBase The coordinate system that defines the geometric context. shape : Sequence[int] or Tuple[int, ...] The tensor shape of the symbolic object (e.g., (3,) for a vector). dependent_axes : str or Sequence[str], optional The coordinate axes on which the tensor depends. Can be a single axis name (e.g., "r") or a list of axis names. If None, the tensor is assumed to depend on all axes in the coordinate system. """ super().__init__(coordinate_system) # Store shape and scalar flag self.__shape__: Tuple[int, ...] = tuple(shape) self.__is_scalar__: bool = len(self.__shape__) == 0 # Normalize the dependent axes if dependent_axes is None: # Default: depend on all axes dependent_axes = self.__cs__.__AXES__[:] elif isinstance(dependent_axes, str): # Single axis string → list dependent_axes = [dependent_axes] # Canonicalize and store ordered dependent axes and their index positions self.__dependent_axes__: List[str] = self.__cs__.order_axes_canonical( list(dependent_axes) ) self.__dependent_axes_idx__: List[int] = [ self.__cs__.convert_axes_to_indices(ax) for ax in self.__dependent_axes__ ]
# @@ Dunder Methods @@ # def __repr__(self) -> str: return ( f"DenseDependenceObject(cs={self.coordinate_system}, shape={self.shape}, " f"axes={self.dependent_axes})" ) def __str__(self) -> str: summary = "scalar" if self.is_scalar else f"tensor of shape {self.shape}" return f"<DenseDependenceObject over {self.coordinate_system} | {summary} | depends on {self.dependent_axes}>" def __eq__(self, other: "DenseDependenceObject") -> bool: # For two DenseDependenceObjects to be equivalent, they must # be of the same type, share the same coordinate system, have # the same shape, and have the same dependence. return ( type(self) is type(other) and self.coordinate_system == other.coordinate_system and self.shape == other.shape and self.dependent_axes == other.dependent_axes ) # @@ Universal Properties @@ # # These are common structural attributes of all DependenceObjects. # Subclasses may override or extend them, but the base logic # supports consistent behavior for symbolic tensor fields. @property def shape(self) -> Tuple[int, ...]: """ The tensor shape of this symbolic field. Returns ------- Tuple[int, ...] A tuple describing the shape (e.g., (), (3,), (3, 3)). """ return self.__shape__ @property def rank(self) -> int: """ The tensor rank [number of indices] of this field. Returns ------- int The number of tensor indices, equivalent to `len(shape)`. """ return 0 if self.__is_scalar__ else len(self.__shape__) @property def is_scalar(self) -> bool: """ Whether this object represents a scalar field (i.e., rank 0). Returns ------- bool True if this is a scalar field; False otherwise. """ return self.__is_scalar__ @property def dependent_axes(self) -> List[str]: """ The list of coordinate axis names this field depends on. This is returned as a copy to prevent accidental mutation. Returns ------- List[str] Ordered list of axis names (e.g., ['r', 'theta']). """ return self.__dependent_axes__[:] @property def axes_symbols(self) -> List[sp.Symbol]: """ The symbolic representations of the dependent coordinate axes. These are SymPy symbols corresponding to each axis name in `dependent_axes`. Returns ------- List[sympy.Symbol] List of SymPy axis symbols. """ return [self.__cs__.axes_symbols[i] for i in self.__dependent_axes_idx__] # @@ Abstract Methods @@ # # These two methods are critical for all subclasses and # are used to convert symbolic proxies to / from the base class.
[docs] def to_symbolic_proxy(self) -> _GenericFieldType: """ Construct a symbolic proxy representing this tensor or scalar field. This method builds a symbolic field or tensor using SymPy function objects. For scalar fields, it returns a single symbolic function depending on the relevant coordinate symbols. For tensors, it returns a dense symbolic array of the appropriate shape, with each component named based on its index. Returns ------- sympy.Function or sympy.MutableDenseNDimArray A symbolic scalar or tensor function with coordinate dependence. """ if self.is_scalar: # Scalar field: return a single symbolic function depending on axes return sp.Function("T")(*self.axes_symbols) # Tensor field: construct a dense symbolic array with named components proxy = sp.MutableDenseNDimArray.zeros(*self.__shape__) for idx in np.ndindex(*self.__shape__): # Build a label like T_r or T_01 depending on coordinate naming label = "".join(str(idx)) proxy[idx] = sp.Function(f"T_{label}")(*self.axes_symbols) return proxy
[docs] @classmethod def from_symbolic_proxy( cls, coordinate_system: "_CoordinateSystemBase", symbolic_proxy: _GenericFieldType, ) -> "DenseDependenceObject": """ Reconstruct a DenseDependenceObject from a symbolic proxy expression. This method is the inverse of `to_symbolic_proxy`. It inspects a symbolic expression— either a scalar symbolic function or a dense symbolic tensor array—to determine: - its shape (based on `.shape` if present), - its dependent coordinate axes (based on free symbols). Parameters ---------- coordinate_system : _CoordinateSystemBase The coordinate system context in which the symbolic proxy is defined. symbolic_proxy : _GenericFieldType A symbolic scalar or tensor expression (e.g., from SymPy). Returns ------- DenseDependenceObject A new instance initialized from the inferred shape and dependencies. """ # Extract all free symbols from the expression free_syms = symbolic_proxy.free_symbols dependent_axes = [str(sym) for sym in free_syms] # Determine shape: assume scalar if `.shape` is not available shape = () if not hasattr(symbolic_proxy, "shape") else symbolic_proxy.shape return cls(coordinate_system, shape, dependent_axes=dependent_axes)
# @@ Basic Methods @@ # # These are methods specific to these dense structural # methods.
[docs] def depends_on(self, axis: str) -> bool: """ Check whether this object symbolically depends on a given coordinate axis. This method inspects the symbolic structure and returns whether the field (scalar or tensor) has symbolic dependence on the specified axis. Parameters ---------- axis : str The name of the coordinate axis (e.g., "r", "theta", "z"). Returns ------- bool True if this object depends on the given axis; False otherwise. """ return axis in self.dependent_axes
[docs] class DenseTensorDependence(DenseDependenceObject, TensorDependenceMixin): """ Dense symbolic tensor dependence with attached tensor-aware operators. This subclass uses the tensor rank to define the shape and supports symbolic tensor operations (e.g., raising/lowering indices, divergence), in addition to element-wise operations. Parameters ---------- coordinate_system : _CoordinateSystemBase The coordinate system in which the tensor is defined. rank : int The rank (number of tensor indices) for the tensor. Shape will be (ndim,)*rank. dependent_axes : str or Sequence[str], optional The coordinate axes on which the tensor depends. """
[docs] def __init__( self, coordinate_system: "_CoordinateSystemBase", rank: int, /, *, dependent_axes: Optional[Union[str, Sequence[str]]] = None, ) -> None: """ Initialize a dense tensor dependence object with a given rank and coordinate dependence. This constructor extends `DenseDependenceObject` by inferring the shape of the tensor based on its rank and the dimensionality of the coordinate system. For example, a rank-2 tensor in a 3D coordinate system will have shape (3, 3). Parameters ---------- coordinate_system : _CoordinateSystemBase The coordinate system that defines the geometric context. rank : int The rank (number of tensor indices) of the tensor field. A rank of 0 indicates a scalar field. dependent_axes : str or Sequence[str], optional The coordinate axes the tensor depends on. Can be a single axis name or a list. If None, the tensor is assumed to depend on all coordinate axes. """ # Infer shape from rank and coordinate dimensionality shape = () if rank == 0 else (coordinate_system.ndim,) * rank # Delegate to the base DenseDependenceObject constructor super().__init__(coordinate_system, shape, dependent_axes=dependent_axes) # Store rank explicitly for clarity and subclass use self.__rank__: int = rank
# @@ Dunder Methods @@ # def __repr__(self) -> str: return ( f"DenseTensorDependence(cs={self.coordinate_system}, rank={self.rank}, " f"axes={self.dependent_axes})" ) def __str__(self) -> str: summary = "scalar" if self.rank == 0 else f"tensor of rank {self.rank}" return f"<DenseTensorDependence over {self.coordinate_system} | {summary} | depends on {self.dependent_axes}>" # @@ Universal Properties @@ # # These are universal properties of all # DependenceObjects, but may be supplemented in # subclasses. @property def rank(self) -> int: """ The tensor rank [number of indices] of this field. Returns ------- int The rank explicitly stored during construction. """ return self.__rank__ # @@ Abstract Methods @@ # # These two methods are critical for all subclasses and # are used to convert symbolic proxies to / from the base class.
[docs] def to_symbolic_proxy(self) -> _GenericFieldType: """ Construct a symbolic proxy representing this tensor or scalar field. This method builds a symbolic field or tensor using SymPy function objects. For scalar fields, it returns a single symbolic function depending on the relevant coordinate symbols. For tensors, it returns a dense symbolic array of the appropriate shape, with each component named based on its index. Returns ------- sympy.Function or sympy.MutableDenseNDimArray A symbolic scalar or tensor function with coordinate dependence. """ if self.is_scalar: # Scalar field: return a single symbolic function depending on axes return sp.Function("T")(*self.axes_symbols) # Tensor field: construct a dense symbolic array with named components coord_labels = np.asarray(self.__cs__.__AXES__) proxy = sp.MutableDenseNDimArray.zeros(*self.__shape__) for idx in np.ndindex(*self.__shape__): # Build a label like T_r or T_01 depending on coordinate naming label = "".join(coord_labels[list(idx)]) proxy[idx] = sp.Function(f"T_{label}")(*self.axes_symbols) return proxy
[docs] @classmethod def from_symbolic_proxy( cls, coordinate_system: "_CoordinateSystemBase", symbolic_proxy: _GenericFieldType, ) -> "DenseTensorDependence": """ Rebuild a DenseTensorDependence from a symbolic proxy. Parameters ---------- coordinate_system : _CoordinateSystemBase symbolic_proxy : sympy expression Returns ------- DenseTensorDependence """ free_syms = symbolic_proxy.free_symbols dependent_axes = [str(sym) for sym in free_syms] rank = 0 if not hasattr(symbolic_proxy, "shape") else len(symbolic_proxy.shape) return cls(coordinate_system, rank, dependent_axes=dependent_axes)