Source code for pantr.grid._overlay
"""Overlay of two tensor-product grids.
:func:`overlay` takes two :class:`TensorProductGrid` instances and returns a
third whose per-axis breakpoints are the union of both inputs' breakpoints,
restricted to the intersection of their domains. The overlay is the coarsest
tensor-product grid that simultaneously refines both inputs: every overlay cell
is contained in exactly one cell of each input.
This is the background-grid bridge for immersed / unfitted quadrature. When a
B-spline (one knot structure) is integrated over an immersion grid (a different
structure), each overlay cell falls inside one polynomial piece of the B-spline
*and* one immersion cell, so a Bernstein patch can be reused without further
subdivision.
The operation is symmetric -- ``overlay(a, b)`` and ``overlay(b, a)`` produce
the same breakpoints up to floating-point noise -- and is defined for any
``ndim >= 1``.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
import numpy as np
from ..tolerance import get_default
from ._tensor_product_grid import TensorProductGrid
if TYPE_CHECKING:
import numpy.typing as npt
[docs]
def overlay(grid_a: TensorProductGrid, grid_b: TensorProductGrid) -> TensorProductGrid:
"""Return the tensor-product overlay of ``grid_a`` and ``grid_b``.
The overlay's per-axis breakpoints are the sorted union of both inputs'
breakpoint arrays restricted to the intersection of their domains.
Breakpoints closer than the default ``float64`` tolerance
(:func:`pantr.tolerance.get_default`) are merged into one. The result is the
coarsest :class:`TensorProductGrid` that refines both inputs.
Args:
grid_a (TensorProductGrid): First input grid.
grid_b (TensorProductGrid): Second input grid; must share
:attr:`~TensorProductGrid.ndim` with ``grid_a`` and have a non-empty
domain intersection on every axis.
Returns:
TensorProductGrid: The overlay grid.
Raises:
TypeError: If either argument is not a :class:`TensorProductGrid`.
ValueError: If the grids have different
:attr:`~TensorProductGrid.ndim`, or if their domains do not overlap
on some axis (the per-axis intersection is empty or degenerate).
Example:
>>> import numpy.testing as npt
>>> from pantr.grid import uniform_grid, overlay
>>> a = uniform_grid([[0.0, 1.0]], 2)
>>> b = uniform_grid([[0.0, 1.0]], 3)
>>> npt.assert_allclose(
... overlay(a, b).breakpoints[0],
... [0.0, 1/3, 0.5, 2/3, 1.0],
... )
"""
if not isinstance(grid_a, TensorProductGrid) or not isinstance(grid_b, TensorProductGrid):
raise TypeError(
"overlay() requires two TensorProductGrid instances; got "
f"{type(grid_a).__name__!r} and {type(grid_b).__name__!r}."
)
if grid_a.ndim != grid_b.ndim:
raise ValueError(f"overlay(): grids must share ndim; got {grid_a.ndim} vs {grid_b.ndim}.")
atol = get_default(np.float64)
merged: list[npt.NDArray[np.float64]] = []
for d in range(grid_a.ndim):
ba = grid_a.breakpoints[d]
bb = grid_b.breakpoints[d]
lo = max(float(ba[0]), float(bb[0]))
hi = min(float(ba[-1]), float(bb[-1]))
if hi - lo <= atol:
raise ValueError(
f"overlay(): domains do not overlap on axis {d}; grid_a extent "
f"[{ba[0]}, {ba[-1]}] vs grid_b extent [{bb[0]}, {bb[-1]}]."
)
merged.append(_merge_axis_breakpoints(ba, bb, lo, hi, atol))
return TensorProductGrid(merged)
def _merge_axis_breakpoints(
ba: npt.NDArray[np.float64],
bb: npt.NDArray[np.float64],
lo: float,
hi: float,
atol: float,
) -> npt.NDArray[np.float64]:
"""Merge two 1-D breakpoint arrays into their sorted, deduplicated union.
Only breakpoints strictly inside ``(lo, hi)`` participate; the ``lo`` / ``hi``
intersection bounds are always emitted as the first and last entries.
Breakpoints closer than ``atol`` are folded into a single entry.
Args:
ba (npt.NDArray[np.float64]): First input's 1-D breakpoints.
bb (npt.NDArray[np.float64]): Second input's 1-D breakpoints.
lo (float): Lower intersection bound; always emitted first.
hi (float): Upper intersection bound; always emitted last.
atol (float): Absolute tolerance for merging near-coincident entries.
Returns:
npt.NDArray[np.float64]: Strictly increasing ``float64`` array starting
with ``lo`` and ending with ``hi`` (at least two entries).
"""
candidates = np.concatenate(
[
np.asarray([lo, hi], dtype=np.float64),
ba[(ba > lo + atol) & (ba < hi - atol)],
bb[(bb > lo + atol) & (bb < hi - atol)],
]
)
candidates.sort(kind="stable")
keep = np.ones(candidates.shape[0], dtype=bool)
keep[1:] = np.diff(candidates) > atol
merged = candidates[keep]
# Belt-and-suspenders: re-pin lo/hi so any float conversion noise in
# the sort/concatenate path doesn't alter the caller-supplied bounds.
merged[0] = lo
merged[-1] = hi
return np.ascontiguousarray(merged, dtype=np.float64)