r"""
# OGRePy: An Object-Oriented General Relativity Package for Python

By **Barak Shoshany**\
Email: <baraksh@gmail.com>\
Website: <https://baraksh.com/>\
GitHub: <https://github.com/bshoshany>

GitHub repository: <https://github.com/bshoshany/OGRePy>\
PyPi project: <https://pypi.org/project/OGRePy/>

Based on the Mathematica package [OGRe](https://github.com/bshoshany/OGRe) by Barak Shoshany.

Copyright (c) 2024 [Barak Shoshany](https://baraksh.com/). Licensed under the [MIT license](https://github.com/bshoshany/OGRePy/blob/master/LICENSE.txt).

If you use this package in published software or research, please provide a link to [the GitHub repository](https://github.com/bshoshany/OGRePy) in the source code and documentation.
"""


#################################################################################################
#                                        Initialization                                         #
#################################################################################################


###########
# Imports #
###########

from __future__ import annotations

import collections
import functools
import inspect
import itertools
import re
from collections.abc import Callable, Mapping
from copy import copy
from numbers import Number
from types import UnionType
from typing import Any, ClassVar, Literal, NoReturn, Self, cast, override

import sympy as s
from IPython.core.display import Markdown
from IPython.core.getipython import get_ipython
from IPython.display import display  # pyright: ignore [reportUnknownVariableType]
from sympy.core.function import AppliedUndef, UndefinedFunction

import __main__

#######################
# Regular expressions #
#######################


# A pattern used to find free index placeholders. Matches `[n]` where `n` is an integer.
_free_pattern: re.Pattern[str] = re.compile(r"\[(\d+)\]")

# A pattern used to find summation index placeholders. Matches either `[:n]` or `[n:]` where `n` is an integer. Will return a tuple of the form `(first colon, n, second colon)`.
_summation_pattern: re.Pattern[str] = re.compile(r"\[(:)?(\d+)((?(1)|:))\]")


#################################################################################################
#                                   Public objects (exported)                                   #
#################################################################################################


#####################
# Public exceptions #
#####################


class OGRePyError(Exception):
    """
    A class used for all exceptions generated by OGRePy.
    """


####################
# Public variables #
####################


__version__: str = "1.0.1"
release_date: str = "2024-09-04"


####################
# Public functions #
####################


def calc(
    *,
    formula: Tensor,
    symbol: str | s.Symbol = r"\square",
    permute: list[str | s.Symbol] | str | None = None,
) -> Tensor:
    r"""
    Take the result of an arbitrary tensor calculation, give it the desired symbol, and permute its indices to the given index specification.
    #### Parameters:
    * `formula`: The tensor object to process. Should usually be a tensor formula involving multiple tensor objects.
    * `symbol` (optional): A string or a SymPy `Symbol` object designating the symbol to be used when displaying the tensor. The string can include any TeX symbols, e.g. `r"\hat{T}"` (note the `r` in front of the string, indicating that the `\` in the string is not an escape character). If omitted, the placeholder $\square$ (`r"\square"`) will be used.
    * `permute`: A list of one or more strings or SymPy `Symbol` objects indicating the new index specification to permute the result to. `Symbol` objects will be converted into TeX strings, e.g. Symbol("mu") will be converted to "\mu". Strings must be given in the same format as SymPy's `symbols()` function, that is, a space- or comma-separated list of one or more letters or TeX codes. For example, "mu nu" will be converted to two strings, "\mu" and "\nu". The list will splice in any symbols, even if two or more symbols are defined together. For example, if `a` is a SymPy `Symbol` and the input is [a, "b", "c d"], the output will be ["a", "b", "c", "d"]. A single string of space- or comma-separated symbols can also be entered instead of a list.
    #### Returns:
    The result of the tensor calculation as a `Tensor` object with the desired symbol and index permutation.
    """
    # Validate the symbol.
    symbol = _validate_symbol(symbol, formula.rank())
    # Create a new tensor with the same properties and components as the source tensor.
    new: Tensor = Tensor(metric=formula.metric(), coords=formula.default_coords, indices=formula.default_indices, components=formula.components(coords=formula.default_coords, indices=formula.default_indices, warn=False), symbol=symbol)
    # Permute the indices if necessary.
    if permute is not None and permute != formula.index_letters():
        new.permute(new=permute, old=formula.index_letters())
    return new


def diag(
    *values: s.Basic | AnyNumber,
    **kwargs: object,
) -> s.Matrix:
    """
    Return a matrix with the specified diagonal. This is a wrapper around SymPy's `Matrix.diag()` with `strict=True` and `unpack=False`.
    #### Parameters:
    * `values`: One or more numbers or matrices to put on the diagonal. If matrices are specified, a block-diagonal matrix is produced. Lists are converted to matrices.
    * `kwargs` (optional): Zero or more keyword arguments to pass to the function.
    #### Returns:
    The matrix.
    """
    return cast(s.Matrix, s.Matrix.diag(*values, strict=True, unpack=False, **kwargs))


def doc(
    obj: Callable[..., Any] | type,
) -> None:
    """
    Print the documentation for an object as a Markdown-formatted notebook cell. If the object is a class, print the documentation for its constructor.
    #### Parameters:
    * `obj`: The object to print the documentation for.
    #### Exceptions:
    * `OGRePyError`: If no documentation exists.
    """
    # Check that the object is callable.
    if not callable(obj):
        _handle_error("Can only display the documentation for callable objects.")
    # If the object is a class, and its constructor has a docstring, get the documentation for the constructor. Otherwise, get the documentation for the object itself.
    docs: str | None = inspect.getdoc(obj.__init__ if inspect.isclass(obj) and hasattr(obj, "__init__") and inspect.getdoc(obj.__init__) is not None else obj)
    # Get the name of the object, if it has one.
    name: str = obj.__name__ if hasattr(obj, "__name__") else ""
    # If the documentation exists, display it. Otherwise, raise an error.
    if docs is not None:
        # `inspect.signature()` can fail if called, for example, on built-in types.
        try:
            signature: str = str(inspect.signature(obj)).replace("'", "")
        except (ValueError, TypeError):
            _handle_error(f"Could not find call signature for `{name if name != "" else "this object"}`.")
        # Print the object's name, then its signature, then the docstring itself. Types in the signature will be surrounded by single quotes, so we eliminate those before printing.
        _display_markdown('<div style="border: 1px solid; margin: auto; padding: 1em; width: 90%">\n\n`' + name + signature + "`\n\n" + docs + "\n\n</div>")
    else:
        _handle_error(f"Could not find documentation for `{name if name != "" else "this object"}`.")


def func(
    symbol: str | s.Symbol,
) -> UndefinedFunction:
    r"""
    Convert the given string, TeX code, or SymPy `Symbol` object into a SymPy `UndefinedFunction`, including the assumption that the function is real. This is a wrapper around SymPy's `Function` with `real=True`, which also **converts the internal symbol to TeX code** if it is given as a non-TeX string, such as the name of a Greek letter. The reason for this is that in SymPy, `Symbol("mu") != Symbol(r"\mu")`, even though they are both displayed using the same symbol. On the other hand, in OGRePy, `sym("mu") == sym(r"\mu")`.
    #### Parameters:
    * `symbol`: The function's symbol.
    #### Returns:
    The function.
    """
    return cast(UndefinedFunction, s.Function(sym(symbol), real=True))


def info() -> None:
    """
    List all tensors created so far in this notebook session: coordinates, metrics, and all tensors associated with each metric.
    """
    # Get the notebook globals.
    glob: dict[str, Any] = __main__.__dict__
    # Collect dictionaries of all coordinates, metrics, and generic tensors created in the notebook.
    coord_dict: dict[str, Coordinates] = _filter_classes(glob, [Coordinates])
    metric_dict: dict[str, Metric] = _filter_classes(glob, [Metric])
    tensor_dict: dict[str, Tensor] = _filter_classes(glob, [Tensor])
    # Deal with the possibility of more than one variable referencing the same object by creating a reverse dictionary matching each object reference with a list of the variable names referencing it.
    coord_reverse: dict[Coordinates, list[str]] = _reverse_dict(coord_dict)
    metric_reverse: dict[Metric, list[str]] = _reverse_dict(metric_dict)
    tensor_reverse: dict[Tensor, list[str]] = _reverse_dict(tensor_dict)
    # Display general statistics.
    text: str = f"{len(coord_reverse) + len(metric_reverse) + len(tensor_reverse)} tensor objects created: {len(coord_reverse)} coordinates, {len(metric_reverse)} metrics, {len(tensor_reverse)} tensors.\n"
    # Display a list of the coordinate systems.
    text += "\nCoordinate systems:\n"
    text += _list_references(coord_reverse)
    # Display a list of the metrics and their associated tensors.
    text += "\nMetrics and associated tensors:\n"
    text += _list_references(metric_reverse)
    # Display a list of tensors.
    text += "\nTensors:\n"
    text += _list_references(tensor_reverse)
    _display_markdown(text)


def sym(
    symbol: str | s.Symbol,
    **assumptions: bool,
) -> s.Symbol:
    r"""
    Convert the given string, TeX code, or SymPy `Symbol` object into a SymPy `Symbol` object, including the assumption that the symbol is real, plus any additional assumptions given. This is a wrapper around SymPy's `Symbol()` with `real=True`, which also **converts the internal symbol to TeX code** if it is given as a non-TeX string, such as the name of a Greek letter. The reason for this is that in SymPy, `Symbol("mu") != Symbol(r"\mu")`, even though they are both displayed using the same symbol. On the other hand, in OGRePy, `sym("mu") == sym(r"\mu")`.
    #### Parameters:
    * `symbol`: The symbol.
    * `assumptions`: Zero or more keyword arguments indicating assumptions about the symbol, such as `nonnegative=True`. Please see [the SymPy documentation](https://docs.sympy.org/latest/guides/assumptions.html) for a list of possible assumptions.
    #### Returns:
    The resulting symbol.
    """
    _check_type(symbol, str | s.Symbol, "The symbol must be given as a string or SymPy `Symbol` object.")
    if isinstance(symbol, str):
        symbol = s.Symbol(symbol)
    return s.Symbol(_to_tex(symbol), real=True, **assumptions)


def syms(
    symbols: str,
    **assumptions: bool,
) -> list[s.Symbol]:
    r"""
    Convert the given space- or comma-separated strings or TeX codes into SymPy `Symbol` objects, including the assumption that the symbols are real, plus any additional assumptions given. This is a wrapper around SymPy's `symbols()` with `real=True`, which also **converts the internal symbol to TeX code** if it is given as a non-TeX string, such as the name of a Greek letter. The reason for this is that in SymPy, `Symbol("mu") != Symbol(r"\mu")`, even though they are both displayed using the same symbol. On the other hand, in OGRePy, `sym("mu") == sym(r"\mu")`.
    #### Parameters:
    * `symbols`: A string containing one or more space- or comma-separated strings or TeX codes.
    * `assumptions`: Zero or more keyword arguments indicating assumptions about the symbols, such as `nonnegative=True`. Please see [the SymPy documentation](https://docs.sympy.org/latest/guides/assumptions.html) for a list of possible assumptions.
    #### Returns:
    The resulting list of symbols. Note that this will always be a list, even if only one symbol was requested.
    """
    _check_type(symbols, str | s.Symbol, "The symbols must be given as a string containing one or more space- or comma-separated strings or TeX codes.")
    all_symbols: tuple[s.Symbol] = s.symbols(symbols, seq=True)
    return [sym(symbol, **assumptions) for symbol in all_symbols]


def welcome() -> None:
    """
    Print the welcome message.
    """
    if _in_notebook():
        # If we're in a notebook, display a styled welcome message in Markdown format.
        _display_markdown(
            inspect.cleandoc(rf"""
            <b>OGRePy: An <u>O</u>bject-Oriented <u>G</u>eneral <u>Re</u>lativity Package for <u>Py</u>thon\
            By [Barak Shoshany](https://github.com/bshoshany) ([baraksh@gmail.com](mailto:baraksh@gmail.com)) ([baraksh.com](https://baraksh.com/))\
            v{__version__} ({release_date})\
            GitHub repository: <https://github.com/bshoshany/OGRePy></b>
            """),
        )
    else:
        # If we're not in a notebook, display a plain text welcome message, and warn that the package should be used inside a notebook.
        print(  # noqa: T201
            inspect.cleandoc(rf"""
            OGRePy: An Object-Oriented General Relativity Package for Python
            By Barak Shoshany (baraksh@gmail.com) (https://baraksh.com/)
            v{__version__} ({release_date})
            GitHub repository: https://github.com/bshoshany/OGRePy
            WARNING: No notebook interface detected! This package was designed to be used inside a Jupyter notebook in Visual Studio Code or JupyterLab.
            """),
        )


##################
# Public classes #
##################


class Coordinates:
    """
    A class representing a coordinate system.
    """

    # =========== #
    # Constructor #
    # =========== #

    __slots__: tuple[str, ...] = (
        "_christoffel_jacobians",
        "_components",
        "_inverse_jacobians",
        "_jacobians",
        "_transformations",
    )

    def __init__(
        self: Self,
        *components: s.Symbol | str,
    ) -> None:
        """
        Construct a new coordinate object.
        #### Parameters:
        * `components`: One or more strings or SymPy `Symbol` objects specifying the coordinates. Strings should be in the same format as the argument to SymPy's `symbols()`, e.g. `"t x y z", and it is possible to enter just one string for all the coordinates.
        """
        # Validate and store the components.
        self._components: s.Array = s.Array(_collect_symbols(*components))
        # Create empty dictionaries for coordinate transformations and the resulting Jacobians, to be filled later.
        self._transformations: dict[Coordinates, CoordTransformation] = {}
        self._jacobians: dict[Coordinates, s.Array] = {}
        self._inverse_jacobians: dict[Coordinates, s.Array] = {}
        self._christoffel_jacobians: dict[Coordinates, s.Array] = {}

    # =============== #
    # Special methods #
    # =============== #

    @override
    def __str__(
        self: Self,
    ) -> str:
        """
        Overloaded method to obtain this coordinate system's name as a string.
        #### Returns:
        The names of any notebook variables that refer to this coordinates object.
        """
        # We get rid of the backticks since we just want a pure string, not a Markdown formatted string.
        return _lookup_names(self).replace("`", "")

    def __pos__(
        self: Self,
    ) -> None:
        """
        Overloaded unary plus operator. Used as a shortcut for calling `info()`.
        """
        self.info()

    def _repr_markdown_(
        self: Self,
    ) -> str:
        """
        Overloaded method to print the components of this coordinate system to a notebook cell.
        #### Returns:
        The Markdown text to display in the notebook.
        """
        text: str = f"$${_to_tex(self._components)}$$"
        return _format_with_css(text)

    # ============== #
    # Public methods #
    # ============== #

    def christoffel_jacobian(
        self: Self,
        target: Coordinates,
    ) -> s.Array | None:
        """
        Get the "Christoffel Jacobian" of the coordinate transformation from this coordinate system to a target coordinate system. The "Christoffel Jacobian" is the extra second-derivative term in the coordinate transformation of the Christoffel symbols.
        #### Parameters:
        * `target`: The target coordinate system.
        #### Returns:
        * A SymPy `Array` containing the "Christoffel Jacobian", or `None` if a suitable coordinate transformation has not been defined.
        """
        if target not in self._christoffel_jacobians:
            _handle_error(f"A coordinate transformation between `{self}` and `{target}` has not been defined.")
        return self._christoffel_jacobians[target]

    def components(
        self: Self,
    ) -> s.Array:
        """
        Get the components of this coordinate system, that is, the coordinate symbols.
        #### Returns:
        The components of the coordinate vector as a SymPy `Array`.
        """
        return self._components

    def dim(
        self: Self,
    ) -> int:
        """
        Get the number of dimensions in this coordinate system (that is, the number of coordinates).
        #### Returns:
        The number of dimensions.
        """
        return len(self._components)

    def get_coord_transformation(
        self: Self,
        target: Coordinates,
    ) -> CoordTransformation:
        """
        Get the coordinate transformation from this coordinate system to a target coordinate system.
        #### Parameters:
        * `target`: The target coordinate system.
        #### Exceptions:
        * `OGRePyError`: If the requested coordinate transformation has not been defined.
        #### Returns:
        The dictionary supplying the rules for the requested coordinate transformation.
        """
        target = _validate_coordinates(target)
        if target not in self._transformations:
            _handle_error(f"A coordinate transformation between `{self}` and `{target}` has not been defined.")
        return self._transformations[target]

    def info(
        self: Self,
    ) -> None:
        """
        Display information about this coordinate system in human-readable form.
        """
        text: str = f"* **Name**: {_lookup_names(self)}\n"
        text += f"* **Class**: {type(self).__name__}\n"
        text += f"* **Dimensions**: {self.dim()}\n"
        text += f"* **Default Coordinates For**: {", ".join(_using_coords(self))}\n"
        _display_markdown(text)

    def inverse_jacobian(
        self: Self,
        target: Coordinates,
    ) -> s.Array:
        """
        Get the inverse Jacobian of the coordinate transformation from this coordinate system to a target coordinate system.
        #### Parameters:
        * `target`: The target coordinate system.
        #### Returns:
        * A SymPy `Array` containing the inverse Jacobian.
        """
        if target not in self._inverse_jacobians:
            _handle_error(f"A coordinate transformation between `{self}` and `{target}` has not been defined.")
        return self._inverse_jacobians[target]

    def jacobian(
        self: Self,
        target: Coordinates,
    ) -> s.Array:
        """
        Get the Jacobian of the coordinate transformation from this coordinate system to a target coordinate system.
        #### Parameters:
        * `target`: The target coordinate system.
        #### Returns:
        * A SymPy `Array` containing the Jacobian.
        """
        if target not in self._jacobians:
            _handle_error(f"A coordinate transformation between `{self}` and `{target}` has not been defined.")
        return self._jacobians[target]

    def mathematica(
        self: Self,
    ) -> str:
        """
        Get the components of this coordinate system as a Mathematica list.
        #### Returns:
        The components as a Mathematica list.
        """
        return str(s.mathematica_code(self._components))

    def of_param(
        self: Self,
        param: s.Symbol | None = None,
    ) -> list[AppliedUndef]:
        """
        Get the coordinates as functions of a parameter.
        #### Parameters:
        * `param` (optional): The parameter. Default is the curve parameter.
        #### Returns:
        A list containing the coordinate functions.
        """
        if param is None:
            param = options.curve_parameter
        return [func(c)(param) for c in self._components]

    def of_param_dict(
        self: Self,
        param: s.Symbol | None = None,
    ) -> dict[s.Symbol, AppliedUndef]:
        """
        Get a dictionary mapping the coordinate symbols to the coordinates as functions of a parameter.
        #### Parameters:
        * `param` (optional): The parameter. Default is the curve parameter.
        #### Returns:
        A dictionary containing the mapping.
        """
        if param is None:
            param = options.curve_parameter
        return {c: func(c)(param) for c in self._components}

    def of_param_rdict(
        self: Self,
        param: s.Symbol | None = None,
    ) -> dict[AppliedUndef, s.Symbol]:
        """
        Get a dictionary mapping the coordinates as functions of a parameter to the pure coordinate symbols. This is the reverse dictionary of `of_param_dict()`
        #### Parameters:
        * `param` (optional): The parameter. Default is the curve parameter.
        #### Returns:
        A dictionary containing the mapping.
        """
        if param is None:
            param = options.curve_parameter
        return {func(c)(param): c for c in self._components}

    def of_param_ddot(
        self: Self,
        param: s.Symbol | None = None,
    ) -> list[s.Derivative]:
        """
        Get the second derivatives of the coordinates as functions of a parameter.
        #### Parameters:
        * `param` (optional): The parameter. Default is the curve parameter.
        #### Returns:
        A list containing the derivatives.
        """
        if param is None:
            param = options.curve_parameter
        return [func(c)(param).diff((param, 2)) for c in self._components]

    def of_param_dot(
        self: Self,
        param: s.Symbol | None = None,
    ) -> list[s.Derivative]:
        """
        Get the first derivatives of the coordinates as functions of a parameter.
        #### Parameters:
        * `param` (optional): The parameter. Default is the curve parameter.
        #### Returns:
        A list containing the derivatives.
        """
        if param is None:
            param = options.curve_parameter
        return [func(c)(param).diff(param) for c in self._components]

    def of_param_dot_dict(
        self: Self,
        param: s.Symbol | None = None,
    ) -> dict[s.Symbol, s.Derivative]:
        """
        Get a dictionary mapping the coordinate symbols to the first derivatives of the coordinates as functions of a parameter.
        #### Parameters:
        * `param` (optional): The parameter. Default is the curve parameter.
        #### Returns:
        A dictionary containing the mapping.
        """
        if param is None:
            param = options.curve_parameter
        return {c: func(c)(param).diff(param) for c in self._components}

    def of_param_newton_dot(
        self: Self,
        param: s.Symbol | None = None,
    ) -> dict[s.Derivative, s.Symbol]:
        """
        Get a dictionary mapping the first and second derivatives of the coordinates as functions of a parameter to symbols in Newton dot and double dot notation respectively.
        #### Parameters:
        * `param` (optional): The parameter. Default is the curve parameter.
        #### Returns:
        A dictionary containing the mapping.
        """
        if param is None:
            param = options.curve_parameter
        return {func(c)(param).diff(param): sym(r"\dot{" + _to_tex(c) + "}") for c in self._components} | {func(c)(param).diff((param, 2)): sym(r"\ddot{" + _to_tex(c) + "}") for c in self._components}

    def set_coord_transformation(
        self: Self,
        target: Coordinates,
        rules: CoordTransformation | None = None,
    ) -> None:
        """
        Set the coordinate transformation from this coordinate system to a target coordinate system.
        #### Parameters:
        * `target`: The target coordinate system.
        * `rules` (optional): A dictionary specifying the transformation rules from each coordinate in the source system to the target system. The dictionary's keys must be SymPy `Symbol` objects corresponding to the source coordinate symbols. The dictionary's values must be SymPy `Expr` objects corresponding to the values each source coordinate transforms to in the target coordinates. For example, `{x: r * sin(theta) * cos(phi), y: r * sin(theta) * sin(phi), z: r * cos(theta)}` is a transformation from Cartesian to spherical coordinates. If no value is supplied, the previously defined transformation (if any) will be deleted.
        #### Exceptions:
        * `OGRePyError`: If trying to define a transformation from a coordinate system to itself.
        * `OGRePyError`: If the source and target coordinate systems are not of the same dimension.
        * `OGRePyError`: If no value was given and the requested coordinate transformation has not been defined.
        """
        # Validate the input.
        if target is self:
            _handle_error("Cannot define a transformation from a coordinate system to itself.")
        target = _validate_coordinates(target)
        # Make sure the source and target coordinate systems have the same dimension.
        if self.dim() != target.dim():
            _handle_error("The source and target coordinate systems must be of the same dimension.")
        if rules is None:
            # If no value is supplied, delete the previously defined transformation, if it exists. We also delete the transformation's Jacobians.
            if target in self._transformations:
                del self._transformations[target]
                del self._jacobians[target]
                del self._inverse_jacobians[target]
                del self._christoffel_jacobians[target]
            else:
                _handle_error(f"Cannot delete the transformation to {_lookup_names(target)}, since no such transformation exists.")
        else:
            # Validate the dictionary.
            _check_dict_type(rules, s.Symbol, s.Expr, "The rules must be a dictionary with SymPy `Symbol` objects as keys and SymPy `Expr` objects as values.")
            # Make sure the keys actually correspond to the source coordinate symbols.
            for key in rules:
                if key not in self._components:
                    _handle_error(f"The rules must be a dictionary with the source coordinate symbols as its keys. The symbol {key} is not one of the coordinate symbols {self._components}.")
            # Store the new rules.
            self._transformations[target] = rules
            # Calculate the Jacobians for later use.
            self._calculate_jacobians(target)

    def show(
        self: Self,
    ) -> None:
        """
        Show the components of this coordinate system in vector notation.
        """
        _display_tex(self.tex())

    def tex(
        self: Self,
    ) -> str:
        """
        Get the components of this coordinate system as a TeX string.
        #### Returns:
        The components as a TeX string.
        """
        return _to_tex(self._components)

    # =============== #
    # Private methods #
    # =============== #

    def _calculate_jacobians(
        self: Self,
        target: Coordinates,
    ) -> None:
        """
        Calculate the Jacobians for a transformation between this coordinate system and the target coordinate system.
        #### Parameters:
        * `target`: The target coordinate system.
        """
        # Collect the source and target symbols.
        source_symbols: s.Array = self._components
        target_symbols: s.Array = target._components
        # Retrieve the transformation rules.
        rules: CoordTransformation = self._transformations[target]
        # Store the dimension of the coordinate systems.
        dim: int = self.dim()
        # To calculate the Jacobian, we loop over all possible combinations of both coordinate systems and store the derivatives of each source coordinate with respect to each target coordinate. Note that it is possible for the rules to not include a specific source coordinate. Presumably, in that case, the same coordinate appears in the target system (e.g. t when the coordinate transformation is only spatial), so the derivative will be 1. Therefore, if the coordinate does not exist in the dictionary, we just use the coordinate itself.
        self._jacobians[target] = _simplify_array(s.Array([[s.diff(rules.get(source_symbols[i], source_symbols[i]), target_symbols[j]) for j in range(dim)] for i in range(dim)]))
        # The inverse Jacobian is just the inverse of the Jacobian matrix.
        self._inverse_jacobians[target] = _simplify_array(s.Array(cast(s.Matrix, s.Matrix(self._jacobians[target]).inv())))
        # The "Christoffel Jacobian" is the extra second-derivative term in the coordinate transformation of the Christoffel symbols.
        second_derivative: s.Array = _simplify_array(s.Array([[[s.diff(rules.get(source_symbols[i], source_symbols[i]), target_symbols[j], target_symbols[k]) for k in range(dim)] for j in range(dim)] for i in range(dim)]))
        self._christoffel_jacobians[target] = _simplify_array(_contract((self._inverse_jacobians[target], ["lambda'", "lambda"]), (second_derivative, ["lambda", "mu'", "nu'"]))[0])


