Divergence theorem (without FEniCSx)

This demo is implemented in demo_div_thm_no_fenicsx.py and solves the same problem as the Divergence theorem demo but without using FEniCSx features. It uses only low-level interfaces to the C++ component of QUGaR.

This demo covers:

  • How to create an unfitted domain described by an implicit function.

  • How to generate custom quadrature rules for cells, facets, and unfitted boundaries, and access the generated points, weights, and normals.

all of them using only the C++ component of QUGaR.

Theorem and problem definition

This demo follows closely the problem in Divergence theorem demo: to compute and verify the volumetric and surface integrals of the divergence theorem for an immersed domain. So, be sure to first read the Theorem and problem definition section of that demo for a detailed explanation of the problem and the basic concepts of unfitted domains.

The problem setup is also the same as the one in the Divergence theorem demo. Namely:

  • \(\Omega^\ast = [0,1]^d\) (with \(d=2,3\)) (a square in 2D, a cube in 3D)

  • \(\mathcal{T}\) is created with \(16\times16\) quadrangles in 2D, and and \(16\times16\times16\) hexahedra in 3D.

  • \(\Omega=\left\lbrace x\in\Omega^\ast: \lVert x-x_0\rVert < R\right\rbrace\) (a disk in 2D, a sphere in 3D), with

    • \(x_0 = (-0.25, 0.15)\) in 2D, and \(x_0 = (0.25, 0.35, 0.45)\) in 3D

    • \(R=0.6\) in 2D, and \(R=0.7\) in 3D

  • \(\mathbf{F} = \left(\sin(x),\cos(y)\right)\) in 2D, and \(\mathbf{F} = \left(\sin(x),\cos(y),\sin(z)\right)\) in 3D.

Implementation

First we import the needed modules and functions.

from typing import Callable, Tuple, TypeAlias

import numpy as np
import numpy.typing as npt

import qugar
import qugar.cpp

and declare some handy type aliases.

ImplicitFunc: TypeAlias = qugar.cpp.ImplicitFunc_2D | qugar.cpp.ImplicitFunc_3D
UnfittedDomain: TypeAlias = qugar.cpp.UnfittedImplDomain_2D | qugar.cpp.UnfittedImplDomain_3D
ReparamMesh: TypeAlias = (
    qugar.cpp.ReparamMesh_1_2
    | qugar.cpp.ReparamMesh_2_2
    | qugar.cpp.ReparamMesh_2_3
    | qugar.cpp.ReparamMesh_3_3
)
Func = Callable[[npt.NDArray[np.floating]], npt.NDArray[np.floating]]
DivFunc = Callable[[npt.NDArray[np.floating]], npt.NDArray[np.floating]]

We define the floating point type to be used in the computations. Be aware that the C++ component of QUGaR exclusively uses 64 bits floating point types (doubles).

dtype = np.float64

We define the geometry to be considered: a disk in 2D and a sphere in 3D. Uncomment the one you want to use.

# radius_2D = dtype(0.6)
# center_2D = np.array([-0.25, 0.15], dtype=dtype)
# impl_func = qugar.cpp.create_disk(center=center_2D, radius=radius_2D)
# name = "disk"

radius_3D = dtype(0.7)
center_3D = np.array([0.25, 0.35, 0.45], dtype=dtype)
impl_func = qugar.cpp.create_sphere(center=center_3D, radius=radius_3D)
name = "sphere"

print(f"{name.capitalize()} - divergence theorem verification")

and also the number of quadrature points per direction to be used in each integration.

n_quad_pts = 5

We create a Cartesian grid (corresponding to \(\mathcal{T}\)) in which we will embed the domain \(\Omega\).

dim = impl_func.dim
n_cells = [16] * dim
cell_breaks = [np.linspace(0.0, 1.0, n_cells[dir] + 1, dtype=dtype) for dir in range(dim)]

grid = qugar.cpp.create_cart_grid(cell_breaks)

and then create an unfitted domain that stores the partition \(\mathcal{T}=\mathcal{T}_{\text{cut}}\cup\mathcal{T}_{\text{full}}\cup\mathcal{T}_{\text{empty}}\).

