Source code for minterpy.core.domain

r"""
This module contains the implementation of the `Domain` class.

The `Domain` class represents a rectangular domain in :math:`m`-dimensional
space and provides utilities for transformation between user-defined domains
and the internal reference domain (currently :math:`[-1, 1]^m`).

Background information
======================

Internal polynomial representations of Minterpy are defined on an internal
reference domain (currently :math:`[-1, 1]^m`).
Users, however, are interested in approximating functions defined on custom
rectangular domains
:math:`\Omega = [a_1, b_1] \times \cdots \times [a_i, b_i] \times \cdots \times [a_m, b_m]`
where :math:`a_i` and :math:`b_i` are the lower and upper bounds of
dimension :math:`i`, respectively.
In other words, the domain :math:`\Omega` is a cartesian product of intervals
:math:`[a_i, b_i]` for :math:`i = 1, \dots, m`.

The transformations between these domains are essential for correct evaluation,
differentiation, and integration of polynomials approximating functions
in the user-defined domains.

More detailed background information can be found in
:doc:`/fundamentals/domain`.

Implementation details
======================

An instance of the `Domain` class consists of domain bounds
as a two-dimensional array of shape ``(m, 2)``, where ``m`` is the spatial
dimension. The first column contains the lower bounds
and the second column the upper bounds across dimensions.
Each row is the interval of each dimension.
The bounds are finite real numbers, and the lower bounds are strictly
smaller than the upper bounds.

The instance of the class provides:

- Coordinate transformations to and from internal domain
- Scaling factors for differentiation from chain rule
- Scaling factors for integration (i.e., the determinant of the Jacobian)
- Domain validation utilities

The transformations are **separable**: each spatial dimension is transformed
independently of the other dimensions via an affine map.

How-To Guides
=============

The relevant section of the :doc:`docs </how-to/domain/index>` contains
several how-to guides related to instances of the `Domain` class illustrating
their main usages and features.

----

"""
import numpy as np

from typing import Optional, Union

from minterpy.global_settings import DEFAULT_DOMAIN, FLOAT_DTYPE
from minterpy.utils.verification import verify_domain_bounds
from minterpy.utils.exceptions import DomainMismatchError

__all__ = ["Domain"]


DEFAULT_ATOL = 1e-12
DEFAULT_RTOL = 1e-9