class CovariantD:
    """
    A class representing a covariant derivative when used in a tensor calculation.
    """

    # =========== #
    # Constructor #
    # =========== #

    __slots__: tuple[str, ...] = ("_calc_letters",)

    # The symbol for a covariant derivative is the same for all instances.
    _symbol: str = r"\nabla[0]"

    def __init__(
        self: Self,
        index_spec: str | s.Symbol,
    ) -> None:
        r"""
        Construct a new covariant derivative object, which can be used in combination with tensor objects to calculate covariant derivatives with respect to the specified index.
        #### Parameters:
        * `index_spec`: A single TeX string or SymPy `Symbol` object indicating the index specification. A `Symbol` object will be converted into a TeX string, e.g. Symbol("mu") will be converted to "\mu".
        """
        # Validate the input and convert the SymPy `Symbol` objects, if given, to a TeX string.
        self._calc_letters: IndexSpecification = _collect_tex_symbols(index_spec)
        # Check that the index specification has exactly one index.
        if len(self._calc_letters) != 1:
            _handle_error("The index specification for a covariant derivative must contain exactly one index.")

    def __matmul__(
        self: Self,
        other: Tensor,
    ) -> Tensor:
        """
        Overloaded matrix multiplication operator. Used to apply the covariant derivative to a tensor.
        #### Parameters:
        * `other`: The tensor we wish to apply the covariant derivative to.
        #### Returns:
        A tensor containing the result of applying the covariant derivative, with index letters taken into account.
        """
        # Check that the other object is in fact a tensor.
        _check_type(other, Tensor, "The covariant derivative can only be applied to a tensor.")
        # The covariant derivative object should always have index letters set, since that is a class invariant.
        covariant_letter: str = self._calc_letters[0]
        # If the tensor is a scalar, then the covariant derivative reduces to the partial derivative.
        rank: int = other.rank()
        if rank == 0:
            return PartialD(covariant_letter) @ other
        # Check that the tensor has index letters set, and retrieve the letters.
        tensor_letters: IndexSpecification = other.index_letters()
        # We always use the tensor's default index configuration.
        use_indices: list[Literal[+1, -1]] = list(other.default_indices)
        # Save a reference to the Christoffel symbols of the tensor's associated metric. This will also calculate the Christoffel symbols if they haven't been calculated yet.
        use_christoffel: Christoffel = cast(Christoffel, other.metric().christoffel())
        # To avoid having to worry about raising or lowering indices, we calculate a gradient first, then possibly calculate a trace if a divergence is requested, as with a partial derivative. However, to calculate the gradient we first replace the index specification of the covariant derivative itself with a dummy index, otherwise this will cause the partial derivative to be a divergence and the Christoffel symbols to be traced prematurely.
        dummy_covariant: str = _to_tex(s.Dummy())
        # The first term is just the partial derivative.
        out_tensor: Tensor = PartialD(dummy_covariant) @ other
        # The next terms add one Christoffel symbol per index, contracted with that index. We use another dummy index for these contractions to prevent collisions with indices given by the user. The sign, as well as which index of the Christoffel symbols is contracted, is determined by whether the tensor's index is upper or lower. The tensor is always on the left of the contraction to ensure that the tensor's default coordinate system is used.
        dummy_christoffel: str = _to_tex(s.Dummy())
        for pos, (index_letter, index_type) in enumerate(zip(tensor_letters, use_indices, strict=True)):
            # Replace the current index with the dummy index in the tensor's index specification.
            index_replaced: IndexSpecification = tensor_letters[0:pos] + [dummy_christoffel] + tensor_letters[pos + 1 : rank]
            if index_type == 1:
                # For an upper index, add the Christoffel symbols with their third index contracted with the tensor.
                out_tensor += other(*index_replaced) @ use_christoffel(index_letter, dummy_covariant, dummy_christoffel)
            else:
                # For a lower index, subtract the Christoffel symbols with their first index contracted with the tensor.
                out_tensor -= other(*index_replaced) @ use_christoffel(dummy_christoffel, dummy_covariant, index_letter)
        # Create a symbol for the output tensor by increasing the numbering of the free indices in the tensor's symbol by 1 and prepending the covariant derivative.
        out_symbol: str = self._symbol + " " + _free_pattern.sub(lambda i: f"[{int(i[1]) + 1}]", other.symbol)
        out_tensor.symbol = out_symbol
        # If we are evaluating a gradient, we are done. If we are evaluating a divergence, we also need to trace over the summation index, but this will be taken care of automatically because `out_letters` will contain the same letter twice.
        out_letters: IndexSpecification = [covariant_letter, *tensor_letters]
        return out_tensor(*out_letters)


class PartialD:
    """
    A class representing a partial derivative when used in a tensor calculation.
    """

    # =========== #
    # Constructor #
    # =========== #

    __slots__: tuple[str, ...] = ("_calc_letters",)

    # The symbol for a partial derivative is the same for all instances.
    _symbol: str = r"\partial[0]"

    def __init__(
        self: Self,
        index_spec: str | s.Symbol,
    ) -> None:
        r"""
        Construct a new partial derivative object, which can be used in combination with tensor objects to calculate partial derivatives with respect to the specified index.
        #### Parameters:
        * `index_spec`: A single TeX string or SymPy `Symbol` object indicating the index specification. A `Symbol` object will be converted into a TeX string, e.g. Symbol("mu") will be converted to "\mu".
        """
        # Validate the input and convert the SymPy `Symbol` objects, if given, to a TeX string.
        self._calc_letters: IndexSpecification = _collect_tex_symbols(index_spec)
        # Check that the index specification has exactly one index.
        if len(self._calc_letters) != 1:
            _handle_error("The index specification for a partial derivative must contain exactly one index.")

    def __matmul__(
        self: Self,
        other: Tensor,
    ) -> Tensor:
        """
        Overloaded matrix multiplication operator. Used to apply the partial derivative to a tensor.
        #### Parameters:
        * `other`: The tensor we wish to apply the partial derivative to.
        #### Returns:
        A tensor containing the result of applying the partial derivative, with index letters taken into account.
        """
        # Check that the other object is in fact a tensor.
        _check_type(other, Tensor, "The partial derivative can only be applied to a tensor.")
        # The partial derivative object should always have index letters set, since that is a class invariant.
        partial_letter: str = self._calc_letters[0]
        # Check that the tensor has index letters set, and retrieve the letters.
        tensor_letters: IndexSpecification = other.index_letters()
        # We always use the tensor's default coordinate system and index configuration.
        use_coords: Coordinates = other.default_coords
        use_indices: list[Literal[+1, -1]] = list(other.default_indices)
        # We calculate a gradient first, then possibly calculate a trace if a divergence is requested. (There is no noticeable performance penalty, since we have to calculate all dim^rank components in any case, the question is only whether we keep them as is or sum over them.)
        if partial_letter in tensor_letters:
            # If the derivative's index matches one of the tensor's indices, we are calculating a divergence, so raise that index on the tensor to match the lower index on the derivative, which will save us from doing the index transformation on a possibly more complicated expression after taking the gradient.
            pos: int = tensor_letters.index(partial_letter)
            use_indices[pos] = 1
        # The index configuration of the output tensor will be such that the partial derivative always has a lower index in either case.
        out_indices: list[Literal[+1, -1]] = [-1, *use_indices]
        # Get the tensor's components in the desired representation, adding it if it does not already exist.
        components: s.Array = other.components(coords=use_coords, indices=tuple(use_indices), warn=False)
        # Collect the coordinate symbols, since we are taking derivatives with respect to them.
        coord_symbols: s.Array = other.default_coords.components()
        # Take the derivative of the entire tensor with respect to each of the coordinates and then combine all the results into one higher-rank tensor.
        result: s.Array = s.Array([s.diff(components[0] if other.rank() == 0 else components, coord_symbol) for coord_symbol in coord_symbols])
        # Create a symbol for the output tensor by increasing the numbering of the free indices in the tensor's symbol by 1 and prepending the partial derivative.
        out_symbol: str = self._symbol + " " + _free_pattern.sub(lambda i: f"[{int(i[1]) + 1}]", other.symbol)
        out_tensor: Tensor = Tensor(metric=other.metric(), coords=use_coords, indices=tuple(out_indices), components=result, symbol=out_symbol)
        # If we are evaluating a gradient, we are done. If we are evaluating a divergence, we also need to trace over the summation index, but this will be taken care of automatically because `out_letters` will contain the same letter twice.
        out_letters: IndexSpecification = [partial_letter, *tensor_letters]
        return out_tensor(*out_letters)