unf_domain = qugar.cpp.create_unfitted_impl_domain(impl_func, grid)

We create functions for evaluating the vector function \(\mathbf{F}\) and \(\text{div}\mathbf{F}\).

def F_func(x: npt.NDArray[dtype]) -> npt.NDArray[dtype]:
    vals = np.empty_like(x)
    for i in range(x.shape[1]):
        vals[:, i] = np.sin(x[:, i]) if i % 2 == 0 else np.cos(x[:, i])
    return vals


def divF_func(x: npt.NDArray[dtype]) -> npt.NDArray[dtype]:
    vals = np.zeros(x.shape[0], dtype=x.dtype)
    for i in range(x.shape[1]):
        vals += np.cos(x[:, i]) if i % 2 == 0 else -np.sin(x[:, i])
    return vals

Auxiliar functions.

Before computing the integrals, we define some auxiliar functions to help us. Check their docstrings for more information.

def scale_points_from_01(
    domain: npt.NDArray[dtype], points: npt.NDArray[dtype]
) -> npt.NDArray[dtype]:
    """
    Scales points from the unit interval [0, 1] to a specified domain.

    Args:
        domain (npt.NDArray[dtype]): A 2D array where each row represents the
            [min, max] range for each dimension.
        points (npt.NDArray[dtype]): A 2D array of points to be scaled, where
            each row is a point and each column corresponds to a dimension.

    Returns:
        npt.NDArray[dtype]: A 2D array of scaled points.
    """
    dim = points.shape[1]
    scaled_points = np.empty_like(points)
    for dir in range(dim):
        scaled_points[:, dir] = domain[dir, 0] + points[:, dir] * (domain[dir, 1] - domain[dir, 0])
    return scaled_points
def find_facets_on_boundary(
    unf_domain: UnfittedDomain,
    cells: npt.NDArray[np.int32],
    local_faces: npt.NDArray[np.int32],
    local_face_id: int,
) -> npt.NDArray[np.int32]:
    """
    Among the given cells (and associated faces), finds (and returns) the cells whose
    faces are equal to local_face_id.

    Args:
        unf_domain (UnfittedDomain): The unfitted implicit domain object containing the grid.
        cells (npt.NDArray[np.int32]): Array of cell indices.
        facets (npt.NDArray[np.int32]): Array of local face indices corresponding to the cells.
        local_face_id (int): The ID of the local face to find among the cells.

    Returns:
        npt.NDArray[np.int32]: Array of cell indices that have have an associated
        local face id equal to local_face_id.
    """
    cells = cells[np.where(local_faces == local_face_id)]

    grid = unf_domain.grid
    bound_cells = grid.get_boundary_cells(local_face_id)
    indices = np.where(np.isin(cells, bound_cells))[0]
    return cells[indices]
def find_full_facets_on_boundary(
    unf_domain: UnfittedDomain, local_face_id: int
) -> npt.NDArray[np.int32]:
    """
    Find full facets on the boundary of an unfitted implicit domain that have the
    given local facet_id.

    This function retrieves the full facets from the given unfitted implicit domain
    and identifies which of these facets are on the boundary based on the
    provided local face ID.

    Args:
        unf_domain (UnfittedDomain): The unfitted implicit domain object containing
            the grid data.
        local_face_id (int): The local ID of the face to check for boundary status.

    Returns:
        npt.NDArray[np.int32]: An array of cell IDs associated to the facets.
    """
    cells, facets = unf_domain.get_full_facets()
    return find_facets_on_boundary(unf_domain, cells, facets, local_face_id)
def find_cut_facets_on_boundary(
    unf_domain: UnfittedDomain, local_face_id: int
) -> npt.NDArray[np.int32]:
    """
    Find cut facets on the boundary of an unfitted implicit domain that have the
    given local local_face_id.

    This function retrieves the cut facets from the given unfitted implicit domain
    and identifies which of these facets are on the boundary based on the
    provided local face ID.

    Args:
        unf_domain (UnfittedDomain): The unfitted implicit domain object containing
            the grid data.
        local_face_id (int): The local ID of the face to check for boundary status.

    Returns:
        npt.NDArray[np.int32]: An array of cell IDs associated to the facets.
    """
    cells, facets = unf_domain.get_cut_facets()
    return find_facets_on_boundary(unf_domain, cells, facets, local_face_id)