[docs] class Domain: """A class representing the domain of the polynomials. A domain of a polynomial is the set of all input points for which the polynomials are defined. A domain is defined by the lower and upper bounds per dimension. Currently, only domains with finite bounds are supported. Parameters ---------- bounds : np.ndarray The domain bounds as a 2D array with shape ``(m, 2)``, where ``m`` is the spatial dimension. The first column contains the lower bounds and the second column the upper bounds across dimensions. """ _INTERNAL_BOUNDS: np.ndarray = DEFAULT_DOMAIN def __init__(self, bounds: np.ndarray): # Verify and assign the bounds self._bounds = verify_domain_bounds(bounds) # Assign lazily-evaluated properties self._internal_bounds = None self._is_identity = None # --- Factory methods
[docs] @classmethod def uniform( cls, spatial_dimension: int, lower: float, upper: float, ): r"""Create an instance with uniform bounds. Creates a hyper-rectangular domain :math:`[a, b]^m` where :math:`a` and :math:`b` are the lower and upper bounds for each dimension, respectively. Parameters ---------- spatial_dimension : int The number of dimensions for the space. lower : float The lower bound of each dimension. upper : float The upper bound of each dimension. Returns ------- Domain A new instance of the `Domain` class initialized with uniform bounds, i.e., the same lower and upper bound for all dimensions. """ bounds = np.empty((spatial_dimension, 2), dtype=FLOAT_DTYPE) bounds[:, 0] = lower bounds[:, 1] = upper return cls(bounds)
[docs] @classmethod def identity(cls, spatial_dimension: int): r"""Create an instance with the default internal bounds. The default internal bounds are :math:`[-1, 1]^m`. Parameters ---------- spatial_dimension : int The number of dimensions for the space. Returns ------- Domain A new instance of the `Domain` class initialized with the default internal bounds, i.e., :math:`[-1, 1]^m` where :math:`m` is the spatial dimension. """ internal_bounds = cls._INTERNAL_BOUNDS[np.newaxis, :] bounds = np.repeat(internal_bounds, spatial_dimension, axis=0) return cls(bounds)
# --- Properties (public) @property def bounds(self) -> np.ndarray: """The domain bounds. Return ------ np.ndarray The domain bounds as a 2D array with shape ``(m, 2)``, where ``m`` is the spatial dimension. The first column contains the lower bounds and the second column the upper bounds across dimensions. """ return self._bounds @property def spatial_dimension(self): """Dimension of the domain space. Return ------ int The dimension of the domain space. """ return len(self.bounds) @property def lowers(self) -> np.ndarray: """The lower bounds of the domain. Returns ------- np.ndarray The lower bounds of the domain as a one-dimensional array having shape ``(m, )``. """ return self.bounds[:, 0] @property def uppers(self) -> np.ndarray: """The upper bounds of the domain. Returns ------- np.ndarray The upper bounds of the domain as a one-dimensional array having shape ``(m, )``. """ return self.bounds[:, 1] @property def widths(self) -> np.ndarray: """The widths of the domain. Returns ------- np.ndarray The difference between the upper and lower bounds of the domain as a one-dimensional array having shape ``(m, )``. """ return self.uppers - self.lowers @property def is_identity(self) -> bool: """Check whether the domain is (approx) equal to the internal domain. Returns ------- bool ``True`` if the domain is an identity, ``False`` otherwise. Notes ----- - This check uses numerical tolerances (``DEFAULT_RTOL`` and ``DEFAULT_ATOL``) for robustness against floating-point errors. - When the domain is an identity, transformations and scaling factors computation can usually be skipped. """ if self._is_identity is None: # Get the default tolerances rtol = DEFAULT_RTOL atol = DEFAULT_ATOL lb = np.allclose(self.lowers, self._internal_lowers, rtol, atol) ub = np.allclose(self.uppers, self._internal_uppers, rtol, atol) self._is_identity = bool(lb and ub) return self._is_identity @property def is_uniform(self) -> bool: r"""Check whether the domain is uniform. A uniform domain has the same lower and upper bounds in all dimensions, i.e., the domain has the form :math:`[a, b]^m` for some :math:`a` and :math:`b`. Returns ------- bool ``True`` if the domain is uniform, ``False`` otherwise. Notes ----- - The dimension of a uniform domain can be expanded by extrapolating the bounds of the extra dimension from the bounds of the other dimensions. - A domain of dimension 1 is always uniform by definition. - An identity domain is currently always uniform, but not vice versa. - This check uses numerical tolerances (``DEFAULT_RTOL`` and ``DEFAULT_ATOL``) for robustness against floating-point errors. """ # Get the default tolerances rtol = DEFAULT_RTOL atol = DEFAULT_ATOL # Lower bound condition lb_c = np.allclose(self.lowers, self.lowers[0], rtol=rtol, atol=atol) # Upper bound condition ub_c = np.allclose(self.uppers, self.uppers[0], rtol=rtol, atol=atol) return bool(lb_c and ub_c) @property def internal_bounds(self) -> np.ndarray: """The bounds of the internal domain. Returns ------- np.ndarray The bounds of the internal domain as a 2D array with shape ``(m, 2)``, where ``m`` is the spatial dimension. The first column contains the lower bounds and the second column the upper bounds across dimensions. """ if self._internal_bounds is None: # Note: Use the default hard-coded internal bounds self._internal_bounds = np.repeat( self._INTERNAL_BOUNDS[np.newaxis, :], self.spatial_dimension, axis=0, ) return self._internal_bounds # --- Properties (private) @property def _internal_lowers(self) -> np.ndarray: """The lower bounds of the internal domain. Returns ------- np.ndarray The lower bounds of the internal domain as a one-dimensional array having shape ``(m, )``. """ return self.internal_bounds[:, 0] @property def _internal_uppers(self) -> np.ndarray: """The upper bounds of the internal domain. Returns ------- np.ndarray The upper bounds of the internal domain as a one-dimensional array having shape ``(m, )``. """ return self.internal_bounds[:, 1] @property def _internal_widths(self) -> np.ndarray: """The widths of the internal domain. Returns ------- np.ndarray The difference between the upper and lower bounds of the internal domain as a one-dimensional array having shape ``(m, )``. """ return self._internal_uppers - self._internal_lowers # --- Instance methods
[docs] def map_to_internal( self, xx: np.ndarray, validate: bool = False, ) -> np.ndarray: r"""Map input points to the internal domain. The default internal domain is :math:`[-1, 1]^m`. Parameters ---------- xx : :class:`numpy:numpy.ndarray` The input points to be mapped to the internal domain. validate : bool, optional If True, validate that input points are within domain bounds, i.e., no extrapolation is allowed. Returns ------- :class:`numpy:numpy.ndarray` The points in the internal domain, except when extrapolated (with ``validate=False``). """ if validate and not np.all(self.contains(xx)): raise ValueError( "Input points are outside of domain bounds. " "Set validate=False to allow extrapolation." ) ilb = self._internal_lowers iwidths = self._internal_widths return ilb + iwidths * (xx - self.lowers) / self.widths
[docs] def map_from_internal( self, xx: np.ndarray, validate: bool = False, ) -> np.ndarray: r"""Map points in the internal domain to the original domain. The default internal domain is :math:`[-1, 1]^m`. Parameters ---------- xx : :class:`numpy:numpy.ndarray` The input points in the internal domain to be mapped to the original domain. validate : bool, optional If True, validate that input points are within domain bounds, i.e., no extrapolation is allowed. Returns ------- :class:`numpy:numpy.ndarray` The points in the original domain. """ if validate and not np.all(self.contains(xx, internal=True)): raise ValueError( "Input points are outside of the internal domain. " "Set validate=False to allow extrapolation." ) ilb = self._internal_lowers iwidths = self._internal_widths return self.lowers + (xx - ilb) / iwidths * self.widths
[docs] def int_factor(self) -> float: """Compute the scaling factor for polynomial integration. Returns ------- float The scaling factor for polynomial integration. Notes ----- - Here, we assume that the integration is carried out over all dimensions. """ if self.is_identity: return 1.0 return float(np.prod(self.widths / self._internal_widths))
[docs] def diff_factor(self, order: np.ndarray) -> float: """Compute the scaling factor for polynomial differentiation. Parameters ---------- order : :class:`numpy:numpy.ndarray` A one-dimensional integer array specifying the order of derivatives along each dimension. The length of the array must be ``m`` where ``m`` is the spatial dimension of the polynomial. Returns ------- float The scaling factor for polynomial differentiation. """ if self.is_identity: return 1.0 if np.all(order == 0): return 1.0 idx = order > 0 iwidths = self._internal_widths return float(np.prod((iwidths[idx] / self.widths[idx])**(order[idx])))
[docs] def partial_matching( self, other: "Domain", rtol: Optional[float] = None, atol: Optional[float] = None, ) -> bool: """Compare two instances of domain up to the common dimension. This method performs approximate equality checking between two domains using numerical tolerances, comparing only up to the minimum spatial dimension of the two domains. Both user-defined bounds and internal reference domain bounds are compared. This is useful for checking the compatibility of domains of different dimensions before, e.g., merging. Parameters ---------- other : Domain An instance of :class:`Domain` that is to be compared with the current instance. rtol : float, optional The relative tolerance parameter. If not specified, the module-level default ``DEFAULT_RTOL`` is used. atol : float, optional The absolute tolerance parameter. If not specified, the module-level default ``DEFAULT_ATOL`` is used. Returns ------- bool ``True`` if the two instances match up to the common dimension, ``False`` otherwise. """ # Get the default tolerances rtol = DEFAULT_RTOL if rtol is None else rtol atol = DEFAULT_ATOL if atol is None else atol dim = min(self.spatial_dimension, other.spatial_dimension) # "User" bounds bounds_match = np.allclose( self.bounds[:dim, :], other.bounds[:dim, :], rtol=rtol, atol=atol, ) # Check internal bounds (currently identical for all instances, # but enables future generalization of an internal coordinate system) internal_bounds_match = np.allclose( self.internal_bounds[:dim, :], other.internal_bounds[:dim, :], rtol=rtol, atol=atol, ) return bool(bounds_match and internal_bounds_match)
[docs] def contains(self, xx: np.ndarray, internal: bool = False) -> np.ndarray: """Check whether the input points are contained in the domain. Parameters ---------- xx : :class:`numpy:numpy.ndarray` A set of values to be checked. internal : bool, optional Check against internal bounds instead of the domain bounds. The default is ``False``. Returns ------- :class:`numpy:numpy.ndarray` A boolean array indicating whether each point is contained in the domain. """ if internal: lb = self._internal_lowers ub = self._internal_uppers else: lb = self.lowers ub = self.uppers return np.all(lb <= xx, axis=1) & np.all(xx <= ub, axis=1)
[docs] def expand_dim(self, target: Union[int, "Domain"]) -> "Domain": """Expand the dimension of the domain. Parameters ---------- target : Union[int, Domain] The target dimension to expand to. If an integer, it specifies the new dimension. If a Domain instance, it specifies the domain whose dimension to expand to. Returns ------- Domain A new instance of the :class:`Domain` class with expanded dimension. Raises ------ ValueError If the target dimension is smaller than the current dimension or an expansion to a target integer is attempted for a non-identity domain. DomainMismatchError If the target domain does not match the current domain. TypeError If the target is not an instance of Domain or int. Notes ----- - If the target domain is the current instance, the current instance is returned. """ if isinstance(target, int): if target < self.spatial_dimension: raise ValueError( f"Target dimension {target} cannot be smaller than " f"the current dimension {self.spatial_dimension}" ) if not self.is_uniform: raise ValueError( "Non-uniform domain cannot be expanded due to ambiguous " "bounds for the extra dimension." ) lb = self.lowers[0] ub = self.uppers[0] return self.__class__.uniform(target, lb, ub) if isinstance(target, Domain): if not self.partial_matching(target): raise DomainMismatchError( "Target domain does not match the current domain" ) # If there's no need to expand, return the current instance if self is target: return self if target.spatial_dimension < self.spatial_dimension: raise ValueError( f"Target dimension {target.spatial_dimension} cannot be " "smaller than the current dimension " f"{self.spatial_dimension}" ) return self.__class__(target.bounds.copy()) raise TypeError( "Target domain must be an instance of Domain or int, " f"got {type(target)} instead" )
# --- Dunder methods
[docs] def __eq__(self, other: "Domain") -> bool: """Compare two instances of Domain for exact equality in value. Two instances of :class:`Domain` class are considered equal in value if and only if their underlying bounds arrays are exactly equal. In other words, this is a strict comparison without any numerical tolerance. Parameters ---------- other : Domain An instance of :class:`Domain` that is to be compared with the current instance. Returns ------- bool ``True`` if the two instances are equal in value, ``False`` otherwise. """ if not isinstance(other, Domain): return False bounds_eq = np.array_equal(self.bounds, other.bounds) internal_bounds_eq = np.array_equal( self.internal_bounds, other.internal_bounds, ) return bool(bounds_eq and internal_bounds_eq)
[docs] def __or__(self, other: "Domain") -> "Domain": """Combine two instances of Domain via the ``|`` operator. Two instances of Domain may be combined via the ``|`` operator if they are partially matched. Parameters ---------- other : Domain An instance of :class:`Domain` that is to be combined with the current instance. Returns ------- Domain A new instance of :class:`Domain` that is the result of the combination of the two instances. Raises ------ DomainMismatchError If the two domains are not partially matched. """ if self.spatial_dimension > other.spatial_dimension: return other.expand_dim(self) return self.expand_dim(other)