class Tensor:
    """
    A class representing a generic tensor.
    """

    # =========== #
    # Constructor #
    # =========== #

    __slots__: tuple[str, ...] = (
        "_calc_letters",
        "_components",
        "_default_coords",
        "_default_indices",
        "_metric",
        "_symbol",
    )

    def __init__(
        self: Self,
        *,
        metric: Metric,
        indices: IndexConfiguration,
        coords: Coordinates,
        components: list[Any] | s.NDimArray | s.Matrix,
        symbol: str | s.Symbol = r"\square",
        simplify: bool = False,
    ) -> None:
        r"""
        Construct a new tensor object.
        #### Parameters:
        * `metric`: An OGRePy `Metric` object specifying the metric which will be used to raise and lower indices for this tensor.
        * `indices`: A tuple of integers specifying the index configuration of the representation of the initialization components. Each integer in the tuple can be either +1 for an upper index or -1 for a lower index. Will also be designated the default index configuration of the tensor.
        * `coords`: An OGRePy `Coordinates` object specifying the coordinate system of the representation of the initialization components. Will also be designated the default coordinate system of the tensor.
        * `components`: The components with which to initialize the tensor. Can be a list, a SymPy `Array` object, or (for rank 2 tensors) a SymPy `Matrix` object.
        * `symbol` (optional): A string or a SymPy `Symbol` object designating the symbol to be used when displaying the tensor. The string can include any TeX symbols, e.g. `r"\hat{T}"` (note the `r` in front of the string, indicating that the `\` in the string is not an escape character). If omitted, the placeholder $\square$ (`r"\square"`) will be used.
        * `simplify` (optional): Whether to simplify (`True`) or not simplify (`False`, the default) the components before storing them.
        """
        # Validate and store the metric, default coordinates, and default index configuration.
        self._metric: Metric = _validate_metric(metric)
        self._default_coords: Coordinates = _validate_coordinates(coords)
        self._default_indices: IndexConfiguration = _validate_indices(indices)
        # The components are stored as values in a dictionary, where the keys are tuples of the form (indices, coords).
        self._components: Components = {}
        # The number of coordinates is the dimension of the manifold, which we will need for validation.
        num_coords: int = len(self._default_coords.components())
        # The number of indices is the rank of the tensor, which we will also need for validation.
        num_indices: int = len(self._default_indices)
        # Validate the components.
        components = _validate_components(components=components, num_coords=num_coords, num_indices=num_indices)
        # Simplify the components, depending on the `simplify` argument.
        if simplify is True:
            components = _simplify_array(components)
        # Store the components.
        self._store_components(indices=self._default_indices, coords=self._default_coords, components=components)
        # When validating the symbol, we also convert it from a `Symbol` to a TeX string if necessary, and add index placeholders if the user did not supply them.
        self._symbol: str = _validate_symbol(symbol, num_indices)
        # The `_calc_letters` attribute is used only in temporary objects, when performing calculations using tensors.
        self._calc_letters: IndexSpecification | None = None

    # =============== #
    # Special methods #
    # =============== #

    def __add__(
        self: Self,
        other: Tensor,
    ) -> Tensor:
        """
        Overloaded addition operator. Used to add two tensors.
        #### Parameters:
        * `other`: The tensor we wish to add to this tensor.
        #### Returns:
        A tensor containing the sum of this tensor and the other tensor, with index letters taken into account.
        """
        # Check that the other object is in fact a tensor.
        _check_type(other, Tensor, "Tensors can only be added to other tensors.")
        # Check that both tensors have index letters set, and retrieve the letters.
        first_letters: IndexSpecification = self._check_index_letters()
        second_letters: IndexSpecification = other._check_index_letters()
        # Check that both tensors are associated with the same metric.
        if self._metric is not other._metric:
            _handle_error(f"The tensors cannot be added, as they are associated with different metrics, {self._metric} and {other._metric}.")
        # Check that both tensors have the same rank.
        rank: int = self.rank()
        if rank != other.rank():
            _handle_error(f"The tensors cannot be added, as they have different ranks, {rank} and {other.rank()}.")
        # Check that the index specifications of both tensors are the same up to permutation.
        if set(first_letters) != set(second_letters):
            _handle_error("The tensors cannot be added, as their index specifications must be the same up to permutation.")
        # The components that will be added are the ones corresponding to the default representation of the first tensor.
        use_coords: Coordinates = self._default_coords
        use_indices: IndexConfiguration = self._default_indices
        first_components: s.Array = self._get_components(indices=use_indices, coords=use_coords)
        # The index configuration for the second tensor must be rearranged to correctly calculate expressions like T^a_b + T_b^a.
        use_second_indices: IndexConfiguration = tuple(use_indices[first_letters.index(second_letters[i])] for i in range(rank))
        # Add the representation in the appropriate coordinate system and index configuration to the second tensor if it does not already exist.
        second_components: s.Array = other._calc_representation(use_coords, use_second_indices)
        # If needed, permute the indices of the second tensor so that they are the same as the first tensor (for example, if we have S^ab + T^ba we will permute the indices of T to ab).
        other_symbol: str = other._symbol
        if len(first_letters) > 0 and first_letters != second_letters:
            second_components = cast(s.Array, s.permutedims(second_components, index_order_old=second_letters, index_order_new=first_letters))
            # Permute the free index placeholders for the second tensor as well.
            other_symbol = _permute_placeholders(other_symbol, second_letters, first_letters)
        # If the second symbol starts with a minus sign, trim it and perform a subtraction instead of addition.
        sign: str = " + " if not other_symbol.startswith("-") else " "
        # Add the two tensors by adding the components directly.
        result: s.Array = first_components + second_components
        # Simplify and store the result in a new tensor object.
        output_tensor: Tensor = Tensor(metric=self._metric, coords=use_coords, indices=use_indices, components=_simplify_array(result))
        # Specify the index letters for the resulting tensor before returning it, in case we are chaining multiple operations.
        output_tensor._calc_letters = first_letters
        # Create a symbol for the new tensor, keeping all existing index placeholders. Note that this will create duplicate placeholders, e.g. `S[0][1] + T[0][1]`, which will result in the correct notation S_ab + T_ab.
        output_tensor._symbol = self._symbol + sign + other_symbol
        return output_tensor

    def __call__(
        self: Self,
        *letters: str | s.Symbol,
    ) -> Self | Tensor:
        r"""
        Overloaded function call operator. Used to indicate that the tensor is being used in calculations, with the arguments specifying the indices to use. Also used to calculate tensor traces (by repeating one or more index letters exactly twice) and to indicate which index letters to use when calling `show()`.
        #### Parameters:
        * `letters` (optional): Zero or more strings or SymPy `Symbol` objects indicating the index specification. `Symbol` objects will be converted into TeX strings, e.g. Symbol("mu") will be converted to "\mu". Strings must be given in the same format as SymPy's `symbols()` function, that is, a space- or comma-separated list of one or more letters or TeX codes. For example, "mu nu" will be converted to two strings, "\mu" and "\nu". The list will splice in any symbols, even if two or more symbols are defined together. For example, if `a` is a SymPy `Symbol` and the input is [a, "b", "c d"], the output will be ["a", "b", "c", "d"].
        #### Returns:
        A shallow copy of this tensor, with the index letters saved, for use in calculations.
        """
        # Validate the input and convert all the letters to TeX symbols.
        calc_letters: IndexSpecification = _collect_tex_symbols(*letters)
        # Check that the index specification matches the rank of the tensor.
        if len(calc_letters) != self.rank():
            _handle_error(f"{f"The index specification\n${"".join(calc_letters)}$\n" if len(calc_letters) > 0 else "The empty index specification "}does not match the rank of the tensor. The number of indices should be {self.rank()}.")
        # Check for duplicate index letters.
        tally: dict[str, int] = {letter: calc_letters.count(letter) for letter in set(calc_letters)}
        max_duplicates: int = max(tally.values()) if len(tally) > 0 else 0
        if max_duplicates > 2:
            # We can't have more than 2 instances of the same index.
            invalid_indices: IndexSpecification = [letter for letter, count in tally.items() if count > 2]
            _handle_error(f"The index specification\n${"".join(calc_letters)}$\nis invalid, as it contains more than two instances of the {"index" if len(invalid_indices) == 1 else "indices"} \n${", ".join(invalid_indices)}$\n.")  # noqa: RET503
        elif max_duplicates == 2:
            # If any indices appear exactly twice, we need to trace them.
            trace_letters: IndexSpecification = [letter for letter, count in tally.items() if count == 2]
            return self._trace(all_letters=calc_letters, trace_letters=trace_letters)
        else:
            # Create a shallow copy of this tensor, which will be modified with the given index letters. Creating a copy is necessary because we might want, for example, to do something like `tensor(a, b) + tensor(b, c)` for the same tensor, in which case each term must be a different object with different index letters.
            result: Self = copy(self)
            # Store the index letters.
            result._calc_letters = calc_letters
            return result

    def __getitem__(
        self: Self,
        letters: str,
    ) -> Tensor:
        """
        Overloaded subscript [] operator. Used to emulate index specifications from the Mathematica version of OGRe.
        #### Parameters:
        * `letters`: A string of letters indicating the index specification. The string will be converted to a SymPy-compatible space-separated list, so that each individual letter will be converted to a separate symbol, and `__call__()` will be called on the SymPy-compatible list. For example, `MyTensor["abc"]` is equivalent to `MyTensor("a b c")`.
        #### Returns:
        A shallow copy of this tensor, with the index letters saved, for use in calculations.
        """
        return self(" ".join(letters))

    def __invert__(
        self: Self,
    ) -> None:
        """
        Overloaded unary invert operator (~). Used as a shortcut for calling `list()`.
        """
        self.list()

    def __matmul__(
        self: Self,
        other: Tensor,
    ) -> Tensor:
        """
        Overloaded matrix multiplication operator. Used to contract two tensors.
        #### Parameters:
        * `other`: The tensor we wish to contract with this tensor.
        #### Returns:
        A tensor containing the contraction of this tensor and the other tensor, with index letters taken into account.
        """
        # Check that the other object is in fact a tensor.
        _check_type(other, Tensor, "Tensors can only be contracted with other tensors.")
        # Check that both tensors have index letters set, and retrieve the letters.
        first_letters: IndexSpecification = self._check_index_letters()
        second_letters: IndexSpecification = other._check_index_letters()
        # Check that both tensors are associated with the same metric.
        if self._metric is not other._metric:
            _handle_error(f"The tensors cannot be contracted, as they are associated with different metrics, {self._metric} and {other._metric}.")
        # If either of the tensors is a scalar, simply do multiplication of tensor by scalar, and then modify the symbol of the output tensor so it contains the symbol of the scalar instead of the value of the scalar.
        if self.rank() == 0:
            out_tensor: Tensor = other.__mul__(self._get_components(indices=self._default_indices, coords=self._default_coords)[0])
            out_tensor._symbol = self._symbol + " " + other._symbol
            return out_tensor
        if other.rank() == 0:
            out_tensor: Tensor = self.__mul__(other._get_components(indices=other._default_indices, coords=other._default_coords)[0])
            out_tensor._symbol = other._symbol + " " + self._symbol
            return out_tensor
        # We perform the calculation in the default coordinate system of the first tensor.
        use_coords: Coordinates = self._default_coords
        # We start with the default index configuration of both tensors, but then rearrange the indices on the second tensor so that any contracted indices will be one upper, one lower. The indices for the second tensor are obtained as a mutable list for this purpose.
        use_first_indices: IndexConfiguration = self._default_indices
        use_second_indices_list: list[Literal[1, -1]] = [*other._default_indices]
        # Determine the index configuration of the output tensor. We start from the default indices of the first tensor, remove any indices that were contracted, and add the remaining free indices of the second tensor.
        out_indices_list: list[Literal[1, -1, 0]] = [*use_first_indices]
        index_placeholders: list[list[int]] = []
        for i in range(len(use_second_indices_list)):
            if second_letters[i] in first_letters:
                # If the same index letter appears in both index specifications, then it is a contracted index. Find the position of the index that is contracted in the first tensor's index specification.
                position: int = first_letters.index(second_letters[i])
                # In the index configuration of the second tensor, the index at the contracted position must be the opposite of the same index in the index configuration of the first tensor. (Recall that +1 is upper index and -1 is lower index, so negation switches the index between upper and lower.)
                use_second_indices_list[i] = -use_first_indices[position]
                # Since this index is contracted, it will not appear in the resulting tensor. Mark it with 0 and remove it later.
                out_indices_list[position] = 0
                # Keep track of the locations of the contracted indices so we can indicate the index placeholders there in the output symbol.
                index_placeholders.append([position, self.rank() + i])
            else:
                # If an index letter does not appear in both index specifications, then it is a free index. Add it to the list of indices in the new tensor without switching the type of the index (upper or lower).
                out_indices_list.append(use_second_indices_list[i])
        # Remove all the indices we marked for removal earlier, and save the index configuration for the output tensor as a tuple.
        out_indices: IndexConfiguration = tuple(i for i in out_indices_list if i != 0)
        # Similarly, convert the modified index configuration for the second tensor to a tuple.
        use_second_indices: IndexConfiguration = tuple(use_second_indices_list)
        # The components of the first tensor will be taken in the default index representation.
        first_components: s.Array = self._get_components(indices=use_first_indices, coords=use_coords)
        # The components of the second tensor will be taken in the index representation that matches the contraction, with indices raised or lowered to match the corresponding indices in the first tensor.
        second_components: s.Array = other._calc_representation(use_coords, use_second_indices)
        # Perform the contraction.
        result: TensorWithIndexSpecification = _contract((first_components, [*first_letters]), (second_components, [*second_letters]))
        # Simplify and store the result in a new tensor object.
        output_tensor: Tensor = Tensor(metric=self._metric, coords=use_coords, indices=out_indices, components=_simplify_array(result[0]))
        # Specify the index letters for the resulting tensor before returning it, in case we are chaining multiple operations.
        output_tensor._calc_letters = result[1]
        # Increase the numbering of the free indices in the second tensor's symbol so they start after the last number in the first tensor's symbol. For example, if we had 2 free indices [0] and [1] in the first symbol, then [0] in the second symbol will be increased to [2], and so on. (This assumes indices are consecutive and always start from 0, but that is a class invariant.)
        second_symbol: str = _free_pattern.sub(lambda i: f"[{int(i[1]) + self.rank()}]", other._symbol)
        # Count how many summation indices there already are in the first tensor's symbol (from previous contractions).
        contract_count: int = len(_unique_summation_placeholders(self._symbol))
        # Increase the numbering of the summation indices as well.
        second_symbol = _summation_pattern.sub(lambda match: f"[{match[0]}{int(match[1]) + contract_count}{match[2]}]", second_symbol)
        # Add the number of contractions in the second symbol to the total count.
        contract_count += len(_unique_summation_placeholders(second_symbol))
        # Join the symbols of the two tensors to create a new symbol for the output tensor, with consecutive summation index placeholders.
        out_symbol: str = _add_parentheses(self._symbol) + " " + _add_parentheses(second_symbol)
        # Convert each pair of indices that are contracted out into summation indices (with a :). For example, `A[0][1] B[2][3]` with indices 1 and 2 contracted will become `A[0][:0] B[0:][3]`. The position of the : indicates the original relationship between the indices, so that for example in (A[:0] + B[:0])C[0:] we will know to put a lower index on the A and B and an upper index on the C. Start from the number of summation indices that already exist.
        for i, pos in enumerate(index_placeholders, contract_count):
            out_symbol = out_symbol.replace(f"[{pos[0]}]", f"[:{i}]")
            out_symbol = out_symbol.replace(f"[{pos[1]}]", f"[{i}:]")
        # After converting some indices to summation indices, the free indices will not be consecutive, so we need to fix that.
        out_symbol = _consecutive_placeholders(out_symbol)
        # Store the new symbol and return the new tensor.
        output_tensor._symbol = out_symbol
        return output_tensor

    def __mul__(
        self: Self,
        other: s.Basic | AnyNumber,
    ) -> Tensor:
        """
        Overloaded multiplication operator. Used to multiply a tensor by a scalar.
        #### Parameters:
        * `other`: The scalar we wish to multiply this tensor by.
        #### Returns:
        A tensor containing the product of this tensor and the given scalar, with index letters preserved.
        """
        # Validate the input.
        if isinstance(other, Tensor):
            _handle_error("Cannot multiply two tensors. Did you mean to use the `@` operator to contract the tensors?")
        _check_type(other, s.Basic | Number, "Can only multiply a tensor by a number or a SymPy expression.")
        # Check that the tensor has index letters set, and retrieve the letters.
        letters: IndexSpecification = self._check_index_letters()
        # The components that will be multiplied are the ones corresponding to the default representation of the tensor.
        components: s.Array = self._get_components(indices=self._default_indices, coords=self._default_coords)
        # Multiply each component of the tensor by the scalar, simplify, and store the result in a new tensor object.
        output_tensor: Tensor = Tensor(metric=self._metric, coords=self._default_coords, indices=self._default_indices, components=_simplify_array(cast(s.Array, components * other)))
        # Specify the index letters for the resulting tensor before returning it, in case we are chaining multiple operations.
        output_tensor._calc_letters = letters
        # Create a TeX symbol for the scalar. We use a dirty trick: multiply by a dummy symbol so that SymPy will add parentheses if necessary, then simply remove the dummy symbol. This also turns -1 into -, which is desirable. However, it turns 1/2 into /2, which is not desirable, so we fix that with a replacement.
        dummy: str = _to_tex(s.Dummy())
        scalar_symbol: str = _to_tex(cast(s.Mul, other * sym(dummy))).replace(dummy, "").strip().replace(r"\frac{}", r"\frac{1}")
        tensor_symbol: str = _add_parentheses(self._symbol)
        if tensor_symbol.startswith("-"):
            # If the tensor's symbol starts with a minus sign, remove it.
            tensor_symbol = tensor_symbol[1:]
            # If both the tensor and the scalar are negative, remove the minus sign from the scalar too. Otherwise, attach the tensor's minus sign to the scalar, since it will go in front.
            scalar_symbol = scalar_symbol[1:] if scalar_symbol.startswith("-") else "-" + scalar_symbol
        # Create a symbol for the new tensor, with all index placeholders preserved.
        output_tensor._symbol = scalar_symbol + " " + _add_parentheses(tensor_symbol)
        return output_tensor

    def __neg__(
        self: Self,
    ) -> Tensor:
        """
        Overloaded unary minus operator. Used to negate a tensor.
        #### Returns:
        A tensor containing the negative of this tensor, with index letters taken into account.
        """
        return -1 * self

    def __pos__(
        self: Self,
    ) -> None:
        """
        Overloaded unary plus operator. Used as a shortcut for calling `info()`.
        """
        self.info()

    def __radd__(
        self: Self,
        other: Tensor,
    ) -> Tensor:
        """
        Overloaded reflected addition operator. Used to add two tensors.
        #### Parameters:
        * `other`: The tensor we wish to add to this tensor.
        #### Returns:
        A tensor containing the sum of this tensor and the other tensor, with index letters taken into account.
        """
        return self.__add__(other)

    def __rmatmul__(
        self: Self,
        other: Tensor,
    ) -> Tensor:
        """
        Overloaded reflected matrix multiplication operator. Used to contract two tensors.
        #### Parameters:
        * `other`: The tensor we wish to contract with this tensor.
        #### Returns:
        A tensor containing the contraction of this tensor and the other tensor, with index letters taken into account.
        """
        return self.__matmul__(other)

    def __rmul__(
        self: Self,
        other: s.Basic | AnyNumber,
    ) -> Tensor:
        """
        Overloaded reflected multiplication operator. Used to multiply a tensor by a scalar.
        #### Parameters:
        * `other`: The scalar we wish to multiply this tensor by.
        #### Returns:
        A tensor containing the product of this tensor and the given scalar, with index letters preserved.
        """
        return self.__mul__(other)

    def __rsub__(
        self: Self,
        other: Tensor,
    ) -> Tensor:
        """
        Overloaded reflected subtraction operator. Used to subtract two tensors.
        #### Parameters:
        * `other`: The tensor we wish to subtract from this tensor.
        #### Returns:
        A tensor containing the difference of this tensor and the other tensor, with index letters taken into account.
        """
        return self.__add__(-other)

    @override
    def __str__(
        self: Self,
    ) -> str:
        """
        Overloaded method to obtain this tensor's name as a string.
        #### Returns:
        The names of any notebook variables that refer to this tensor object.
        """
        # We get rid of the backticks since we just want a pure string, not a Markdown formatted string.
        return _lookup_names(self).replace("`", "")

    def __sub__(
        self: Self,
        other: Tensor,
    ) -> Tensor:
        """
        Overloaded subtraction operator. Used to subtract two tensors.
        #### Parameters:
        * `other`: The tensor we wish to subtract from this tensor.
        #### Returns:
        A tensor containing the difference of this tensor and the other tensor, with index letters taken into account.
        """
        return self.__add__(-other)

    def _repr_markdown_(
        self: Self,
    ) -> str:
        """
        Overloaded method to print this tensor in Markdown format to a notebook cell. Will reproduce the same output as calling the `show()` method with no arguments.
        #### Returns:
        The Markdown text to display in the notebook.
        """
        text: str = f"$${self.tex_show()}$$"
        return _format_with_css(text)

    # ========== #
    # Properties #
    # ========== #

    ### default_coords property ###

    @property
    def default_coords(
        self: Self,
    ) -> Coordinates:
        """
        A reference to the default coordinates of this tensor. The default coordinates determine which coordinate system the tensor will be displayed in when calling `show()` or `list()` without specifying coordinates. To see which notebook variables refer to the `Coordinates` object, use `info()`.
        """
        return self._default_coords

    @default_coords.setter
    def default_coords(
        self: Self,
        value: Coordinates,
    ) -> None:
        # Make sure the input is valid.
        value = _validate_coordinates(value)
        # Calculate the representation of this tensor in the new default coordinates, if it does not already exist. We do this for the default indices only.
        _ = self._calc_representation(value, self._default_indices)
        # Store the new default coordinates.
        self._default_coords = value

    ### default_indices property ###

    @property
    def default_indices(
        self: Self,
    ) -> IndexConfiguration:
        """
        A tuple representing the default indices of this tensor. The default indices determine which index configuration the tensor will be displayed in when calling `show()` or `list()` without specifying indices. Each integer in the tuple can be either +1 for an upper index or -1 for a lower index.
        """
        return self._default_indices

    @default_indices.setter
    def default_indices(
        self: Self,
        value: IndexConfiguration,
    ) -> None:
        # Make sure we are not trying to change the default indices on a class that does not allow that.
        if isinstance(self, FixedDefaultIndices):
            _handle_error("Cannot change the default index configuration for this tensor object.")
        # Make sure the input is valid.
        value = _validate_indices(value)
        if len(value) != len(self._default_indices):
            _handle_error("The number of indices must match the rank of the tensor.")
        # Calculate the representation of this tensor in the new default indices, if it does not already exist. We do this for the default coordinates only.
        _ = self._calc_representation(self._default_coords, value)
        # Store the new default indices.
        self._default_indices = value

    ### symbol property ###

    @property
    def symbol(
        self: Self,
    ) -> str:
        """
        The TeX string designating the symbol to be used when displaying the tensor. If the symbol has any free index placeholders of the form `[n]` with integer `n`, they will be replaced with index letters as needed. If the symbol has any pairs of summation index placeholders of the form `[:n]` and `[n:]` with integer `n`, they will be replaced with additional indices, with `[:n]` getting a lower index and `[n:]` an upper index. Both types of placeholders must be consecutively and independently numbered starting from 0. If index placeholders are not specified, they will be added automatically.
        """
        return self._symbol

    @symbol.setter
    def symbol(
        self: Self,
        value: str | s.Symbol,
    ) -> None:
        self._symbol = _validate_symbol(value, self.rank())

    # ============== #
    # Public methods #
    # ============== #

    def components(
        self: Self,
        *,
        indices: IndexConfiguration | None = None,
        coords: Coordinates | None = None,
        warn: bool = True,
    ) -> s.Array:
        """
        Get the components of this tensor in a particular representation as a SymPy `Array`.
        #### Parameters:
        * `indices` (optional): A tuple of integers specifying the index configuration of the representation. Each integer in the tuple can be either +1 for an upper index or -1 for a lower index. If not specified, the tensor's default index configuration will be used.
        * `coords` (optional): An OGRePy `Coordinates` object specifying the coordinate system of the representation. If not specified, the tensor's default coordinate system will be used.
        * `warn` (optional): Whether to display a warning if using the default coordinate system and/or index configuration. This warning is intended to ensure that the user always knows which representation the components are given in, even if it is not specified explicitly. The default value is `True`; specify `warn=False` to suppress the warning.
        #### Returns:
        The desired components as a SymPy `Array`.
        """
        # Validate the coordinates and indices.
        use_coords: Coordinates = self._validate_or_default_coords(coords)
        use_indices: IndexConfiguration = self._validate_or_default_indices(indices)
        # Retrieve the components, calculating them if they have not already been calculated.
        components: s.Array = self._calc_representation(use_coords, use_indices)
        # Optionally, show a warning if using the defaults, to ensure that the user knows which representation the components correspond to.
        if warn:
            warning: str = ""
            if coords is None:
                warning = f"Using default coordinate system {_lookup_names(use_coords)}"
            if indices is None:
                warning += f"{"Using" if warning == "" else " and"} default index configuration {use_indices}"
            if warning != "":
                _display_markdown(f"**OGRePy**: {warning}.")
        return components

    def dim(
        self: Self,
    ) -> int:
        """
        Get the number of dimensions in the manifold that this tensor is defined in.
        #### Returns:
        The number of dimensions.
        """
        return len(self._default_coords.components())

    def index_letters(
        self: Self,
    ) -> IndexSpecification:
        """
        Retrieve the index letters attached to this tensor for use in tensor calculations, if any.
        #### Exceptions:
        * `OGRePyError`: If the tensor has no index letters.
        #### Returns:
        The index letters.
        """
        return self._check_index_letters()

    def info(
        self: Self,
    ) -> None:
        """
        Display information about this tensor, including its class, symbol, rank, dimensions, default coordinates, default indices, and associated metric (if the tensor is not itself a metric), in human-readable form.
        """
        text: str = f"* **Name**: {_lookup_names(self)}\n"
        text += f"* **Class**: `{type(self).__name__}`\n"
        text += f"* **Symbol**: ${self._tex_symbol(indices=self._default_indices, letters=options.index_letters)}$\n"
        text += f"* **Rank**: {self.rank()}\n"
        text += f"* **Dimensions**: {self.dim()}\n"
        text += f"* **Default Coordinates**: {_lookup_names(self._default_coords)}\n"
        text += f"* **Default Indices**: {self._default_indices}\n"
        if not isinstance(self, Metric):
            text += f"* **Metric**: {_lookup_names(self._metric)}\n"
        else:
            used_by: list[str] = _using_metric(self)
            text += f"* **Associated Metric For**: {", ".join(used_by) if len(used_by) > 0 else "None"}\n"
        _display_markdown(text)

    def list(
        self: Self,
        *,
        indices: IndexConfiguration | None = None,
        coords: Coordinates | None = None,
        replace: dict[Any, Any] | None = None,
        function: Callable[..., Any] | None = None,
        simplify: bool = False,
        exact: bool = False,
    ) -> None:
        """
        List the unique, non-zero components of this tensor in a particular representation.
        #### Parameters:
        * `indices` (optional): A tuple of integers specifying the index configuration of the representation to use. Each integer in the tuple can be either +1 for an upper index or -1 for a lower index. If not specified, the tensor's default index configuration will be used.
        * `coords` (optional): An OGRePy `Coordinates` object specifying the coordinate system of the representation to use. If not specified, the tensor's default coordinate system will be used.
        * `replace` (optional): A dictionary of substitutions to be made in the components before printing them. Each key in the dictionary will be replaced with its value.
        * `function` (optional): A function to apply to the components before printing them. If both `replace` and `function` are used, `function` will run first, then `replace`.
        * `simplify` (optional): Whether to simplify (`True`) or not simplify (`False`, the default) the components after applying `replace` and/or `function`.
        * `exact` (optional): Whether to check if two components are the same up to sign by simple comparison (`False`, the default) or by simplifying the sum of the components (`True`). Note that setting this to `True` may increase processing time for large tensors with complicated components.
        """
        _display_tex(self.tex_list(indices=indices, coords=coords, replace=replace, function=function, simplify=simplify, exact=exact))

    def mathematica(
        self: Self,
        *,
        indices: IndexConfiguration | None = None,
        coords: Coordinates | None = None,
        warn: bool = True,
    ) -> str:
        """
        Get the components of this tensor in a particular representation as a Mathematica list.
        #### Parameters:
        * `indices` (optional): A tuple of integers specifying the index configuration of the representation. Each integer in the tuple can be either +1 for an upper index or -1 for a lower index. If not specified, the tensor's default index configuration will be used.
        * `coords` (optional): An OGRePy `Coordinates` object specifying the coordinate system of the representation. If not specified, the tensor's default coordinate system will be used.
        * `warn` (optional): Whether to display a warning if using the default coordinate system and/or index configuration. This warning is intended to ensure that the user always knows which representation the components are given in, even if it is not specified explicitly. The default value is `True`; specify `warn=False` to suppress the warning.
        #### Returns:
        The desired components as a Mathematica list.
        """
        return str(s.mathematica_code(self.components(indices=indices, coords=coords, warn=warn)))

    def metric(
        self: Self,
    ) -> Metric:
        """
        Get an object reference to the metric associated with this tensor. The associated metric is used to raise and lower indices for this tensor. To see which notebook variables refer to this object, use `info()`.
        #### Returns:
        An object reference to the metric.
        """
        return self._metric

    def permute(
        self: Self,
        new: IndexSpecification | list[str | s.Symbol] | str,
        *,
        old: IndexSpecification | list[str | s.Symbol] | str | None = None,
    ) -> None:
        r"""
        Permute the indices of this tensor.
        #### Parameters:
        * `new`: A list of one or more strings or SymPy `Symbol` objects indicating the new index specification. `Symbol` objects will be converted into TeX strings, e.g. Symbol("mu") will be converted to "\mu". Strings must be given in the same format as SymPy's `symbols()` function, that is, a space- or comma-separated list of one or more letters or TeX codes. For example, "mu nu" will be converted to two strings, "\mu" and "\nu". The list will splice in any symbols, even if two or more symbols are defined together. For example, if `a` is a SymPy `Symbol` and the input is [a, "b", "c d"], the output will be ["a", "b", "c", "d"]. A single string of space- or comma-separated symbols can also be entered instead of a list.
        * `old` (optional): A list of one or more strings or SymPy `Symbol` objects, or a single string, similarly indicating the old index specification. For example, if `old` is `"a b c"` and `new` is `"a c b"`, then the second and third indices will be exchanged. If `old` is omitted, the index specification stored in this tensor, either as a result of the calculation that created it or explicitly via the `()` function call operator, will be used.
        """
        # Validate the input and convert all the letters to TeX symbols.
        old_letters: IndexSpecification = []
        if old is None:
            if self._calc_letters is not None:
                old_letters = self._calc_letters
            else:
                _handle_error("Cannot find an existing index specification. Please specify the old specification explicitly as a second argument.")
        else:
            old_letters = _validate_permutation(old, self.rank())
        new_letters: IndexSpecification = _validate_permutation(new, self.rank())
        # Check that the old and new index specifications are the same up to permutation.
        if set(old_letters) != set(new_letters):
            _handle_error("The index specifications must be the same up to permutation.")
        # Create a list of replacements for the index configuration. The item at position i in the list corresponds to the position in the old indices of the index that should be moved to position i in the new indices. For example, for the permutation a b c d -> a d b c we will have the list [0, 3, 1, 2].
        replacements: list[int] = [old_letters.index(letter) for letter in new_letters]
        # Permute the indices in all stored representations of this tensor. Permute the index configurations as well; for example, for the permutation abc->acb we will permute (-1, +1, -1) to (-1, -1, +1) and so on.
        new_components: Components = {}
        for (indices, coords), components in self._components.items():
            new_indices: IndexConfiguration = tuple(indices[replacements[i]] for i in range(len(indices)))
            new_components[(new_indices, coords)] = cast(s.Array, s.permutedims(components, index_order_old=old_letters, index_order_new=new_letters))
        # Permute the default indices as well.
        self._default_indices = tuple(self._default_indices[replacements[i]] for i in range(len(self._default_indices)))
        # Store the result.
        self._components = new_components

    def rank(
        self: Self,
    ) -> int:
        """
        Get the rank of this tensor, that is, the number of indices.
        #### Returns:
        The rank.
        """
        return len(self._default_indices)

    def show(
        self: Self,
        *,
        indices: IndexConfiguration | None = None,
        coords: Coordinates | None = None,
        replace: dict[Any, Any] | None = None,
        function: Callable[..., Any] | None = None,
        simplify: bool = False,
    ) -> None:
        """
        Show the components of this tensor in a particular representation, in vector or matrix notation when applicable.
        #### Parameters:
        * `indices` (optional): A tuple of integers specifying the index configuration of the representation to use. Each integer in the tuple can be either +1 for an upper index or -1 for a lower index. If not specified, the tensor's default index configuration will be used.
        * `coords` (optional): An OGRePy `Coordinates` object specifying the coordinate system of the representation to use. If not specified, the tensor's default coordinate system will be used.
        * `replace` (optional): A dictionary of substitutions to be made in the components before printing them. Each key in the dictionary will be replaced with its value.
        * `function` (optional): A function to apply to the components before printing them. If both `replace` and `function` are used, `function` will run first, then `replace`.
        * `simplify` (optional): Whether to simplify (`True`) or not simplify (`False`, the default) the components after applying `replace` and/or `function`.
        """
        _display_tex(self.tex_show(indices=indices, coords=coords, replace=replace, function=function, simplify=simplify))

    def simplify(
        self: Self,
    ) -> None:
        """
        Simplify all previously-calculated representations of this tensor using the simplification function defined by `options.simplify_func`. To be used if the function has changed after the components have already been calculated; has no effect otherwise.
        """
        new_components: Components = {}
        for (indices, coords), components in self._components.items():
            new_components[(indices, coords)] = _simplify_array(components)
        self._components = new_components

    def tex_components(
        self: Self,
        *,
        indices: IndexConfiguration | None = None,
        coords: Coordinates | None = None,
        warn: bool = True,
    ) -> str:
        """
        Get the components of this tensor in a particular representation as a TeX string.
        #### Parameters:
        * `indices` (optional): A tuple of integers specifying the index configuration of the representation. Each integer in the tuple can be either +1 for an upper index or -1 for a lower index. If not specified, the tensor's default index configuration will be used.
        * `coords` (optional): An OGRePy `Coordinates` object specifying the coordinate system of the representation. If not specified, the tensor's default coordinate system will be used.
        * `warn` (optional): Whether to display a warning if using the default coordinate system and/or index configuration. This warning is intended to ensure that the user always knows which representation the components are given in, even if it is not specified explicitly. The default value is `True`; specify `warn=False` to suppress the warning.
        #### Returns:
        The desired components as a TeX string.
        """
        return _to_tex(self.components(indices=indices, coords=coords, warn=warn))

    def tex_symbol(
        self: Self,
        *,
        indices: IndexConfiguration | None = None,
        letters: IndexSpecification | list[str | s.Symbol] | None = None,
    ) -> str:
        r"""
        Generate TeX code for the tensor's symbol and indices in the desired representation.
        #### Parameters:
        * `indices` (optional): A tuple of integers specifying the index configuration of the representation. Each integer in the tuple can be either +1 for an upper index or -1 for a lower index. If not specified, the tensor's default index configuration will be used.
        * `letters` (optional): A list of zero or more strings or SymPy `Symbol` objects indicating the index specification. `Symbol` objects will be converted into TeX strings, e.g. Symbol("mu") will be converted to "\mu". Strings must be given in the same format as SymPy's `symbols()` function, that is, a space- or comma-separated list of one or more letters or TeX codes. For example, "mu nu" will be converted to two strings, "\mu" and "\nu". The list will splice in any symbols, even if two or more symbols are defined together. For example, if `a` is a SymPy `Symbol` and the input is [a, "b", "c d"], the output will be ["a", "b", "c", "d"]. Both "mu" and "\mu" will result in the same output, and similarly for all Greek letters. If not specified, the default index letters will be used.
        #### Returns:
        The TeX code.
        """
        # Call the private method after validating the input.
        if letters is None:
            letters = options.index_letters
        return self._tex_symbol(indices=self._validate_or_default_indices(indices), letters=_collect_tex_symbols(*letters))

    def tex_list(
        self: Self,
        *,
        indices: IndexConfiguration | None = None,
        coords: Coordinates | None = None,
        replace: dict[Any, Any] | None = None,
        function: Callable[..., Any] | None = None,
        simplify: bool = False,
        exact: bool = False,
    ) -> str:
        """
        Get the output of the `list()` method as a TeX string.
        #### Parameters:
        * `indices` (optional): A tuple of integers specifying the index configuration of the representation to use. Each integer in the tuple can be either +1 for an upper index or -1 for a lower index. If not specified, the tensor's default index configuration will be used.
        * `coords` (optional): An OGRePy `Coordinates` object specifying the coordinate system of the representation to use. If not specified, the tensor's default coordinate system will be used.
        * `replace` (optional): A dictionary of substitutions to be made in the components before printing them. Each key in the dictionary will be replaced with its value.
        * `function` (optional): A function to apply to the components before printing them. If both `replace` and `function` are used, `function` will run first, then `replace`.
        * `simplify` (optional): Whether to simplify (`True`) or not simplify (`False`, the default) the components after applying `replace` and/or `function`.
        * `exact` (optional): Whether to check if two components are the same up to sign by simple comparison (`False`, the default) or by simplifying the sum of the components (`True`). Note that setting this to `True` may increase processing time for large tensors with complicated components.
        #### Returns:
        The full TeX string of the output, or an empty string if there are no non-zero elements.
        """
        # Process the input.
        (use_coords, use_indices, components) = self._process_show_list(indices=indices, coords=coords, replace=replace, function=function, simplify=simplify)
        # Create a dictionary where each key is a unique, non-zero SymPy expression, and each key's value is a list of all the tensor components, in coordinate-index notation (e.g.: g^{xy}) encoded in TeX, which correspond to that expression or its negative. Note that we use a defaultdict so that accessing a missing key will automatically create an empty list.
        non_zero: collections.defaultdict[s.Basic, list[str]] = collections.defaultdict(list)
        # Store the tensor's rank for later use,
        rank: int = self.rank()
        # If the tensor is a scalar, then populating the dictionary is very simple: keep it empty if the scalar is zero, otherwise populate it with a single key pointing to a list with a single item.
        if rank == 0:
            if components[0] != 0:
                non_zero[components[0]] = [self._symbol]
        # If the tensor is not a scalar, then we need to go over all the components one by one.
        else:
            # Instead of the default index letters, we will use the coordinate letters themselves (e.g.: t, x, y, z) to display the tensor components.
            coord_symbols: s.Array = use_coords.components()
            # Create an iterator for all the possible index permutations of the tensor, or more precisely, the Cartesian product (0, ..., d-1)^r where d is the dimension of the manifold and r is the rank of the tensor. Note that this will be an empty iterator if r = 0.
            permutations: itertools.product[tuple[int, ...]] = itertools.product(range(self.dim()), repeat=rank)
            # Loop over all the permutations to find the non-zero elements.
            for position in permutations:
                # If the component is non-zero, store it in the dictionary.
                expr: s.Basic = components[position]
                if expr != 0:
                    # Collect the letters corresponding to the component's position. For example, if the coordinate symbols are (t, x, y, z) and the position is (0, 3), we would get (t, z).
                    letters: IndexSpecification = [_to_tex(coord_symbols[position[i]]) for i in range(rank)]
                    # Generate the TeX representation for the component, e.g. T^{xy}.
                    tex: str = self._tex_symbol(indices=use_indices, letters=letters)
                    # If a key for the negative of the value already exists, store the component in that key, with an added minus sign. Otherwise, store it in its own key.
                    if not exact:
                        # Do a normal comparison. This will only produce correct results for very simple expressions, resulting in false negatives.
                        if -expr in non_zero:
                            non_zero[-expr].append("-" + tex)
                        else:
                            non_zero[expr].append(tex)
                    else:
                        # Do an exact comparison by simplifying the sum of the two expressions. This will produce the correct results, but will increase processing time.
                        for e in non_zero:
                            if _simplify(cast(s.Add, expr + e)) == 0:
                                non_zero[e].append("-" + tex)
                                break
                        else:
                            non_zero[expr].append(tex)
        # If there are no non-zero elements, just display a message.
        if len(non_zero) == 0:
            return r"\text{No non-zero elements.}"
        # Otherwise, display the elements. Start an {align*} environment so the equations can be aligned at the equal sign.
        out: str = r"\begin{align*}" + "\n"
        # Create an equation for each unique element. Note that in an {align*} environment, \\ indicates the end of a line and & indicates the position where the equations will be vertically aligned.
        for key, value in non_zero.items():
            out += rf"    {" = ".join(value)} &= {_to_tex(key)} \\" + "\n"
        # Remove the last end of line indicator (otherwise there will be an extra empty line) and end the {align*} environment, then return the generated TeX code.
        return out[:-3] + "\n" + r"\end{align*}"

    def tex_show(
        self: Self,
        *,
        indices: IndexConfiguration | None = None,
        coords: Coordinates | None = None,
        replace: dict[Any, Any] | None = None,
        function: Callable[..., Any] | None = None,
        simplify: bool = False,
    ) -> str:
        """
        Get the output of the `show()` method as a TeX string.
        #### Parameters:
        * `indices` (optional): A tuple of integers specifying the index configuration of the representation to use. Each integer in the tuple can be either +1 for an upper index or -1 for a lower index. If not specified, the tensor's default index configuration will be used.
        * `coords` (optional): An OGRePy `Coordinates` object specifying the coordinate system of the representation to use. If not specified, the tensor's default coordinate system will be used.
        * `replace` (optional): A dictionary of substitutions to be made in the components before printing them. Each key in the dictionary will be replaced with its value.
        * `function` (optional): A function to apply to the components before printing them. If both `replace` and `function` are used, `function` will run first, then `replace`.
        * `simplify` (optional): Whether to simplify (`True`) or not simplify (`False`, the default) the components after applying `replace` and/or `function`.
        #### Returns:
        The full TeX string of the output.
        """
        # Process the input.
        (use_coords, use_indices, components) = self._process_show_list(indices=indices, coords=coords, replace=replace, function=function, simplify=simplify)
        # Display the tensor's symbol and indices, using the default index letters, or the calculation letters if available.
        tex: str = self._tex_symbol(indices=use_indices, letters=self._calc_letters if self._calc_letters is not None else options.index_letters)
        # Display the coordinate symbols inside brackets.
        tex += r"\Bigg|_{\left(" + ", ".join(_to_tex(symbol) for symbol in use_coords.components()) + r"\right)}"
        # Display an equal sign between the tensor's symbol and components.
        tex += " = "
        # If this is a vector, transpose it into a column vector before showing it (for compatibility with the Mathematica version).
        if self.rank() == 1:
            # `components.shape` for a vector will be (d,) where d is the dimension of the manifold. We simply reshape it into (d, 1).
            components = components.reshape(*cast(tuple[int], components.shape), 1)
        # Scalars are stored as an array with a single element for consistency with tensors of other ranks. If the tensor is a scalar, display only the single element, not the array itself.
        tex += _to_tex(components[0] if len(use_indices) == 0 else components)
        # Return the generated TeX code.
        return tex

    # =============== #
    # Private methods #
    # =============== #

    def _calc_representation(
        self: Self,
        coords: Coordinates,
        indices: IndexConfiguration,
    ) -> s.Array:
        """
        Get the components of this tensor in the given representation, calculating them if they have not been calculated yet.
        #### Parameters:
        * `coords`: An OGRePy `Coordinates` object specifying the coordinate system of the representation.
        * `indices`: A tuple of integers specifying the index configuration of the representation. Each integer in the tuple can be either +1 for an upper index or -1 for a lower index.
        #### Returns:
        The desired components.
        """
        if (indices, coords) not in self._components:
            # If the representation has not already been calculated, calculate it now.
            if (self._default_indices, coords) in self._components:
                # It's always simpler to transform indices rather than coordinates. So if we have the representation in the desired coordinates in the default indices, we start from there and transform just the indices.
                self._transform_indices(source_indices=self._default_indices, target_indices=indices, coords=coords)
            elif (indices, self._default_coords) in self._components:
                # Otherwise, if we have the representation in the desired indices in the default coordinates, we start from there and transform just the coordinates.
                self._transform_coordinates(source_coords=self._default_coords, target_coords=coords, indices=indices)
            else:
                # As a last resort, we start from the representation in both the default indices and default coordinates, which is always guaranteed to exist as a class invariant. In that case we need to perform two transformations. We do the coordinates first and then the indices.
                self._transform_coordinates(source_coords=self._default_coords, target_coords=coords, indices=self._default_indices)
                self._transform_indices(source_indices=self._default_indices, target_indices=indices, coords=coords)
        return self._get_components(indices=indices, coords=coords)

    def _check_index_letters(
        self: Self,
    ) -> IndexSpecification:
        """
        Check that this tensor has index letters attached to it for use in tensor calculations. If the tensor is a scalar, always return an empty index specification.
        #### Exceptions:
        * `OGRePyError`: If the tensor has no index letters.
        #### Returns:
        The index letters.
        """
        if self.rank() == 0:
            return []
        if self._calc_letters is None:
            _handle_error(f"The tensor `{self}` has no index specification, so it cannot be used in a tensor operation. Please specify its index letters in parentheses.")
        return self._calc_letters

    def _cleanup_notation(
        self: Self,
        *,
        components: s.Array,
        coords: Coordinates,
    ) -> s.Array:
        """
        Convert the components of a tensor to a cleaner form by making the notation of functions and derivatives more compact.
        #### Parameters:
        * `components`: The components to clean up.
        * `coords`: An OGRePy `Coordinates` object specifying the coordinate system being used.
        #### Returns:
        The cleaned up components.
        """
        # Obtain the coordinate symbols.
        coord_symbols: list[s.Symbol] = coords.components().tolist()
        # If this object has the mixin class `CleanupTimeParameter`, the parameter with respect to which we need to clean up is the time parameter (assumed to be the first coordinate). Otherwise, it is the curve parameter if the object has `CleanupCurveParameter`, or simply irrelevant if it has neither class.
        param: s.Symbol = coord_symbols[0] if isinstance(self, CleanupTimeParameter) else options.curve_parameter
        if isinstance(self, CleanupCurveParameter | CleanupTimeParameter):
            # If this is a type of tensor that uses a parameter, replace any instance of the coordinate functions in terms of the parameter with the ordinary coordinate symbols, and similarly for their first and second derivatives.
            components = _subs_array(components, coords.of_param_rdict(param) | coords.of_param_newton_dot(param))
        # If a single-letter function has only coordinate symbols as its arguments, show only the name of the function, without the arguments.
        components = _subs_array(components, {f: sym(str(f.func)) for f in components.atoms(s.Function) if all(arg in coord_symbols for arg in f.args) and len(str(f.func)) == 1})
        if isinstance(self, CleanupCurveParameter | CleanupTimeParameter):
            # Replace any derivative with respect to the curve parameter with a more compact partial derivative symbol, and enclose the argument in parentheses (which SymPy doesn't do for some reason).
            components = _subs_array(components, {f: sym(r"\partial_{" + _to_tex(param) + r"} \left(" + _to_tex(f.args[0]) + r"\right)") for f in components.atoms(s.Derivative) if f.args[1] == (param, 1)})
        # For all tensors, replace any derivative with respect to the coordinate symbols with a more compact partial derivative symbol.
        for x in coord_symbols:
            components = _subs_array(components, {f: sym(r"\partial_{" + _to_tex(x) + r"} " + _to_tex(f.args[0])) for f in components.atoms(s.Derivative) if f.args[1] == (x, 1)})
        return components

    def _get_components(
        self: Self,
        *,
        indices: IndexConfiguration,
        coords: Coordinates,
    ) -> s.Array:
        """
        Get the components of this tensor for the given representation.
        #### Parameters:
        * `indices`: A tuple of integers specifying the index configuration. Each integer in the tuple can be either +1 for an upper index or -1 for a lower index.
        * `coords`: An OGRePy `Coordinates` object specifying the coordinate system.
        """
        return self._components[(indices, coords)]

    def _process_show_list(
        self: Self,
        *,
        indices: IndexConfiguration | None = None,
        coords: Coordinates | None = None,
        replace: dict[Any, Any] | None = None,
        function: Callable[..., Any] | None = None,
        simplify: bool = False,
    ) -> tuple[Coordinates, IndexConfiguration, s.Array]:
        """
        Process the input for use in `show()`, `list()`, and related functions.
        #### Parameters:
        * `indices` (optional): A tuple of integers specifying the index configuration of the representation to use. Each integer in the tuple can be either +1 for an upper index or -1 for a lower index. If not specified, the tensor's default index configuration will be used.
        * `coords` (optional): An OGRePy `Coordinates` object specifying the coordinate system of the representation to use. If not specified, the tensor's default coordinate system will be used.
        * `replace` (optional): A dictionary of substitutions to be made in the components before printing them. Each key in the dictionary will be replaced with its value.
        * `function` (optional): A function to apply to the components before printing them. If both `replace` and `function` are used, `function` will run first, then `replace`.
        * `simplify` (optional): Whether to simplify (`True`) or not simplify (`False`, the default) the components after applying `replace` and/or `function`.
        #### Returns:
        The coordinates, indices, and components to use.
        """
        # Validate the coordinates and indices.
        use_coords: Coordinates = self._validate_or_default_coords(coords)
        use_indices: IndexConfiguration = self._validate_or_default_indices(indices)
        # Retrieve the components, calculating them if they have not already been calculated.
        components: s.Array = self._calc_representation(use_coords, use_indices)
        # Apply the function, if any.
        if function is not None:
            components = _map_function(components, function)
        # Make the replacements, if any.
        if replace is not None:
            components = _make_replacement(components, replace)
        # Simplify the components, if desired.
        if simplify is True:
            components = _simplify_array(components)
        # Clean up the notation if relevant.
        components = self._cleanup_notation(components=components, coords=use_coords)
        # Return the results.
        return (use_coords, use_indices, components)

    def _store_components(
        self: Self,
        *,
        indices: IndexConfiguration,
        coords: Coordinates,
        components: s.Array,
    ) -> None:
        """
        Store the components of this tensor for the given representation.
        #### Parameters:
        * `indices`: A tuple of integers specifying the index configuration. Each integer in the tuple can be either +1 for an upper index or -1 for a lower index.
        * `coords`: An OGRePy `Coordinates` object specifying the coordinate system.
        * `components`: The components to store.
        """
        self._components[(indices, coords)] = components

    def _tex_symbol(
        self: Self,
        *,
        indices: IndexConfiguration,
        letters: IndexSpecification,
    ) -> str:
        """
        Generate TeX code for the tensor's symbol and indices in the desired representation. If the symbol has any free index placeholders, `[n]` with integer `n`, they will be replaced with the specified index letters (it is assumed that there are as many unique free index placeholders as indices). If the symbol has any pairs of summation index placeholders, `[:n]` and `[n:]` with integer `n`, they will be replaced with additional indices from the default index letters, with `[:n]` getting a lower index and `[n:]` an upper index. It is assumed that both types of placeholders are consecutively and independently numbered starting from 0.
        #### Parameters:
        * `indices`: A tuple of integers specifying the index configuration. Each integer in the tuple can be either +1 for an upper index or -1 for a lower index.
        * `letters`: The index letters to use, given as a list of TeX strings.
        #### Returns:
        The TeX code. For example, if `symbol` is `A[0][1] + B[1][0][:0][0:]`, `indices` are (+1, -1), and `letters` are `["a", "b"]`, the output will be `A{}^{a}{}_{b} + B{}_{b}{}^{a}{}_{c}{}^{c}`.
        """
        # Start with the raw symbol of the tensor.
        tex: str = self._symbol
        # Loop over the indices and replace each index placeholder of the form [n] with the correct index letter and position (superscript/subscript).
        for i, (index, letter) in enumerate(zip(indices, letters, strict=False)):
            tex = tex.replace(f"[{i}]", "{}" + ("^" if index == 1 else "_") + "{" + letter + "}")
        # Make a list of letters we haven't used it, to use for the contracted indices.
        unused_letters: IndexSpecification = [letter for letter in options.index_letters if letter not in letters[: len(indices)]]
        # Make a list of all the numbers inside the summation index placeholders.
        placeholders: list[int] = _unique_summation_placeholders(tex)
        # Replace any placeholders of the form [:n] and [n:] with unused index letters, lower and upper respectively, same letter for all placeholders with the same integer n. The [:n] should always be on the left, so that a partial derivative (which always appears on the left of the tensor) will have a lower index.
        for i in placeholders:
            tex = tex.replace(f"[:{i}]", "{}_{" + unused_letters[i] + "}")
            tex = tex.replace(f"[{i}:]", "{}^{" + unused_letters[i] + "}")
        return tex

    def _trace(
        self: Self,
        *,
        all_letters: IndexSpecification,
        trace_letters: IndexSpecification,
    ) -> Tensor:
        """
        Calculate the trace of one or more indices of this tensor.
        #### Parameters:
        * `all_letters`: All the index letters of this tensor.
        * `trace_letters`: A list of one or more indices to trace. It is assumed that each index appears exactly twice in `calc_letters`.
        """
        # The best way to do a trace is to not use the metric at all. For example, to calculate T^a_a we can just take the index representation (+1, -1) and sum on the index. As a more complicated example, to calculate T^ab_ac^cd we can take the representation (+1, +1, -1, -1, +1, +1), sum on a and c, and leave b and d as free indices. So really, the only thing we need to do here is figure out the correct index representation to use.
        # For the indices of the input tensor, we start from the default index configuration, and whenever we see two repeating indices we just make sure the second index is at an opposite position, so the indices are either up-down or down-up.
        in_indices: list[Literal[+1, -1]] = [*self._default_indices]
        # Similarly, for the indices of the output tensor, we start from the default index configuration, and whenever we see two repeating indices we remove them from the output tensor.
        out_indices_list: list[Literal[+1, -1, 0]] = [*self._default_indices]
        index_placeholders: list[list[int]] = []
        for letter in trace_letters:
            # For each letter to be traced, find the positions of the first and second index being contracted.
            positions: list[int] = [pos for pos, index in enumerate(all_letters) if index == letter]
            # For the input indices, make sure the second index is opposite to the first, that is, if the first is up then the second should be down and vice versa.
            in_indices[positions[1]] = -in_indices[positions[0]]
            # For the output indices, mark both traced indices for deletion.
            out_indices_list[positions[0]] = 0
            out_indices_list[positions[1]] = 0
            # Keep track of the locations of the trace so we can indicate the index placeholders there.
            index_placeholders.append(positions)
        # Remove all the indices we marked for removal earlier, and save the index configuration for the output tensor as a tuple.
        out_indices: IndexConfiguration = tuple(i for i in out_indices_list if i != 0)
        # Retrieve the components in the desired representation, calculating it if it does not already exist.
        components: s.Array = self._calc_representation(self._default_coords, tuple(in_indices))
        # Calculate the trace by contracting on the repeated indices.
        result: TensorWithIndexSpecification = _contract((components, [*all_letters]))
        # Simplify and store the result in a new tensor object.
        output_tensor: Tensor = Tensor(metric=self._metric, coords=self._default_coords, indices=out_indices, components=_simplify_array(result[0]))
        # Specify the index letters for the resulting tensor before returning it, in case we are chaining multiple operations.
        output_tensor._calc_letters = result[1]
        # Create a symbol for the new tensor.
        out_symbol: str = self._symbol
        # Count how many summation index placeholders there already are in the tensor's symbol (from previous traces or contractions).
        contract_count: int = len(_unique_summation_placeholders(self._symbol))
        # Convert each pair of indices that are traced out into summation indices (with a :). For example, `A[0][1]` will become `A[:0][0:]`. The position of the : indicates the original relationship between the indices, so that for example in (A[:0] + B[:0])C[0:] we will know to put a lower index on the A and B and an upper index on the C. Start from the number of summation indices that already exist.
        for i, pos in enumerate(index_placeholders, contract_count):
            out_symbol = out_symbol.replace(f"[{pos[0]}]", f"[:{i}]")
            out_symbol = out_symbol.replace(f"[{pos[1]}]", f"[{i}:]")
        # After converting some indices to summation indices, the free indices will not be consecutive, so we need to fix that.
        out_symbol = _consecutive_placeholders(out_symbol)
        # Store the new symbol and return the new tensor.
        output_tensor._symbol = out_symbol
        return output_tensor

    def _transform_coordinates(
        self: Self,
        *,
        source_coords: Coordinates,
        target_coords: Coordinates,
        indices: IndexConfiguration,
    ) -> None:
        """
        Calculate the components of this tensor in a new coordinate system and store them.
        #### Parameters:
        * `source_coords`: An OGRePy `Coordinates` object specifying the coordinate system to transform from.
        * `target_coords`: An OGRePy `Coordinates` object specifying the coordinate system to transform to.
        * `indices`: A tuple of integers specifying the index configuration in which to calculate the transformed tensor. Each integer in the tuple can be either +1 for an upper index or -1 for a lower index. It is assumed that the representation (indices, source_coords) has already been calculated.
        #### Exceptions:
        * `OGRePyError`: If the appropriate coordinate transformation has not been defined.
        """
        # Get the source components.
        components: s.Array = self._get_components(indices=indices, coords=source_coords)
        # Retrieve the transformation rules. Note that this will raise an exception if the transformation has not been defined yet.
        rules: CoordTransformation = source_coords.get_coord_transformation(target_coords)
        if self.rank() == 0:
            # If the tensor is a scalar, simply transform its one component.
            self._store_components(indices=indices, coords=target_coords, components=s.Array([_simplify(components[0].subs(rules))]))
        else:
            # If the tensor is not a scalar, transform its components using contractions with the Jacobian or inverse Jacobian.
            jacobian: s.Array = source_coords.jacobian(target_coords)
            inverse_jacobian: s.Array = source_coords.inverse_jacobian(target_coords)
            # `sum_vars` will contain the variables to be used in the sum.
            sum_vars: IndexSpecification = []
            # We create a tensor product of the form T_{a_1 ... a_n} = J_{a_1 b_1} ... J_{a_n b_n} T^{b_1 ... b_n} where the a_k are free indices in the target coordinate system and the b_n are summation indices in the source coordinate system. J can be either the Jacobian or inverse Jacobian, and T has the index configuration we started with, but `_contract()` doesn't care whether the indices are up or down, it just acts on N-dimensional arrays. `jacobians` stores the list of Jacobians to contract along with their respective index specifications.
            jacobians: list[TensorWithIndexSpecification] = []
            for index in indices:
                # Create an output variable and a summation variable. They are just dummy symbols, since we don't care about the symbol itself. We use SymPy's `Dummy` class to ensure they are unique, but we then convert them to our own convention of using TeX strings for indices. The actual value will be a string of the form `Dummy_{n}` where `n` is a unique integer.
                out_var: str = _to_tex(s.Dummy())
                sum_var: str = _to_tex(s.Dummy())
                # We use the Jacobian to transform a lower index, and the inverse Jacobian to transform an upper index, adding a term of the form J^b_a or J^a_b respectively, where J is the Jacobian, a is the output variable, and b is the summation variable (to be contracted with the tensor).
                jacobians.append((inverse_jacobian, [out_var, sum_var]) if index == 1 else (jacobian, [sum_var, out_var]))
                # We also add a summation variable to the input tensor itself. In the example above, the variable b would be added to the input tensor, resulting in a term of the form J^b_a T_{...b...} or J^a_b T^{...b...} respectively.
                sum_vars.append(sum_var)
            # Contract the Jacobians with the tensor. Note: Unlike in `_transform_indices()`, here we do not need to permute the indices, since we are contracting all the indices and in their original order, so e.g. T_ac = J_ab J_cd T^bd already has the correct index order by design.
            result: TensorWithIndexSpecification = _contract(*jacobians, (components, sum_vars))
            # The contracted tensor will still be expressed using the old coordinate symbols, so we express it using the new coordinate symbols by applying the transformation rules. Then we simplify and store the result.
            self._store_components(indices=indices, coords=target_coords, components=_simplify_array(_subs_array(result[0], rules)))

    def _transform_indices(
        self: Self,
        *,
        source_indices: IndexConfiguration,
        target_indices: IndexConfiguration,
        coords: Coordinates,
    ) -> None:
        """
        Calculate the components of this tensor in a particular index representation, raising or lowering indices using the metric as needed, and store them.
        #### Parameters:
        * `source_indices`: A tuple of integers specifying the index configuration to transform from. Each integer in the tuple can be either +1 for an upper index or -1 for a lower index.
        * `target_indices`: A tuple of integers specifying the index configuration to transform to. Each integer in the tuple can be either +1 for an upper index or -1 for a lower index.
        * `coords`: An OGRePy `Coordinates` object specifying the coordinate system in which to calculate the transformed tensor. It is assumed that the representation (source_indices, coords) has already been calculated.
        """
        # Get the source components.
        components: s.Array = self._get_components(indices=source_indices, coords=coords)
        # Get the components of the metric and inverse metric in the relevant coordinate system, calculating them on the spot if the metric has not been used in this coordinate system before. (Note that there is no risk of an infinite loop here either, since the `Metric` class transforms coordinates and indices on its own, not through this method.)
        metric: s.Array = self._metric.components(coords=coords, indices=(-1, -1), warn=False)
        inverse_metric: s.Array = self._metric.components(coords=coords, indices=(1, 1), warn=False)
        # `in_vars` will contain the variables to be used in the input tensor.
        in_vars: IndexSpecification = []
        # `permute_vars` will contain the variables that we want to permute the final result to (see below for explanation).
        permute_vars: IndexSpecification = []
        # We create a tensor product of the form T_{a_1 ... a_n} = g_{a_1 b_1} ... g_{a_n b_n} T^{b_1 ... b_n} where the a_k are free indices and the b_n are summation indices. g can be either the metric or inverse metric, and T has the index configuration we started with, but `_contract()` doesn't care whether the indices are up or down, it just acts on N-dimensional arrays. `metrics` stores the list of metrics to contract along with their respective index specifications.
        metrics: list[TensorWithIndexSpecification] = []
        for source_index, target_index in zip(source_indices, target_indices, strict=True):
            # Create an output variable and a summation variable. They are just dummy symbols, since we don't care about the symbol itself. We use SymPy's `Dummy` class to ensure they are unique, but we then convert them to our own convention of using TeX strings for indices. The actual value will be a string of the form `Dummy_{n}` where `n` is a unique integer.
            out_var: str = _to_tex(s.Dummy())
            sum_var: str = _to_tex(s.Dummy())
            if source_index != target_index:
                # If the source index differs from the target index, we need to either raise or lower the index. We use the metric to lower the index, and the inverse metric to raise the index, adding a term of the form g_ab, where g is the metric, a is the output variable, and b is the summation variable (to be contracted with the tensor).
                metrics.append((inverse_metric, [out_var, sum_var]) if target_index == 1 else (metric, [sum_var, out_var]))
                # We also add a summation variable to the input tensor itself. In the example above, the variable b would be added to the input tensor, resulting in a term of the form T^{... b ...}.
                in_vars.append(sum_var)
                # Consider a contraction of the form T^cd_a = g_ab T^cdb; the index a is first on the right-hand side, but third on the left-hand side. We therefore need to permute the indices on the result from "acd" to "cda". In this particular example, the resulting index letters returned from `_contract()` will be "acd", so we keep track of the index letters we actually want, "cda", by adding "a" (i.e. `out_var`) at the appropriate position to `permute_vars`.
                permute_vars.append(out_var)
            else:
                # If the source index is the same as the target index, we don't need to do anything. We just add an output variable (i.e. a free index) to the input tensor.
                in_vars.append(out_var)
                permute_vars.append(out_var)
        # Contract the metrics with the tensor.
        result: TensorWithIndexSpecification = _contract(*metrics, (components, in_vars))
        # Permute the indices to the desired configuration.
        permuted: s.Array = cast(s.Array, s.permutedims(result[0], index_order_old=result[1], index_order_new=permute_vars))
        # Simplify and store the result.
        self._store_components(indices=target_indices, coords=coords, components=_simplify_array(permuted))

    def _validate_or_default_coords(
        self: Self,
        coords: Coordinates | None = None,
    ) -> Coordinates:
        """
        Either get the default coordinates if no coordinates are given, or validate the coordinates if they are given.
        #### Parameters:
        * `coords` (optional): An OGRePy `Coordinates` object specifying a coordinate system.
        #### Returns:
        The default coordinates if no coordinates are given, or the given coordinates if they are valid.
        """
        if coords is None:
            return self._default_coords
        return _validate_coordinates(coords)

    def _validate_or_default_indices(
        self: Self,
        indices: IndexConfiguration | None = None,
    ) -> IndexConfiguration:
        """
        Either get the default index configuration if no indices are given, or validate the indices if they are given.
        #### Parameters:
        * `indices` (optional): A tuple of integers specifying an index configuration.
        #### Returns:
        The default index configuration if no indices are given, or the given indices if they are valid.
        """
        if indices is None:
            return self._default_indices
        return _validate_indices(indices)