def create_facet_quadrature(
    facet_points: npt.NDArray[dtype],
    facet_weights: npt.NDArray[dtype],
    local_face_id: int,
) -> Tuple[npt.NDArray[dtype], npt.NDArray[dtype], npt.NDArray[dtype]]:
    """
    Create quadrature points, weights, and normals for a given local face id.

    Extends the points from the facet to the higher-dimensional space by adding
    the constant coordinate of the face.
    It also generated the (constant) normal vectors for the quadrature points.

    Note:
        The weights are not modified.

    Args:
        facet_points (npt.NDArray[dtype]): Array of points on the facet.
        facet_weights (npt.NDArray[dtype]): Array of weights for the quadrature points.
        local_face_id (int): Identifier for the facet, used to determine
            the constant direction and side.

    Returns:
        Tuple[npt.NDArray[dtype], npt.NDArray[dtype], npt.NDArray[dtype]]:
            - points: Array of quadrature points in the higher-dimensional space.
            - facet_weights: Array of weights for the quadrature points.
            - normals: Array of normal vectors for the quadrature points.
    """
    facet_dim = facet_points.shape[1]
    dim = facet_dim + 1
    dtype = facet_points.dtype

    const_dir = local_face_id // 2
    side = local_face_id % 2

    points = np.zeros((facet_points.shape[0], dim), dtype=dtype)

    points[:, const_dir] = dtype.type(side)
    local_dir = 0
    for dir in range(dim):
        if dir != const_dir:
            points[:, dir] = facet_points[:, local_dir]
            local_dir += 1

    normals = np.zeros_like(points)
    normals[:, const_dir] = dtype.type(1.0) if side == 1 else dtype.type(-1.0)

    return points, facet_weights, normals
def create_full_facet_quadrature(
    dim: int, local_face_id: int, n_quad_pts: int
) -> Tuple[npt.NDArray[dtype], npt.NDArray[dtype], npt.NDArray[dtype]]:
    """
    Create a full facet quadrature for a given dimension and local face.

    Args:
        dim (int): The dimension of the space.
        local_face_id (int): The identifier of the local face.
        n_quad_pts (int): The number of quadrature points per direction.

    Returns:
        Tuple[npt.NDArray[dtype], npt.NDArray[dtype], npt.NDArray[dtype]]:
            - points: Array of quadrature points in the higher-dimensional space.
            - facet_weights: Array of weights for the quadrature points.
            - normals: Array of normal vectors for the quadrature points.
    """
    quad = qugar.cpp.create_Gauss_quad_01([n_quad_pts] * (dim - 1))
    return create_facet_quadrature(quad.points, quad.weights, local_face_id)

Computing the volumetric integral

We compute now the volumetric integral for the cut and full cells.

So, first, we create a custom quadrature for the cut cells.

cut_cells_quad = qugar.cpp.create_quadrature(unf_domain, unf_domain.get_cut_cells(), n_quad_pts)

and for the full cells we use the standard quadrature rule.

quad_01 = qugar.cpp.create_Gauss_quad_01([n_quad_pts] * dim)

We define a function for computing the contribution of a single cell, either cut or full.

def compute_cell_integr(points_01, weights_01, cell_id):
    domain = grid.get_cell_domain(cell_id)
    weights = weights_01 * domain.volume
    vals = divF_func(scale_points_from_01(domain.as_array(), points_01))
    return np.dot(vals, weights)

Loop over the full cells and compute their contributions

vol_intgr = dtype(0.0)
for cell_id in unf_domain.get_full_cells():
    vol_intgr += compute_cell_integr(quad_01.points, quad_01.weights, cell_id)

and the same for the cut cells (retrieving the associated custom quadrature points and weights for every cell)

pt_id = 0
for cell_id, n_pts in zip(cut_cells_quad.cells, cut_cells_quad.n_pts_per_entity):
    pt_id_1 = pt_id + n_pts

    weights = cut_cells_quad.weights[pt_id:pt_id_1]
    points = cut_cells_quad.points[pt_id:pt_id_1]
    vol_intgr += compute_cell_integr(points, weights, cell_id)

    pt_id = pt_id_1

Computing the surface integral

We compute now the surface integral for the unfitted boundaries (for \(\Gamma_{\text{unf}}\)) and the cut (for \(\mathcal{F}_{\text{cut}}\)) and full facets (for \(\mathcal{F}_{\text{full}}\)).

We define a function for computing the contribution of a single cell (containing an unfitted boundary) or (cut or full) facet.

def compute_srf_integral(points_01, weights_01, normals, cell_id):
    cell_domain = grid.get_cell_domain(cell_id)
    vals = F_func(scale_points_from_01(cell_domain.as_array(), points_01))

    mapped_normals = np.empty_like(normals)
    for dir in range(dim):
        mapped_normals[:, dir] = normals[:, dir] / cell_domain.length(dir)
    scaled_weights = weights_01 * cell_domain.volume * np.linalg.norm(mapped_normals, axis=1)

    return np.dot(np.sum(vals * normals, axis=1), scaled_weights)

We first compute the contribution of the external facets (i.e., \(\Gamma_{\text{ext}}\)). For that purpose we iterate along the dim * 2 faces of the hypercube \(\Omega^\ast\) and compute first the contribution of \(\mathcal{F}_{\text{full}}\)

srf_intgr = dtype(0.0)

for face_id in range(dim * 2):
    full_facets = find_full_facets_on_boundary(unf_domain, face_id)
    points, weights, normals = create_full_facet_quadrature(dim, face_id, n_quad_pts)

    for cell_id in full_facets:
        srf_intgr += compute_srf_integral(points, weights, normals, cell_id)

and then for \(\mathcal{F}_{\text{cut}}\) (that requires a custom quadrature).

for face_id in range(dim * 2):
    cells = find_cut_facets_on_boundary(unf_domain, face_id)
    facets = np.full_like(cells, face_id)
    facet_quad = qugar.cpp.create_facets_quadrature_exterior_integral(
        unf_domain, cells, facets, n_quad_pts
    )

    pt_id = 0
    for cell_id, n_pts in zip(facet_quad.cells, facet_quad.n_pts_per_entity):
        pt_id_1 = pt_id + n_pts

        facet_points = facet_quad.points[pt_id:pt_id_1]
        facet_weights = facet_quad.weights[pt_id:pt_id_1]
        points, weights, normals = create_facet_quadrature(facet_points, facet_weights, face_id)
        srf_intgr += compute_srf_integral(points, weights, normals, cell_id)

        pt_id = pt_id_1

Finally, we compute the contribution of the unfitted boundaries (for \(\Gamma_{\text{unf}}\)).

We compute the needed custom quadrature.

include_facet_unf_bry = True
exclude_ext_bdry = True
unf_bdry_quad = qugar.cpp.create_unfitted_bound_quadrature(
    unf_domain,
    unf_domain.get_cut_cells(),
    n_quad_pts,
    include_facet_unf_bry,
    exclude_ext_bdry,
)

and then iterate along the cells that contain unfitted boundaries (retrieving the associated custom quadrature points, weights, and normals for every cell).

pt_id = 0
for cell_id, n_pts in zip(unf_bdry_quad.cells, unf_bdry_quad.n_pts_per_entity):
    pt_id_1 = pt_id + n_pts

    weights = unf_bdry_quad.weights[pt_id:pt_id_1]
    normals = unf_bdry_quad.normals[pt_id:pt_id_1]
    points = unf_bdry_quad.points[pt_id:pt_id_1]
    srf_intgr += compute_srf_integral(points, weights, normals, cell_id)

    pt_id = pt_id_1

Finally, we compare both integrals

print(f"  - Volumetric integral: {vol_intgr}")
print(f"  - Surface integral: {srf_intgr}")