class CleanupCurveParameter:
    """
    A mixin class for classes derived from `Tensor` to denote that objects of this class require cleaning up notation of functions of the curve parameter.
    """


class CleanupTimeParameter:
    """
    A mixin class for classes derived from `Tensor` to denote that objects of this class require cleaning up notation of functions of the time parameter.
    """


class FixedDefaultIndices:
    """
    A mixin class for classes derived from `Tensor` to denote that objects of this class should not allow changing their default indices.
    """


class Christoffel(Tensor, FixedDefaultIndices):
    """
    A class representing the Christoffel symbols (the coefficients of the Levi-Civita connection) of a metric.
    """

    # =========== #
    # Constructor #
    # =========== #

    __slots__: tuple[str, ...] = ()

    def __init__(
        self: Self,
        metric: Metric,
    ) -> None:
        """
        Construct a new Christoffel symbols object from the given metric. **Note: This constructor should NOT be called manually! Please use the `Metric.christoffel()` method instead, to facilitate caching.**
        #### Parameters:
        * `metric`: An OGRePy `Metric` object specifying the metric to use.
        """
        # Obtain the metric's default coordinates.
        coords: Coordinates = metric._default_coords
        # Initialize the parent tensor class.
        super().__init__(
            metric=metric,
            coords=coords,
            indices=(+1, -1, -1),
            components=self._calc_christoffel(metric, coords),
            symbol=r"\Gamma[0][1][2]",
            simplify=False,
        )

    # =============== #
    # Private methods #
    # =============== #

    def _calc_christoffel(
        self: Self,
        metric: Metric,
        coords: Coordinates,
    ) -> s.Array:
        """
        Calculate the Christoffel symbols for the given metric and coordinate system.
        #### Parameters:
        * `metric`: An OGRePy `Metric` object specifying the metric to use.
        * `coords`: An OGRePy `Coordinates` object specifying the coordinate system to use.
        #### Returns:
        The calculated components.
        """
        # Calculate the partial derivative of the metric.
        partial_metric: Tensor = PartialD("mu") @ metric("nu sigma")
        # Sum three such partial derivatives.
        sum_partial_metric: Tensor = partial_metric("mu nu sigma") + partial_metric("nu sigma mu") - partial_metric("sigma mu nu")
        # Extract the inverse metric and multiply by half.
        half_inverse_metric: s.Array = cast(s.Array, s.Rational(1, 2) * metric.components(coords=coords, indices=(+1, +1), warn=False))
        # Calculate the Christoffel symbols by contracting the sum of partial derivatives of the metric with half the inverse metric.
        result: s.Array = _contract((half_inverse_metric, ["lamda", "sigma"]), (sum_partial_metric.components(coords=coords, indices=(-1, -1, -1), warn=False), ["mu", "nu", "sigma"]))[0]
        # Simplify and return the result.
        return _simplify_array(result)

    @override
    def _transform_coordinates(
        self: Self,
        *,
        source_coords: Coordinates,
        target_coords: Coordinates,
        indices: IndexConfiguration = (+1, -1, -1),
    ) -> None:
        """
        Calculate the components of these Christoffel symbols in a new coordinate system and store them.
        #### Parameters:
        * `source_coords`: An OGRePy `Coordinates` object specifying the coordinate system to transform from.
        * `target_coords`: An OGRePy `Coordinates` object specifying the coordinate system to transform to. It is assumed that the representation (indices, source_coords) has already been calculated.
        * `indices`: Ignored (only included for compatibility with the parent class).
        """
        # First perform the usual coordinate transformation in the defining (+1, -1, -1) representation. (Te default indices are guaranteed to always be (+1, -1, -1) since this is a `FixedDefaultIndices` class, so we will never have to transform coordinates in any other indices.)
        super()._transform_coordinates(source_coords=source_coords, target_coords=target_coords, indices=self._default_indices)
        # The Christoffel symbols do not transform like a tensor; we need to add an extra term to the transformation.
        incomplete_components: s.Array = self._get_components(indices=self._default_indices, coords=target_coords)
        self._store_components(indices=self._default_indices, coords=target_coords, components=incomplete_components + self._default_coords.christoffel_jacobian(target_coords))


class Einstein(Tensor, FixedDefaultIndices):
    """
    A class representing the Einstein tensor of a metric.
    """

    # =========== #
    # Constructor #
    # =========== #

    __slots__: tuple[str, ...] = ()

    def __init__(
        self: Self,
        metric: Metric,
    ) -> None:
        """
        Construct a new Einstein tensor object from the given metric. **Note: This constructor should NOT be called manually! Please use the `Metric.einstein()` method instead, to facilitate caching.**
        #### Parameters:
        * `metric`: An OGRePy `Metric` object specifying the metric to use.
        """
        # Obtain the metric's default coordinates.
        coords: Coordinates = metric._default_coords
        # Initialize the parent tensor class.
        super().__init__(
            metric=metric,
            coords=coords,
            indices=(-1, -1),
            components=self._calc_einstein(metric, coords),
            symbol="G[0][1]",
            simplify=False,
        )

    # =============== #
    # Private methods #
    # =============== #

    def _calc_einstein(
        self: Self,
        metric: Metric,
        coords: Coordinates,
    ) -> s.Array:
        """
        Calculate the Einstein tensor for the given metric and coordinate system.
        #### Parameters:
        * `metric`: An OGRePy `Metric` object specifying the metric to use.
        * `coords`: An OGRePy `Coordinates` object specifying the coordinate system to use.
        #### Returns:
        The calculated components.
        """
        return (metric.ricci_tensor("mu nu") - s.Rational(1, 2) * metric.ricci_scalar() @ metric("mu nu")).components(coords=coords, indices=(-1, -1), warn=False)


class GeodesicFromChristoffel(Tensor, CleanupCurveParameter, FixedDefaultIndices):
    """
    A class representing the geodesic equations of a metric as obtained from the Christoffel symbols.
    """

    # =========== #
    # Constructor #
    # =========== #

    __slots__: tuple[str, ...] = ()

    def __init__(
        self: Self,
        metric: Metric,
    ) -> None:
        """
        Construct a new Christoffel geodesic equations object from the given metric. **Note: This constructor should NOT be called manually! Please use the `Metric.geodesic_from_christoffel()` method instead, to facilitate caching.**
        #### Parameters:
        * `metric`: An OGRePy `Metric` object specifying the metric to use.
        """
        # Obtain the metric's default coordinates.
        coords: Coordinates = metric._default_coords
        # Initialize the parent tensor class.
        super().__init__(
            metric=metric,
            coords=coords,
            indices=(1,),
            components=self._calc_geodesic_from_christoffel(metric, coords),
            symbol="0",
            simplify=False,
        )

    # =============== #
    # Private methods #
    # =============== #

    def _calc_geodesic_from_christoffel(
        self: Self,
        metric: Metric,
        coords: Coordinates,
    ) -> s.Array:
        """
        Calculate the Christoffel geodesic equations for the given metric and coordinate system.
        #### Parameters:
        * `metric`: An OGRePy `Metric` object specifying the metric to use.
        * `coords`: An OGRePy `Coordinates` object specifying the coordinate system to use.
        #### Returns:
        The calculated components.
        """
        # Create a tangent vector whose components are the first derivatives of the coordinate symbols as functions of the curve parameter.
        tangent: s.Array = s.Array(coords.of_param_dot())
        # Create an acceleration vector whose components are the second derivatives of the coordinate symbols as functions of the curve parameter.
        accel: s.Array = s.Array(coords.of_param_ddot())
        # Obtain the Christoffel symbols, and replace any instance of the coordinate symbols with coordinate functions of the curve parameter.
        christoffel_with_param: s.Array = _subs_array(metric.christoffel().components(coords=coords, indices=(+1, -1, -1), warn=False), coords.of_param_dict())
        # Calculate the geodesic equations by contracting the Christoffel symbols with two tangent vectors and adding to the acceleration vector.
        result: s.Array = accel + _contract((christoffel_with_param, ["sigma", "mu", "nu"]), (tangent, ["mu"]), (tangent, ["nu"]))[0]
        # Simplify and return the result.
        return _simplify_array(result)

    @override
    def _transform_coordinates(
        self: Self,
        *,
        source_coords: Coordinates,
        target_coords: Coordinates,
        indices: IndexConfiguration = (1,),
    ) -> None:
        """
        Calculate the components of the Christoffel geodesic equations in a new coordinate system and store them.
        #### Parameters:
        * `source_coords`: Ignored (only included for compatibility with the parent class).
        * `target_coords`: An OGRePy `Coordinates` object specifying the coordinate system to transform to.
        * `indices`: Ignored (only included for compatibility with the parent class).
        """
        # To "transform" coordinates, we actually just re-calculate the Christoffel geodesic equations from scratch in the target coordinates.
        self._store_components(indices=self._default_indices, coords=target_coords, components=self._calc_geodesic_from_christoffel(self._metric, target_coords))


class GeodesicFromLagrangian(Tensor, CleanupCurveParameter, FixedDefaultIndices):
    """
    A class representing the geodesic equations of a metric as obtained from the curve Lagrangian.
    """

    # =========== #
    # Constructor #
    # =========== #

    __slots__: tuple[str, ...] = ()

    def __init__(
        self: Self,
        metric: Metric,
    ) -> None:
        """
        Construct a new Lagrangian geodesic equations object from the given metric. **Note: This constructor should NOT be called manually! Please use the `Metric.geodesic_from_lagrangian()` method instead, to facilitate caching.**
        #### Parameters:
        * `metric`: An OGRePy `Metric` object specifying the metric to use.
        """
        # Obtain the metric's default coordinates.
        coords: Coordinates = metric._default_coords
        # Initialize the parent tensor class.
        super().__init__(
            metric=metric,
            coords=coords,
            indices=(1,),
            components=self._calc_geodesic_from_lagrangian(metric, coords),
            symbol="0",
            simplify=False,
        )

    # =============== #
    # Private methods #
    # =============== #

    def _calc_geodesic_from_lagrangian(
        self: Self,
        metric: Metric,
        coords: Coordinates,
    ) -> s.Array:
        """
        Calculate the Lagrangian geodesic equations for the given metric and coordinate system.
        #### Parameters:
        * `metric`: An OGRePy `Metric` object specifying the metric to use.
        * `coords`: An OGRePy `Coordinates` object specifying the coordinate system to use.
        #### Returns:
        The calculated components.
        """
        # Make sure we have the components of the Lagrangian in the correct coordinate system. We divide them by 2 since geodesics calculated in this way will inevitably get additional factors of 2 from taking the derivatives of squares.
        components: s.Expr = cast(s.Expr, s.Rational(1, 2) * metric.lagrangian()._calc_representation(coords=coords, indices=())[0])
        # Calculate the dL/dx part of the Euler-Lagrange equation.
        euler_lagrange_x: s.Array = s.Array([components.diff(x) for x in coords.of_param()])
        # Calculate the dL/dx_dot part of the Euler-Lagrange equation.
        euler_lagrange_x_dot: s.Array = s.Array([components.diff(x_dot) for x_dot in coords.of_param_dot()])
        # Take the derivative of the dL/dx_dot part with respect to the curve parameter, but leave the derivative unevaluated.
        euler_lagrange_x_dot_diff: s.Array = s.Array([s.Derivative(term, options.curve_parameter) for term in euler_lagrange_x_dot])
        # Calculate the geodesic equation vector from the full Euler-Lagrange equation.
        result: s.Array = euler_lagrange_x - euler_lagrange_x_dot_diff
        # Simplify and return the result, making sure to pass `doit=False` so the derivatives with respect to the curve parameter will not be evaluated.
        return _simplify_array(result, doit=False)

    @override
    def _transform_coordinates(
        self: Self,
        *,
        source_coords: Coordinates,
        target_coords: Coordinates,
        indices: IndexConfiguration = (1,),
    ) -> None:
        """
        Calculate the components of the Lagrangian geodesic equations in a new coordinate system and store them.
        #### Parameters:
        * `source_coords`: Ignored (only included for compatibility with the parent class).
        * `target_coords`: An OGRePy `Coordinates` object specifying the coordinate system to transform to.
        * `indices`: Ignored (only included for compatibility with the parent class).
        """
        # To "transform" coordinates, we actually just re-calculate the Lagrangian geodesic equations from scratch in the target coordinates.
        self._store_components(indices=self._default_indices, coords=target_coords, components=self._calc_geodesic_from_lagrangian(self._metric, target_coords))


class GeodesicTimeParam(Tensor, CleanupTimeParameter, FixedDefaultIndices):
    """
    A class representing the geodesic equations of a metric in terms of the time coordinate.
    """

    # =========== #
    # Constructor #
    # =========== #

    __slots__: tuple[str, ...] = ()

    def __init__(
        self: Self,
        metric: Metric,
    ) -> None:
        """
        Construct a new geodesic equations with time parameter object from the given metric. **Note: This constructor should NOT be called manually! Please use the `Metric.geodesic_time_param()` method instead, to facilitate caching.**
        #### Parameters:
        * `metric`: An OGRePy `Metric` object specifying the metric to use.
        """
        # Obtain the metric's default coordinates.
        coords: Coordinates = metric._default_coords
        # Initialize the parent tensor class.
        super().__init__(
            metric=metric,
            coords=coords,
            indices=(1,),
            components=self._calc_geodesic_time_param(metric, coords),
            symbol="0",
            simplify=False,
        )

    # =============== #
    # Private methods #
    # =============== #

    def _calc_geodesic_time_param(
        self: Self,
        metric: Metric,
        coords: Coordinates,
    ) -> s.Array:
        """
        Calculate the geodesic equations in terms of the time coordinate for the given metric and coordinate system.
        #### Parameters:
        * `metric`: An OGRePy `Metric` object specifying the metric to use.
        * `coords`: An OGRePy `Coordinates` object specifying the coordinate system to use.
        #### Returns:
        The calculated components.
        """
        # Use the first coordinate as the curve parameter.
        time: s.Symbol = cast(s.Symbol, coords.components()[0])
        # Create a tangent vector whose components are the first derivatives of the coordinates with respect to t (thus the first component will be equal to 1).
        tangent: s.Array = s.Array([1] + coords.of_param_dot(time)[1:])
        # Create an acceleration vector whose components are the second derivatives of the coordinates with respect to t (thus the first component will be equal to 0).
        accel: s.Array = s.Array([0] + coords.of_param_ddot(time)[1:])
        # Obtain the Christoffel symbols, and replace any instance of the spatial coordinate symbols with coordinate functions of time.
        param_dict: dict[s.Symbol, AppliedUndef] = coords.of_param_dict(time)
        del param_dict[time]
        christoffel_with_param: s.Array = _subs_array(metric.christoffel().components(coords=coords, indices=(+1, -1, -1), warn=False), param_dict)
        # We also need the Christoffel symbols with 0 in the first index, which is a rank-2 tensor.
        christoffel_zero: s.Array = cast(s.Array, christoffel_with_param[0, :, :])
        # Contract the Christoffel symbols with the tangent vector and subtract from the Christoffel symbols with 0 in the first index to get the term in the parentheses in the equation (see `Metric.geodesic_time_param()` docs), which is a rank-3 tensor.
        parentheses: s.Array = christoffel_with_param - _contract((tangent, ["sigma"]), (christoffel_zero, ["mu", "nu"]))[0]
        # Calculate the geodesic equations by contracting the parentheses with two tangent vectors and adding to the acceleration vector.
        result: s.Array = accel + _contract((parentheses, ["sigma", "mu", "nu"]), (tangent, ["mu"]), (tangent, ["nu"]))[0]
        # Simplify and return the result.
        return _simplify_array(result)

    @override
    def _transform_coordinates(
        self: Self,
        *,
        source_coords: Coordinates,
        target_coords: Coordinates,
        indices: IndexConfiguration = (1,),
    ) -> None:
        """
        Calculate the components of the geodesic equations in terms of the time coordinate in a new coordinate system and store them.
        #### Parameters:
        * `source_coords`: Ignored (only included for compatibility with the parent class).
        * `target_coords`: An OGRePy `Coordinates` object specifying the coordinate system to transform to.
        * `indices`: Ignored (only included for compatibility with the parent class).
        """
        # To "transform" coordinates, we actually just re-calculate the geodesic equations in terms of the time coordinate from scratch in the target coordinates.
        self._store_components(indices=self._default_indices, coords=target_coords, components=self._calc_geodesic_time_param(self._metric, target_coords))


class Kretschmann(Tensor, FixedDefaultIndices):
    """
    A class representing the Kretschmann scalar of a metric.
    """

    # =========== #
    # Constructor #
    # =========== #

    __slots__: tuple[str, ...] = ()

    def __init__(
        self: Self,
        metric: Metric,
    ) -> None:
        """
        Construct a new Kretschmann scalar object from the given metric. **Note: This constructor should NOT be called manually! Please use the `Metric.kretschmann()` method instead, to facilitate caching.**
        #### Parameters:
        * `metric`: An OGRePy `Metric` object specifying the metric to use.
        """
        # Obtain the metric's default coordinates.
        coords: Coordinates = metric._default_coords
        # Initialize the parent tensor class.
        super().__init__(
            metric=metric,
            coords=coords,
            indices=(),
            components=self._calc_kretschmann(metric, coords),
            symbol="K",
            simplify=False,
        )

    # =============== #
    # Private methods #
    # =============== #

    def _calc_kretschmann(
        self: Self,
        metric: Metric,
        coords: Coordinates,
    ) -> s.Array:
        """
        Calculate the Kretschmann scalar for the given metric and coordinate system.
        #### Parameters:
        * `metric`: An OGRePy `Metric` object specifying the metric to use.
        * `coords`: An OGRePy `Coordinates` object specifying the coordinate system to use.
        #### Returns:
        The calculated components.
        """
        return (metric.riemann("rho sigma mu nu") @ metric.riemann("rho sigma mu nu")).components(coords=coords, indices=(), warn=False)


class Lagrangian(Tensor, CleanupCurveParameter, FixedDefaultIndices):
    """
    A class representing the curve Lagrangian of a metric.
    """

    # =========== #
    # Constructor #
    # =========== #

    __slots__: tuple[str, ...] = ()

    def __init__(
        self: Self,
        metric: Metric,
    ) -> None:
        """
        Construct a new curve Lagrangian object from the given metric. **Note: This constructor should NOT be called manually! Please use the `Metric.lagrangian()` method instead, to facilitate caching.**
        #### Parameters:
        * `metric`: An OGRePy `Metric` object specifying the metric to use.
        """
        # Obtain the metric's default coordinates.
        coords: Coordinates = metric._default_coords
        # Initialize the parent tensor class.
        super().__init__(
            metric=metric,
            coords=coords,
            indices=(),
            components=self._calc_lagrangian(metric, coords),
            symbol="L",
            simplify=False,
        )

    # =============== #
    # Private methods #
    # =============== #

    def _calc_lagrangian(
        self: Self,
        metric: Metric,
        coords: Coordinates,
    ) -> s.Array:
        """
        Calculate the curve Lagrangian for the given metric and coordinate system.
        #### Parameters:
        * `metric`: An OGRePy `Metric` object specifying the metric to use.
        * `coords`: An OGRePy `Coordinates` object specifying the coordinate system to use.
        #### Returns:
        The calculated components.
        """
        # Create a tangent vector whose components are the first derivatives of the coordinate symbols as functions of the curve parameter.
        tangent: s.Array = s.Array(coords.of_param_dot())
        # Obtain the metric components, and replace any instance of the coordinate symbols with coordinate functions of the curve parameter.
        metric_with_param: s.Array = _subs_array(metric.components(coords=coords, indices=(-1, -1), warn=False), coords.of_param_dict())
        # Calculate the Lagrangian by taking the norm squared of the tangent vector.
        result: s.Array = _contract((metric_with_param, ["mu", "nu"]), (tangent, ["mu"]), (tangent, ["nu"]))[0]
        # Simplify and return the result.
        return _simplify_array(result)

    @override
    def _transform_coordinates(
        self: Self,
        *,
        source_coords: Coordinates,
        target_coords: Coordinates,
        indices: IndexConfiguration = (),
    ) -> None:
        """
        Calculate the components of this curve Lagrangian in a new coordinate system and store them.
        #### Parameters:
        * `source_coords`: Ignored (only included for compatibility with the parent class).
        * `target_coords`: An OGRePy `Coordinates` object specifying the coordinate system to transform to.
        * `indices`: Ignored (only included for compatibility with the parent class).
        """
        # To "transform" coordinates, we actually just re-calculate the curve Lagrangian from scratch in the target coordinates.
        self._store_components(indices=self._default_indices, coords=target_coords, components=self._calc_lagrangian(self._metric, target_coords))


class Metric(Tensor, FixedDefaultIndices):
    """
    A class representing a metric.
    """

    # =========== #
    # Constructor #
    # =========== #

    __slots__: tuple[str, ...] = (
        "_christoffel",
        "_einstein",
        "_geodesic_from_christoffel",
        "_geodesic_from_lagrangian",
        "_geodesic_time_param",
        "_kretschmann",
        "_lagrangian",
        "_ricci_scalar",
        "_ricci_tensor",
        "_riemann",
    )

    def __init__(
        self: Self,
        *,
        coords: Coordinates,
        components: list[Any] | s.NDimArray | s.Matrix,
        symbol: str | s.Symbol = "g",
    ) -> None:
        r"""
        Construct a new metric object.
        #### Parameters:
        * `coords`: An OGRePy `Coordinates` object specifying the coordinate system of the representation of the initialization components. Will also be designated the default coordinate system of the metric.
        * `components`: The components with which to initialize the metric. Can be a list, a SymPy `Array` object, or a SymPy `Matrix` object.
        * `symbol` (optional): A string or a SymPy `Symbol` object designating the symbol to be used when displaying the metric. The string can include any TeX symbols, e.g. `r"\hat{T}"` (note the `r` in front of the string, indicating that the `\` in the string is not an escape character).
        #### Exceptions:
        * `OGRePyError`: If the metric components are not an invertible symmetric matrix.
        """
        # Initialize the parent tensor class. Note that metric tensors always have themselves as the associated metric.
        super().__init__(metric=self, coords=coords, indices=(-1, -1), components=components, symbol=symbol)
        # Convert the components to a SymPy `Matrix` if supplied as a different type, so we can calculate the determinant and check if it's symmetric.
        if not isinstance(components, s.Matrix):
            components = s.Matrix(components)
        # Validate the input.
        if components.det() == 0:
            _handle_error("The metric components must be an invertible matrix.")
        if not components.is_symmetric():
            _handle_error("The metric components must be a symmetric matrix.")
        # Calculate the components in all index configurations in advance. The (+1, -1) and (-1, +1) configurations of a metric are always represented by the identity matrix, so we can just calculate that in advance instead of explicitly raising or lowering indices. The (+1, +1) configuration of a metric is, by definition, represented by the inverse matrix.
        self._store_components(indices=(+1, -1), coords=coords, components=s.Array(cast(s.Matrix, s.eye(self.dim()))))
        self._store_components(indices=(-1, +1), coords=coords, components=s.Array(cast(s.Matrix, s.eye(self.dim()))))
        self._store_components(indices=(+1, +1), coords=coords, components=s.Array(cast(s.Matrix, components.inv())))
        # Store placeholders for various curvature tensors related to this metric. We don't pre-calculate them since this may be a very time-consuming task.
        self._christoffel: Christoffel | None = None
        self._einstein: Einstein | None = None
        self._geodesic_from_christoffel: GeodesicFromChristoffel | None = None
        self._geodesic_from_lagrangian: GeodesicFromLagrangian | None = None
        self._geodesic_time_param: GeodesicTimeParam | None = None
        self._kretschmann: Kretschmann | None = None
        self._lagrangian: Lagrangian | None = None
        self._ricci_scalar: RicciScalar | None = None
        self._ricci_tensor: RicciTensor | None = None
        self._riemann: Riemann | None = None

    # ============== #
    # Public methods #
    # ============== #

    def christoffel(
        self: Self,
        *letters: str | s.Symbol,
    ) -> Tensor:
        r"""
        Obtain the Christoffel symbols (the coefficients of the Levi-Civita connection) corresponding to this metric, based on the formula $$\Gamma_{\mu\nu}^{\lambda} = \frac{1}{2} g^{\lambda\sigma} \left( \partial_{\mu} g_{\nu\sigma} + \partial_{\nu} g_{\sigma\mu} - \partial_{\sigma} g_{\mu\nu} \right).$$
        #### Parameters:
        * `letters` (optional): One or more strings or SymPy `Symbol` objects indicating the index specification, as when using the `()` method of the tensor.
        #### Returns:
        The Christoffel symbols.
        """
        # Calculate and cache the Christoffel symbols if they have not already been calculated.
        if self._christoffel is None:
            self._christoffel = Christoffel(self)
        # If index letters are given, use them in the returned tensor, otherwise just return the tensor itself.
        return self._christoffel(*letters) if len(letters) > 0 else self._christoffel

    def einstein(
        self: Self,
        *letters: str | s.Symbol,
    ) -> Tensor:
        r"""
        Obtain the Einstein tensor corresponding to this metric, based on the formula $$G_{\mu\nu} = R_{\mu\nu} - \frac{1}{2} R g_{\mu\nu}.$$
        #### Parameters:
        * `letters` (optional): One or more strings or SymPy `Symbol` objects indicating the index specification, as when using the `()` method of the tensor.
        #### Returns:
        The Einstein tensor.
        """
        # Calculate and cache the Einstein tensor if it has not already been calculated.
        if self._einstein is None:
            self._einstein = Einstein(self)
        # If index letters are given, use them in the returned tensor, otherwise just return the tensor itself.
        return self._einstein(*letters) if len(letters) > 0 else self._einstein

    def geodesic_from_christoffel(
        self: Self,
        *letters: str | s.Symbol,
    ) -> Tensor:
        r"""
        Get the geodesic equations of this metric using the Christoffel symbols, based on the formula $$\quad \Longrightarrow \quad \ddot{x}^{\sigma} + \Gamma^{\sigma}_{\mu\nu} \dot{x}^{\mu} \dot{x}^{\nu} = 0.$$
        #### Parameters:
        * `letters` (optional): One or more strings or SymPy `Symbol` objects indicating the index specification, as when using the `()` method of the tensor.
        #### Returns:
        The Christoffel geodesic equations.
        """
        # Calculate and cache the Christoffel geodesic equations if they have not already been calculated.
        if self._geodesic_from_christoffel is None:
            self._geodesic_from_christoffel = GeodesicFromChristoffel(self)
        # If index letters are given, use them in the returned tensor, otherwise just return the tensor itself.
        return self._geodesic_from_christoffel(*letters) if len(letters) > 0 else self._geodesic_from_christoffel

    def geodesic_from_lagrangian(
        self: Self,
        *letters: str | s.Symbol,
    ) -> Tensor:
        r"""
        Get the geodesic equations of this metric using the curve Lagrangian, based on the formula $$\frac{\mathrm{d}}{\mathrm{d}\lambda} \left( \frac{\partial L}{\partial\dot{x}^{\mu}} \right) - \frac{\partial L}{\partial x^{\mu}} = 0.$$
        #### Parameters:
        * `letters` (optional): One or more strings or SymPy `Symbol` objects indicating the index specification, as when using the `()` method of the tensor.
        #### Returns:
        The Lagrangian geodesic equations.
        """
        # Calculate and cache the Lagrangian geodesic equations if they have not already been calculated.
        if self._geodesic_from_lagrangian is None:
            self._geodesic_from_lagrangian = GeodesicFromLagrangian(self)
        # If index letters are given, use them in the returned tensor, otherwise just return the tensor itself.
        return self._geodesic_from_lagrangian(*letters) if len(letters) > 0 else self._geodesic_from_lagrangian

    def geodesic_time_param(
        self: Self,
        *letters: str | s.Symbol,
    ) -> Tensor:
        r"""
        Get the geodesic equations of this metric in terms of the time coordinate, based on the formula $$\frac{\mathrm{d}}{\mathrm{d}\lambda} \left( \frac{\partial L}{\partial\dot{x}^{\mu}} \right) - \frac{\partial L}{\partial x^{\mu}} = 0.$$
        #### Parameters:
        * `letters` (optional): One or more strings or SymPy `Symbol` objects indicating the index specification, as when using the `()` method of the tensor.
        #### Returns:
        The Lagrangian geodesic equations.
        """
        # Calculate and cache the geodesic equations in terms of the time coordinate if they have not already been calculated.
        if self._geodesic_time_param is None:
            self._geodesic_time_param = GeodesicTimeParam(self)
        # If index letters are given, use them in the returned tensor, otherwise just return the tensor itself.
        return self._geodesic_time_param(*letters) if len(letters) > 0 else self._geodesic_time_param

    def kretschmann(
        self: Self,
    ) -> Kretschmann:
        r"""
        Obtain the Kretschmann scalar corresponding to this metric, based on the formula $$K = R_{\rho\sigma\nu\mu} R^{\rho\sigma\nu\mu}.$$
        #### Returns:
        The Kretschmann scalar.
        """
        # Calculate and cache the Kretschmann scalar if it has not already been calculated.
        if self._kretschmann is None:
            self._kretschmann = Kretschmann(self)
        return self._kretschmann

    def lagrangian(
        self: Self,
    ) -> Lagrangian:
        r"""
        Get the curve Lagrangian of this metric, based on the formula $$L=g_{\mu\nu} \dot{x}^{\mu} \dot{x}^{\nu}.$$ Taking the square root of (the absolute value of) the Lagrangian yields the integrand of the curve length functional. Varying the Lagrangian using the Euler-Lagrange equations yields the geodesic equations.
        #### Returns:
        The curve Lagrangian.
        """
        # Calculate and cache the curve Lagrangian if it has not already been calculated.
        if self._lagrangian is None:
            self._lagrangian = Lagrangian(self)
        return self._lagrangian

    def line_element(
        self: Self,
        coords: Coordinates | None = None,
    ) -> s.Expr:
        r"""
        Get the line element of this metric as a SymPy expression, based on the formula $$\mathrm{d}s^{2} = g_{\mu\nu} \mathrm{d}x^{\mu} \mathrm{d}x^{\nu}.$$
        #### Parameters:
        * `coords` (optional): An OGRePy `Coordinates` object specifying the coordinate system in which to calculate the line element. If not specified, the metric's default coordinate system will be used.
        #### Returns:
        The line element.
        """
        # Validate the input.
        use_coords: Coordinates = self._validate_or_default_coords(coords)
        # Calculate the components of the metric in the desired coordinate system, in case they have not already been calculated.
        components: s.Array = self._calc_representation(coords=use_coords, indices=(-1, -1))
        # Convert each of the coordinate symbols into the corresponding differential.
        diff: s.Array = s.Array([sym(r"\mathrm{d}" + _to_tex(symbol)) for symbol in use_coords.components()])
        # Sum the matrix elements with the corresponding differentials and return the result.
        return s.Add(*[components[i, j] * diff[i] * diff[j] for i in range(self.dim()) for j in range(self.dim())])

    def ricci_scalar(
        self: Self,
    ) -> RicciScalar:
        r"""
        Obtain the Ricci scalar corresponding to this metric, based on the formula $$R = R^{\lambda}{}_{\lambda} = g^{\mu\nu} R_{\mu\nu}$$
        #### Returns:
        The Ricci scalar.
        """
        # Calculate and cache the Ricci scalar if it has not already been calculated.
        if self._ricci_scalar is None:
            self._ricci_scalar = RicciScalar(self)
        return self._ricci_scalar

    def ricci_tensor(
        self: Self,
        *letters: str | s.Symbol,
    ) -> Tensor:
        r"""
        Obtain the Ricci tensor corresponding to this metric, based on the formula $$R_{\mu\nu} = R^{\lambda}{}_{\mu\lambda\nu}.$$
        #### Parameters:
        * `letters` (optional): One or more strings or SymPy `Symbol` objects indicating the index specification, as when using the `()` method of the tensor.
        #### Returns:
        The Ricci tensor.
        """
        # Calculate and cache the Ricci tensor if it has not already been calculated.
        if self._ricci_tensor is None:
            self._ricci_tensor = RicciTensor(self)
        # If index letters are given, use them in the returned tensor, otherwise just return the tensor itself.
        return self._ricci_tensor(*letters) if len(letters) > 0 else self._ricci_tensor

    def riemann(
        self: Self,
        *letters: str | s.Symbol,
    ) -> Tensor:
        r"""
        Obtain the Riemann tensor corresponding to this metric, based on the formula $$R^{\rho}{}_{\sigma\mu\nu} = \partial_{\mu}\Gamma^{\rho}_{\nu\sigma} - \partial_{\nu}\Gamma^{\rho}_{\mu\sigma} + \Gamma^{\rho}_{\mu\lambda} \Gamma^{\lambda}_{\nu\sigma} - \Gamma^{\rho}_{\nu\lambda} \Gamma^{\lambda}_{\mu\sigma}.$$
        #### Parameters:
        * `letters` (optional): One or more strings or SymPy `Symbol` objects indicating the index specification, as when using the `()` method of the tensor.
        #### Returns:
        The Riemann tensor.
        """
        # Calculate and cache the Riemann tensor if it has not already been calculated.
        if self._riemann is None:
            self._riemann = Riemann(self)
        # If index letters are given, use them in the returned tensor, otherwise just return the tensor itself.
        return self._riemann(*letters) if len(letters) > 0 else self._riemann

    def volume_element_squared(
        self: Self,
        coords: Coordinates | None = None,
    ) -> s.Basic:
        """
        Get the determinant of this metric as a SymPy expression. The square root of the determinant (or its negative, for a Lorentzian metric) is the volume element.
        #### Parameters:
        * `coords` (optional): An OGRePy `Coordinates` object specifying the coordinate system in which to show the volume element. If not specified, the metric's default coordinate system will be used.
        #### Returns:
        The volume element squared.
        """
        # Validate the input.
        use_coords: Coordinates = self._validate_or_default_coords(coords)
        # Calculate the components of the metric in the desired coordinate system, in case they have not already been calculated.
        components: s.Array = self._calc_representation(coords=use_coords, indices=(-1, -1))
        # Calculate the determinant, simplify it, and return the result.
        return _simplify(cast(s.Basic, s.Matrix(components).det()))

    # =============== #
    # Private methods #
    # =============== #

    @override
    def _transform_coordinates(
        self: Self,
        *,
        source_coords: Coordinates,
        target_coords: Coordinates,
        indices: IndexConfiguration = (-1, -1),
    ) -> None:
        """
        Calculate the components of this metric, in all index representations, in a new coordinate system and store them.
        #### Parameters:
        * `source_coords`: An OGRePy `Coordinates` object specifying the coordinate system to transform from.
        * `target_coords`: An OGRePy `Coordinates` object specifying the coordinate system to transform to. It is assumed that the representation (indices, source_coords) has already been calculated.
        * `indices`: Ignored (only included for compatibility with the parent class).
        """
        # Transform the components of the metric in the defining (-1, -1) representation.
        super()._transform_coordinates(source_coords=source_coords, target_coords=target_coords, indices=(-1, -1))
        # Calculate the components in the new coordinates in all other index configurations. The (+1, -1) and (-1, +1) configurations of a metric are always represented by the identity matrix, so we can just calculate that in advance instead of explicitly raising or lowering indices. The (+1, +1) configuration of a metric is, by definition, represented by the inverse matrix.
        self._store_components(indices=(+1, -1), coords=target_coords, components=s.Array(cast(s.Matrix, s.eye(self.dim()))))
        self._store_components(indices=(-1, +1), coords=target_coords, components=s.Array(cast(s.Matrix, s.eye(self.dim()))))
        self._store_components(indices=(+1, +1), coords=target_coords, components=s.Array(cast(s.Matrix, s.Matrix(self._get_components(indices=(-1, -1), coords=target_coords)).inv())))


class RicciScalar(Tensor, FixedDefaultIndices):
    """
    A class representing the Ricci scalar of a metric.
    """

    # =========== #
    # Constructor #
    # =========== #

    __slots__: tuple[str, ...] = ()

    def __init__(
        self: Self,
        metric: Metric,
    ) -> None:
        """
        Construct a new Ricci scalar object from the given metric. **Note: This constructor should NOT be called manually! Please use the `Metric.ricci_scalar()` method instead, to facilitate caching.**
        #### Parameters:
        * `metric`: An OGRePy `Metric` object specifying the metric to use.
        """
        # Obtain the metric's default coordinates.
        coords: Coordinates = metric._default_coords
        # Initialize the parent tensor class.
        super().__init__(
            metric=metric,
            coords=coords,
            indices=(),
            components=self._calc_ricci_scalar(metric, coords),
            symbol="R",
            simplify=False,
        )

    # =============== #
    # Private methods #
    # =============== #

    def _calc_ricci_scalar(
        self: Self,
        metric: Metric,
        coords: Coordinates,
    ) -> s.Array:
        """
        Calculate the Ricci scalar for the given metric and coordinate system.
        #### Parameters:
        * `metric`: An OGRePy `Metric` object specifying the metric to use.
        * `coords`: An OGRePy `Coordinates` object specifying the coordinate system to use.
        #### Returns:
        The calculated components.
        """
        return (metric.ricci_tensor("mu mu")).components(coords=coords, indices=(), warn=False)


class RicciTensor(Tensor, FixedDefaultIndices):
    """
    A class representing the Ricci tensor of a metric.
    """

    # =========== #
    # Constructor #
    # =========== #

    __slots__: tuple[str, ...] = ()

    def __init__(
        self: Self,
        metric: Metric,
    ) -> None:
        """
        Construct a new Ricci tensor object from the given metric. **Note: This constructor should NOT be called manually! Please use the `Metric.ricci_tensor()` method instead, to facilitate caching.**
        #### Parameters:
        * `metric`: An OGRePy `Metric` object specifying the metric to use.
        """
        # Obtain the metric's default coordinates.
        coords: Coordinates = metric._default_coords
        # Initialize the parent tensor class.
        super().__init__(
            metric=metric,
            coords=coords,
            indices=(-1, -1),
            components=self._calc_ricci_tensor(metric, coords),
            symbol="R[0][1]",
            simplify=False,
        )

    # =============== #
    # Private methods #
    # =============== #

    def _calc_ricci_tensor(
        self: Self,
        metric: Metric,
        coords: Coordinates,
    ) -> s.Array:
        """
        Calculate the Ricci tensor for the given metric and coordinate system.
        #### Parameters:
        * `metric`: An OGRePy `Metric` object specifying the metric to use.
        * `coords`: An OGRePy `Coordinates` object specifying the coordinate system to use.
        #### Returns:
        The calculated components.
        """
        return (metric.riemann("lamda mu lamda nu")).components(coords=coords, indices=(-1, -1), warn=False)


class Riemann(Tensor, FixedDefaultIndices):
    """
    A class representing the Riemann tensor of a metric.
    """

    # =========== #
    # Constructor #
    # =========== #

    __slots__: tuple[str, ...] = ()

    def __init__(
        self: Self,
        metric: Metric,
    ) -> None:
        """
        Construct a new Riemann tensor object from the given metric. **Note: This constructor should NOT be called manually! Please use the `Metric.riemann()` method instead, to facilitate caching.**
        #### Parameters:
        * `metric`: An OGRePy `Metric` object specifying the metric to use.
        """
        # Obtain the metric's default coordinates.
        coords: Coordinates = metric._default_coords
        # Initialize the parent tensor class.
        super().__init__(
            metric=metric,
            coords=coords,
            indices=(+1, -1, -1, -1),
            components=self._calc_riemann(metric, coords),
            symbol="R[0][1][2][3]",
            simplify=False,
        )

    # =============== #
    # Private methods #
    # =============== #

    def _calc_riemann(
        self: Self,
        metric: Metric,
        coords: Coordinates,
    ) -> s.Array:
        """
        Calculate the Riemann tensor for the given metric and coordinate system.
        #### Parameters:
        * `metric`: An OGRePy `Metric` object specifying the metric to use.
        * `coords`: An OGRePy `Coordinates` object specifying the coordinate system to use.
        #### Returns:
        The calculated components.
        """
        riemann: Tensor = PartialD("mu") @ metric.christoffel("rho nu sigma") - PartialD("nu") @ metric.christoffel("rho mu sigma") + metric.christoffel("rho mu lamda") @ metric.christoffel("lamda nu sigma") - metric.christoffel("rho nu lamda") @ metric.christoffel("lamda mu sigma")
        riemann.permute("rho sigma mu nu")
        return riemann.components(coords=coords, indices=(+1, -1, -1, -1), warn=False)


#################################################################################################
#                                Private objects (not exported)                                 #
#################################################################################################


#####################
# Private functions #
#####################


def _add_parentheses(
    symbol: str,
) -> str:
    """
    Add parentheses to a symbol if it contains addition or subtraction.
    #### Parameters:
    * `symbol`: A symbol string.
    #### Returns:
    The string with parentheses added if needed.
    """
    return symbol if " + " not in symbol else f"({symbol})"


def _check_dict_type(
    dictionary: Mapping[Any, Any],
    desired_key_type: type | UnionType,
    desired_value_type: type | UnionType,
    message: str,
) -> None:
    """
    Check that all keys and values in the dictionary have the desired types. If not, raise an error.
    #### Parameters:
    * `dictionary`: The dictionary to check.
    * `desired_key_type`: The type the keys should have.
    * `desired_value_type` The type the values should have.
    * `message`: A string to display if the object provided is not a dictionary, or the keys or values are not of the desired types, followed by information about the actual type.
    """
    _check_type(dictionary, dict, message)
    for key, value in dictionary.items():
        _check_type(key, desired_key_type, message)
        _check_type(value, desired_value_type, message)


def _check_type(
    obj: object,
    desired_type: type | UnionType,
    message: str,
) -> None:
    """
    Check that the object is of the desired type. If not, raise an error.
    #### Parameters:
    * `obj`: The object to check.
    * `desired_type`: The type the object should have.
    * `message`: A string to display if the object is not of the desired type, followed by information about the object's actual type.
    #### Exceptions:
    * `OGRePyError`: If the object is not of the desired type.
    """
    if not isinstance(obj, desired_type):
        _handle_error(message + f" The object `{obj}` is of type `{type(obj).__name__}`.", "&#x1f4b1;")


def _collect_symbols(
    *items: s.Symbol | str,
) -> list[s.Symbol]:
    """
    Convert items into a list of SymPy symbols.
    #### Parameters:
    * `items` (optional): Zero or more SymPy `Symbol` objects or strings. `Symbol` objects will be retained as is. Strings must be given in the same format as SymPy's `symbols()` function, that is, a space- or comma-separated list of one or more letters or TeX codes. For example, if `a` and `c` are SymPy `Symbol` objects, the input `(a, "b", c, "d e f")` will return a list of 6 SymPy `Symbol`s: `[a, b, c, d, e, f]`.
    #### Returns:
    A list of SymPy symbols.
    """
    # Validate the input.
    for item in items:
        _check_type(item, s.Symbol | str, "The symbols must be SymPy `Symbol` objects or strings.")
    # Process the symbols.
    symbol_list: list[s.Symbol] = []
    for item in items:
        if isinstance(item, s.Symbol):
            # If the item is already a symbol, just append it to the list.
            symbol_list.append(item)
        else:
            # If the item is a string, it can contain one or more symbols (e.g. "a b" contains the symbols a and b), so we first convert the string into a list of symbols and then append it to the list.
            symbol_list.extend(syms(item))
    return symbol_list


def _collect_tex_symbols(
    *items: str | s.Symbol,
) -> IndexSpecification:
    r"""
    Convert symbols into a list of TeX strings.
    #### Parameters:
    * `items` (optional): Zero or more strings or SymPy `Symbol` objects. `Symbol` objects will be converted into TeX strings, e.g. Symbol("mu") will be converted to "\mu". Strings must be given in the same format as SymPy's `symbols()` function, that is, a space- or comma-separated list of one or more letters or TeX codes. For example, "mu nu" will be converted to two strings, "\mu" and "\nu". The list will splice in any symbols, even if two or more symbols are defined together. For example, if `a` is a SymPy `Symbol` and the input is [a, "b", "c d"], the output will be ["a", "b", "c", "d"].
    #### Returns:
    A list of TeX strings.
    """
    # Validate the input.
    for item in items:
        _check_type(item, str | s.Symbol, "The symbols must be strings or SymPy `Symbol` objects.")
    # Process the symbols.
    symbol_list: list[s.Symbol] = []
    for item in items:
        if isinstance(item, s.Symbol):
            # If the item is a symbol, just append it to the list.
            symbol_list.append(item)
        else:
            # If the item is a string, it can contain one or more symbols (e.g. "a b" contains the symbols a and b), so we first convert the string into a list of symbols and then append it to the list.
            symbol_list.extend(syms(item))
    # Return the symbols, converted to TeX strings. Converting strings to symbols and then back to strings guarantees that Greek letters will be properly converted into TeX; for example, "mu" will be converted to "\mu". However, note that `Symbol("mu") != Symbol(r"\mu")`, so care must be taken that all inputs go through this function for consistency.
    return [_to_tex(symbol) for symbol in symbol_list]


def _consecutive_placeholders(
    symbol: str,
) -> str:
    """
    Ensure all free index placeholders of the form [n] are consecutive.
    #### Parameters:
    * `symbol`: A symbol string.
    #### Returns:
    The string with consecutive free index placeholders starting from 0, maintaining relative order between added tensors. For example, A[2][1][6][4] + B[6][4][2][1] will be changed to A[1][0][3][2] + B[3][2][1][0].
    """
    # Make a list of all the numbers inside the index placeholders.
    placeholders: list[int] = _unique_free_placeholders(symbol)
    # Enumerate the placeholders and change each one to the number corresponding to its place in the list.
    for new, old in enumerate(placeholders):
        symbol = symbol.replace(f"[{old}]", f"[{new}]")
    return symbol


def _contract(
    *tensors: TensorWithIndexSpecification,
) -> TensorWithIndexSpecification:
    """
    Contract the given tensors, taking into account index letters.
    #### Parameters:
    * `tensors`: A list of tensors to contract. Each entry in the list must be a tuple of the form `(tensor, [index1, index2, ...])` where `tensor` is a SymPy array containing the components of the tensor and `[index1, index2, ...]` is a list of zero or more TeX strings to use as index letters. The letters will be used to determine which indices to contract. For example, to contract A_ij B_jk we pass `[(a, ["i", "j"]), (b, ["j", "k"])]`. Note that there are no upper or lower indices; it is assumed that the caller has already ensured that all contracted indices are one upper, one lower in the components that were obtained. Also, it is assumed that the caller has ensured no index repeats more than twice in the entire list.
    #### Returns:
    A tuple of the form `(tensor, [index1, index2, ...])` (the same format as the input tensors) containing the components of the result along with the corresponding index letters. For the example above, this would be a tensor C_ik, so `tensor` will contain the components of this tensor and the index letters would be `["i", "k"]`.
    """
    # Collect all the indices from all the tensors.
    all_indices: IndexSpecification = [index for indices in [tensor[1] for tensor in tensors] for index in indices]
    # Check for duplicate indices.
    tally: dict[str, int] = {index: all_indices.count(index) for index in set(all_indices)}
    # Collect all indices that appear exactly twice (one copy of each).
    trace_indices: IndexSpecification = [index for index, count in tally.items() if count == 2]
    # Make a list of pairs of indices to contract, based on their positions in the list of all indices.
    to_contract: list[tuple[int, ...]] = [tuple(pos for pos, index in enumerate(all_indices) if index == trace_index) for trace_index in trace_indices]
    # Figure out which indices are left once we contract the pairs.
    out_indices: IndexSpecification = [index for index in all_indices if index not in trace_indices]
    # Make a list of all the tensors.
    all_tensors: list[s.Array] = [tensor[0] for tensor in tensors]
    # Contract the tensors.
    result: s.Basic = s.tensorcontraction(s.tensorproduct(*all_tensors), *to_contract)
    # Return the result along with the remaining indices. If the result is a scalar, it will be a SymPy `Array` of rank 0, so we turn it into a rank 1 array with one element, since this is how OGRePy stores scalars (rank 0 arrays are buggy, e.g. `simplify()` doesn't work on them).
    return (result if isinstance(result, s.Array) else s.Array([result]), out_indices)


def _display_markdown(
    text: str,
) -> None:
    """
    Display Markdown code in the notebook, or fall back to `print()` if not running in a notebook.
    #### Parameters:
    * `text`: The Markdown code to print.
    """
    if _in_notebook():
        _ = display(Markdown(_format_with_css(text)))
    else:
        print(text)  # noqa: T201


def _display_tex(
    tex: str,
) -> None:
    """
    Display TeX code in the notebook.
    #### Parameters:
    * `text`: The TeX code to print. No need to enclose in dollar signs.
    """
    # Output the TeX code enclosed in double dollar signs, so it gets rendered as a display-style formula in the notebook. We don't use `IPython.display.Math` or `IPython.display.Latex` because we want the custom CSS style to be applied.
    _display_markdown(f"$${tex}$$")


def _filter_classes(
    dictionary: dict[str, Any],
    cls: list[Any],
) -> dict[str, Any]:
    """
    Filter variables of specific classes from a dictionary.
    #### Parameters:
    * `dictionary`: The dictionary to filter. This is assumed to be a dictionary obtained from `globals()` or similar, which associates a variable name to its value.
    * `cls`: A list of classes to filter.
    #### Returns:
    A filtered dictionary containing only variables of the desired classes. Note that, by design, inheritance is NOT taken into account. Also, note that any variables that start with an underscore are ignored, to avoid e.g. the `_` variable, which is the result of the last evaluation, as well as other similar internal variables.
    """
    return {name: value for name, value in dictionary.items() if type(value) in cls and not name.startswith("_")}


def _format_with_css(
    text: str,
) -> str:
    """
    Format the given text using the custom CSS style, if defined.
    #### Parameters:
    * `text`: The text to format.
    #### Returns:
    The formatted text.
    """
    if options.css_style != "":
        return f'<div style="{options.css_style}">\n\n{text}\n\n</div>'
    return text


def _handle_error(
    error: str,
    icon: str = "&#x26a0;&#xfe0f;",
) -> NoReturn:
    """
    Handle an error. If friendly errors are enabled, displays a friendly error message while disabling the traceback. Otherwise, raises the `OGRePyError` exception.
    #### Parameters:
    * `error`: The error message.
    * `icon` (optional): The icon to print along with the friendly error message, if enabled. Default is a yellow triangle with an exclamation mark inside.
    #### Exceptions:
    * `NoTracebackError` if friendly errors are enabled.
    * `OGRePyError` if friendly errors are disabled.
    #### Returns:
    This function never returns; it always raises one of the two exceptions listed above.
    """
    if options.friendly_errors:
        _display_markdown(f'{icon}<span style="color: #cf514b;"><b>OGRePy:</b> {error}</span>')
        raise NoTracebackError
    raise OGRePyError(error)


def _in_notebook() -> bool:
    """
    Check if this code is running inside a Jupyter notebook.
    #### Returns:
    `True` if this code is running inside a Jupyter notebook, `False` otherwise.
    """
    # Calling `get_ipython()` should result in one of the following classes:
    # * `NoneType` (i.e. it just returns `None`) if using pure Python, without IPython.
    # * `TerminalInteractiveShell` if using IPython in the terminal.
    # * `ZMQInteractiveShell` if using IPython in a notebook interface.
    return get_ipython().__class__.__name__ == "ZMQInteractiveShell"


def _list_aliases(
    names: list[str],
) -> str:
    """
    List a variable's name, along with any aliases.
    #### Parameters:
    * `names`: A list of the names of all the variables with the same object reference.
    #### Returns:
    A string containing the variable's name and its aliases, or "[no name]" if the list is empty.
    """
    if len(names) == 0:
        return "`[no name]`"
    if len(names) == 1:
        return f"`{names[0]}`"
    if len(names) == 2:
        return f"`{names[0]}` (alias: `{names[1]}`)"
    return f"`{names[0]}` (aliases: `{"`, `".join(names[1:])}`)"


def _list_references(
    dictionary: dict[Any, list[str]],
) -> str:
    """
    List each reference along with the name of the variable referencing it and any aliases. Used by `info()`.
    #### Parameters:
    * `dictionary`: A dictionary of object references associated with a list of variable names referencing each object.
    #### Returns:
    A string containing a printout of the list.
    """
    text: str = ""
    # We loop over the dictionary using `enumerate()`, so `count` will be the index of the key (starting from 1), `ref` will be the key specifying the object reference, and `names` will be the list specifying the variable names.
    for count, (ref, names) in enumerate(dictionary.items(), start=1):
        text += f"{count}. {_list_aliases(names)}"
        if isinstance(ref, Tensor):
            text += f" (symbol: ${ref.tex_symbol(indices=ref.default_indices, letters=options.index_letters)}$)"
        text += f" (id: `{hex(id(ref))}`)"
        if isinstance(ref, Coordinates):
            using: list[str] = _using_coords(ref)
            if len(using) > 0:
                text += f", default for: `{"`, `".join(using)}`"
        elif isinstance(ref, Metric):
            using: list[str] = _using_metric(ref)
            if len(using) > 0:
                text += f", used by: `{"`, `".join(using)}`"
        text += "\n"
    return text


def _lookup_names(
    ref: object,
) -> str:
    """
    Look up the names of the notebook variables corresponding to a specific object reference.
    #### Parameters:
    * `ref`: The reference to look up.
    #### Returns:
    A string containing the name of the first variable that points to the given reference, along with any aliases, or "[no name]" if there is no match. Note that any variables that start with an underscore are ignored, to avoid e.g. the `_` variable, which is the result of the last evaluation, as well as other similar internal variables.
    """
    return _list_aliases([name for name, value in __main__.__dict__.items() if value is ref and not name.startswith("_")])


def _make_replacement(
    components: s.Array,
    replace: dict[Any, Any],
) -> s.Array:
    """
    Make a replacement in the given components.
    #### Parameters:
    * `components`: The components to process, as a SymPy `Array`.
    * `replace`: A dictionary of substitutions to be made in the components before printing them. Each key in the dictionary will be replaced with its value.
    #### Returns:
    The components with the replacement made.
    """
    # Validate the input.
    _check_type(replace, dict, "The list of replacements must be a dictionary.")
    return _subs_array(components, replace)


def _map_function(
    components: s.Array,
    function: Callable[..., Any],
) -> s.Array:
    """
    Map a function to the given components.
    #### Parameters:
    * `components`: The components to process, as a SymPy `Array`.
    * `function`: A function to apply to the components.
    #### Returns:
    The components with the function mapped.
    """
    # Validate the input.
    if not callable(function):
        _handle_error("The function must be a callable object.")
    return cast(s.Array, components.applyfunc(function))


def _permute_placeholders(
    symbol: str,
    old: IndexSpecification,
    new: IndexSpecification,
) -> str:
    """
    Permute the index placeholder in the given symbol.
    #### Parameters:
    * `symbol`: The symbol string.
    #### Returns:
    The symbol string with its index placeholders permuted.
    """
    # Create a dictionary of replacements.
    replacements: dict[str, str] = {}
    for i, letter in enumerate(old):
        replacements[str(i)] = str(new.index(letter))
    # Perform the replacements and return the new symbol.
    return re.sub(rf"({"|".join(replacements.keys())})", lambda match: replacements[match[0]], symbol)


def _reverse_dict(
    dictionary: dict[Any, Any],
) -> dict[Any, list[Any]]:
    """
    Reverse a dictionary. Each key in the result will contain a list of all the keys in the original dictionary whose value is that key.
    #### Parameters:
    * `dictionary`: The dictionary to reverse.
    #### Returns:
    The reversed dictionary.
    """
    rev: dict[Any, Any] = collections.defaultdict(list)
    for key, value in dictionary.items():
        rev[value].append(key)
    return rev


def _simplify(
    expression: s.Basic,
    **kwargs: object,
) -> s.Basic:
    """
    Simplify an expression using the function specified by `options.simplify_func`.
    #### Parameters:
    * `expression`: The SymPy expression to simplify.
    * `kwargs` (optional): Zero or more keyword arguments to pass to the function.
    #### Returns:
    The simplified expression.
    """
    return options.simplify_func(expression, **kwargs)


def _simplify_array(
    array: s.Array,
    **kwargs: object,
) -> s.Array:
    """
    Simplify a SymPy `Array` using the function specified by `options.simplify_func`.
    #### Parameters:
    * `array`: The array to simplify.
    * `kwargs` (optional): Zero or more keyword arguments to pass to the function.
    #### Returns:
    The simplified array.
    """
    # We apply the simplification function independently to each element for two reasons:
    # 1. SymPy doesn't seem to pass arguments (such as `doit=False`) if `simplify()` is called on an `Array`.
    # 2. A user-supplied function may not be able to handle arrays on its own.
    return cast(s.Array, array.applyfunc(functools.partial(options.simplify_func, **kwargs)))


def _subs_array(
    array: s.Array,
    *args: object,
    **kwargs: object,
) -> s.Array:
    """
    Perform a substitution in a SymPy `Array`.
    #### Parameters:
    * `array`: The array to perform the substitution in.
    * `args` (optional): Zero or more positional arguments to pass to the function.
    * `kwargs` (optional): Zero or more keyword arguments to pass to the function.
    #### Returns:
    The simplified array.
    """
    return cast(s.Array, array.subs(*args, **kwargs))


def _to_tex(
    expression: s.Basic,
) -> str:
    """
    Convert the given SymPy expression to TeX code.
    #### Returns:
    The TeX code.
    """
    # Options being passed:
    # * `diff_operator="rd"` will make the differential operator d show in roman font.
    # * `inv_trig_style="power"` will make inverse trig functions be displayed with a power of -1.
    # * `mat_delim="("` will make matrix delimiters be displayed as round brackets.
    return cast(str, s.latex(expression, diff_operator="rd", inv_trig_style="power", mat_delim="("))


def _unique_free_placeholders(
    symbol: str,
) -> list[int]:
    """
    Get a sorted list containing the unique integer parameters in the free index placeholders of the form `[n]` in the given symbol.
    #### Parameters:
    * `symbol`: The symbol.
    #### Returns:
    The list of free index placeholders.
    #### Examples:
    >>> _unique_free_placeholders("T[0][:0][1][:1][1:][0:][2]")
    [0, 1, 2]
    """
    return sorted({int(match) for match in _free_pattern.findall(symbol)})


def _unique_summation_placeholders(
    symbol: str,
) -> list[int]:
    """
    Get a sorted list containing the unique integer parameters in the summation index placeholders of the form `[:n]` or `[n:]` in the given symbol.
    #### Parameters:
    * `symbol`: The symbol.
    #### Returns:
    The list of summation index placeholders.
    #### Examples:
    >>> _unique_summation_placeholders("T[0][:0][1][:1][1:][0:][2]")
    [0, 1]
    """
    return sorted({int(match[1]) for match in _summation_pattern.findall(symbol)})


def _using_coords(
    coords: Coordinates,
) -> list[str]:
    """
    Find out which tensors are using these coordinates as their default coordinate system.
    #### Parameters:
    * `coords`: The coordinates.
    #### Returns:
    A list of tensors using this coordinate system, including aliases if any.
    """
    return _using_object(coords, [Tensor, Metric])


def _using_metric(
    metric: Metric,
) -> list[str]:
    """
    Find out which tensors are using this metric as their associated metric.
    #### Parameters:
    * `metric`: The metric.
    #### Returns:
    A list of tensors using this metric, including aliases if any.
    """
    return _using_object(metric, [Tensor])


def _using_object(
    obj: object,
    include_classes: list[type],
) -> list[str]:
    """
    Find out which tensors are using this coordinate system or metric as their default coordinate system or associated metric.
    #### Parameters:
    * `obj`: The object to check.
    * `include_classes`: Which classes to include in the check.
    #### Returns:
    A list of relevant tensors, including aliases if any.
    """
    # Get the notebook globals.
    glob: dict[str, Any] = __main__.__dict__
    # Collect a dictionary of all the objects of the desired classes created in the notebook.
    tensor_dict: dict[str, Tensor] = _filter_classes(glob, include_classes)
    # Deal with the possibility of more than one variable referencing the same object by creating a reverse dictionary matching each object reference with a list of the variable names referencing it.
    tensor_reverse: dict[Tensor, list[str]] = _reverse_dict(tensor_dict)
    # Create a list of relevant tensors, including aliases if any.
    using: list[str] = []
    for tensor, names in tensor_reverse.items():
        if isinstance(obj, Coordinates) and obj is tensor.default_coords or isinstance(obj, Metric) and obj is tensor.metric():
            using.append(_list_aliases(names))
    return using


def _validate_components(
    components: list[Any] | s.NDimArray | s.Matrix,
    num_coords: int,
    num_indices: int,
) -> s.Array:
    """
    Validate that the components passed to the constructor represent a valid tensor.
    #### Parameters:
    * `components`: The components of the tensor. Can be a list, a SymPy `Array` object, or (for rank 2 tensors) a SymPy `Matrix` object.
    * `num_coords`: The number of coordinates, i.e. the dimension of the manifold.
    * `num_indices`: The number of indices, i.e. the rank of the tensor.
    #### Returns:
    The components as a SymPy `Array`.
    #### Exceptions:
    * `OGRePyError`: If the components do not contain at least one element, the components of a scalar are not a SymPy `Array` object or a list with exactly one element, the rank of the components does not match the number of indices, or the dimension of each rank of the components does not match the number of coordinates.
    """
    # Check that the components are given in one of the supported types.
    _check_type(components, list | s.NDimArray | s.Matrix, "The components must be a list, a SymPy `Array` object, or (for rank 2 tensors) a SymPy `Matrix` object.")
    # Convert the components to a SymPy `Array` if they were given in a different supported type.
    if not isinstance(components, s.Array):
        components = s.Array(components)
    # Make sure there is at least one element.
    if len(components) == 0:
        _handle_error("The components must contain at least one element.")
    if num_indices == 0:
        # For a tensor of rank 0, i.e. a scalar, we want an array with exactly one dimension and one element, e.g. [1].
        if components.rank() != 1 or len(components) != 1:
            _handle_error("The components of a scalar must be a list or a SymPy `Array` object with exactly one element.")
    else:
        # For a tensor of non-zero rank, we must verify that the rank of the components (i.e. how many dimensions the array has) is equal to the rank of the tensor (i.e. how many indices it has).
        if components.rank() != num_indices:
            _handle_error(f"The rank of the components must match the number of indices. The rank is {components.rank()}, but the number of indices is {num_indices}.")
        # A tensor cannot be a jagged array, so we also go through each rank of the components and make sure the dimension matches the number of coordinates.
        for i in range(cast(int, components.rank())):
            if components.shape[i] != num_coords:
                _handle_error(f"The dimension of each rank of the components must match the number of coordinates. The number of elements at rank {i} is {components.shape[i]}, but should be {num_coords}.")
    return components


def _validate_coordinates(
    coords: Coordinates,
) -> Coordinates:
    """
    Validate that the given object is a Coordinates object.
    #### Parameters:
    * `coords`: The object to validate.
    #### Returns:
    The object.
    """
    _check_type(coords, Coordinates, "The coordinates must be an OGRePy `Coordinates` object.")
    return coords


def _validate_indices(
    indices: IndexConfiguration,
) -> IndexConfiguration:
    """
    Validate that the given object is a valid index representation, that is, a tuple of integers, each of which can be either +1 or -1.
    #### Parameters:
    * `indices`: The object to validate.
    #### Returns:
    The object.
    #### Exceptions:
    * `OGRePyError`: If the object is not a valid index representation.
    """
    tuple_error: str = "The indices must be a tuple of integers, each of which can be either `+1` (for an upper index) or `-1` (for a lower index). For example: `(+1, -1)`."
    _check_type(indices, tuple, tuple_error)
    for index in indices:
        if index not in (-1, 1):
            _handle_error(tuple_error)
    return indices


def _validate_metric(
    metric: Metric,
) -> Metric:
    """
    Validate that the given object is a Metric object.
    #### Parameters:
    * `metric`: The object to validate.
    #### Returns:
    The object.
    """
    _check_type(metric, Metric, "The metric must be an OGRePy `Metric` object.")
    return metric


def _validate_permutation(
    index_spec: IndexSpecification | list[str | s.Symbol] | str,
    rank: int,
) -> list[str]:
    r"""
    Validate that the index specification represent a valid permutation for a tensor of a given rank, that is, the number of indices matches the rank of the tensor and there are no duplicate indices, and return a list of TeX strings corresponding to the index specification.
    #### Parameters:
    * `index_spec`: A list of one or more strings or SymPy `Symbol` objects indicating the index specification. `Symbol` objects will be converted into TeX strings, e.g. Symbol("mu") will be converted to "\mu". Strings must be given in the same format as SymPy's `symbols()` function, that is, a space- or comma-separated list of one or more letters or TeX codes. For example, "mu nu" will be converted to two strings, "\mu" and "\nu". The list will splice in any symbols, even if two or more symbols are defined together. For example, if `a` is a SymPy `Symbol` and the input is [a, "b", "c d"], the output will be ["a", "b", "c", "d"]. A single string of space- or comma-separated symbols can also be entered instead of a list.
    #### Returns:
    A list of TeX strings corresponding to the index specification.
    """
    # If a single string was entered, convert it to a list of one element.
    if isinstance(index_spec, str):
        index_spec = [index_spec]
    # Validate the input and convert all the letters to TeX symbols.
    letters: IndexSpecification = _collect_tex_symbols(*index_spec)
    # Check that the index specifications matches the rank of the tensor.
    if len(letters) != rank:
        _handle_error(f"The index specification\n${"".join(letters)}$\ndoes not match the rank of the tensor. The number of indices should be {rank}.")
    # Check for duplicate index letters.
    tally: dict[str, int] = {letter: letters.count(letter) for letter in set(letters)}
    max_duplicates: int = max(tally.values()) if len(tally) > 0 else 0
    if max_duplicates > 1:
        invalid_indices: IndexSpecification = [letter for letter, count in tally.items() if count > 1]
        _handle_error(f"The index specification\n${"".join(letters)}$\nis invalid, as it contains more than one instance of the {"index" if len(invalid_indices) == 1 else "indices"} \n${", ".join(invalid_indices)}$\n.")
    # Return the letters as a list of TeX strings.
    return letters


def _validate_symbol(
    symbol: str | s.Symbol,
    rank: int,
) -> str:
    r"""
    Validate that the given object is a valid tensor symbol, that is, either a string or a SymPy `Symbol` object, and that it has the right number of index placeholders, adding them if they do not already exist. Also converts strings like "mu" into Tex strings like "\mu".
    #### Parameters:
    * `symbol`: The object to validate.
    * `rank`: The rank of the tensor.
    #### Returns:
    The symbol, converted to a TeX string if necessary.
    """
    # Pass the symbol through `sym()`. This will validate the type, and also convert the internal string to a TeX string. Then convert it back to a TeX string for processing.
    symbol = _to_tex(sym(symbol))
    # Get a list of the unique free index placeholders in the symbol.
    free_placeholders: list[int] = _unique_free_placeholders(symbol)
    if len(free_placeholders) == 0:
        # Append free index placeholders if they are not already specified.
        for i in range(rank):
            symbol += f"[{i}]"
    elif len(free_placeholders) != rank:
        # If the index placeholders are already specified, verify that they match the rank of the tensor.
        _handle_error(f"The number of unique free index placeholders, {len(free_placeholders)}, does not match the rank of the tensor, {rank}.")
    elif set(free_placeholders) != set(range(rank)):
        # Also verify that the index placeholders are consecutive.
        _handle_error("The free index placeholders must be numbered consecutively starting from 0.")
    # Get a list of the unique summation index placeholders in the symbol.
    sum_placeholders: list[int] = _unique_summation_placeholders(symbol)
    if len(sum_placeholders) > 0:
        # Verify that the summation placeholders are consecutive.
        if set(sum_placeholders) != set(range(len(sum_placeholders))):
            _handle_error("The summation index placeholders must be numbered consecutively starting from 0.")
        # Verify that there are upper and lower summation placeholders for each index.
        for i in sum_placeholders:
            if f"[:{i}]" not in symbol or f"[{i}:]" not in symbol:
                _handle_error("The summation index placeholders must come in pairs of one or more lower index `[:n]` and one or more upper index `[n:]` with the same `n`.")
    return symbol


###################
# Private classes #
###################


class NoTracebackError(Exception):
    """
    A dummy exception to be used for disabling the traceback.
    """

    def _render_traceback_(
        self: Self,
    ) -> None:
        pass


class Options:
    """
    A class to store the various options used to configure the package, along with their default values.
    """

    # =========== #
    # Constructor #
    # =========== #

    __slots__: tuple[str, ...] = (
        "_css_style",
        "_curve_parameter",
        "_friendly_errors",
        "_index_letters",
        "_simplify_func",
    )

    _css_style_default: ClassVar[str] = ""
    _curve_parameter_default: ClassVar[s.Symbol] = sym(r"\lambda")
    _friendly_errors_default: ClassVar[bool] = True
    _index_letters_default: ClassVar[IndexSpecification] = [r"\mu", r"\nu", r"\rho", r"\sigma", r"\kappa", r"\lambda", r"\alpha", r"\beta", r"\gamma", r"\delta", r"\epsilon", r"\zeta", r"\epsilon", r"\theta", r"\iota", r"\xi", r"\pi", r"\tau", r"\phi", r"\chi", r"\psi", r"\omega"]
    _simplify_func_default: ClassVar[Callable[..., Any]] = cast(Callable[..., Any], s.simplify)

    def __init__(
        self: Self,
    ) -> None:
        """
        Construct a new options object.
        """
        # Store the defaults for all the options.
        self._css_style: str = Options._css_style_default
        self._curve_parameter: s.Symbol = Options._curve_parameter_default
        self._friendly_errors: bool = Options._friendly_errors_default
        self._index_letters: IndexSpecification = Options._index_letters_default[:]
        self._simplify_func: Callable[..., Any] = Options._simplify_func_default

    # ========== #
    # Properties #
    # ========== #

    ### css_style property ###

    @property
    def css_style(
        self: Self,
    ) -> str:
        """
        The CSS style of the output notebook cells. Must be a string.
        """
        return self._css_style

    @css_style.setter
    def css_style(
        self: Self,
        value: str,
    ) -> None:
        _check_type(value, str, "The CSS style must be a string.")
        self._css_style = value

    @css_style.deleter
    def css_style(
        self: Self,
    ) -> None:
        self._css_style = Options._css_style_default

    ### curve_parameter property ###

    @property
    def curve_parameter(
        self: Self,
    ) -> s.Symbol:
        """
        The symbol used as the curve parameter. Must be a string or a SymPy `Symbol`.
        """
        return self._curve_parameter

    @curve_parameter.setter
    def curve_parameter(
        self: Self,
        value: str | s.Symbol,
    ) -> None:
        self._curve_parameter = sym(value)

    @curve_parameter.deleter
    def curve_parameter(
        self: Self,
    ) -> None:
        self._curve_parameter = Options._curve_parameter_default

    ### friendly_errors property ###

    @property
    def friendly_errors(
        self: Self,
    ) -> bool:
        """
        Whether to display friendly errors. Must be a either `True` or `False`. Normally, error messages displayed to the user by OGRePy utilize HTML/Markdown formatting and suppress the traceback. Advanced users can set this to `False` to see the full traceback and/or catch the exceptions and handle them independently.
        """
        return self._friendly_errors

    @friendly_errors.setter
    def friendly_errors(
        self: Self,
        value: bool,
    ) -> None:
        _check_type(value, bool, "The value must be either `True` or `False`.")
        self._friendly_errors = value

    @friendly_errors.deleter
    def friendly_errors(
        self: Self,
    ) -> None:
        self._friendly_errors = Options._friendly_errors_default

    ### index_letters property ###

    @property
    def index_letters(
        self: Self,
    ) -> IndexSpecification:
        r"""
        The index letters to use when displaying tensors. Must be given as a list of one or more strings or SymPy `Symbol` objects. `Symbol` objects will be converted into TeX strings, e.g. Symbol("mu") will be converted to "\mu". Strings must be given in the same format as SymPy's `symbols()` function, that is, a space- or comma-separated list of one or more letters or TeX codes. For example, "mu nu" will be converted to two strings, "\mu" and "\nu". The list will splice in any symbols, even if two or more symbols are defined together. For example, if `a` is a SymPy `Symbol` and the input is `(a, "b", "c d")`, the output will be `("a", "b", "c", "d")`.
        """
        return self._index_letters

    @index_letters.setter
    def index_letters(
        self: Self,
        value: list[str | s.Symbol],
    ) -> None:
        _check_type(value, list, "The index letters must be a list of one or more strings or SymPy `Symbol` objects.")
        self._index_letters = _collect_tex_symbols(*value)

    @index_letters.deleter
    def index_letters(
        self: Self,
    ) -> None:
        self._index_letters = Options._index_letters_default[:]

    ### simplify_func property ###

    @property
    def simplify_func(
        self: Self,
    ) -> Callable[..., Any]:
        r"""
        The function to use when simplifying expressions. OGRePy will simplify expressions by calling `simplify_func(expression)`.
        """
        return self._simplify_func

    @simplify_func.setter
    def simplify_func(
        self: Self,
        value: Callable[..., Any],
    ) -> None:
        if not callable(value):
            _handle_error("The function must be a callable object.")
        self._simplify_func = value

    @simplify_func.deleter
    def simplify_func(
        self: Self,
    ) -> None:
        self._simplify_func = Options._simplify_func_default


options = Options()


################
# Type aliases #
################


# An all-encompassing type for both SymPy and pure Python numbers. Defined mainly because for some reason Pyright seems to think 1 is not an instance of `Number`.
type AnyNumber = s.Number | Number | int | float | complex

# A dictionary specifying the transformation rules between coordinate systems. The dictionary's keys must be SymPy `Symbol` objects corresponding to the source coordinate symbols (however, the type annotation actually uses `Basic` because that's the type inferred when getting an element of `Array`). The dictionary's values must be SymPy `Expr` objects corresponding to the values each source coordinate transforms to in the target coordinates.
type CoordTransformation = dict[s.Basic, s.Expr]

# A tuple of integers specifying the index configuration of a tensor representation. Each integer in the tuple can be either +1 for an upper index or -1 for a lower index. We use a tuple here even though the container is homogenous because the index configurations serve as dictionary keys.
type IndexConfiguration = tuple[Literal[+1, -1], ...]

# A list of zero or more TeX strings specifying the index letters.
type IndexSpecification = list[str]

# A tuple of the form `(tensor, [index1, index2, ...])` where `tensor` is a SymPy array containing the components of a tensor and `[index1, index2, ...]` is a list of zero or more TeX strings to use as index letters.
type TensorWithIndexSpecification = tuple[s.Array, IndexSpecification]

# The tensor's components are stored as values in a dictionary, where the keys are tuples of the form (indices, coords).
type Components = dict[tuple[IndexConfiguration, Coordinates], s.Array]
