Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add FiniteElement python wrapper #3542

Open
wants to merge 23 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions python/dolfinx/fem/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
locate_dofs_topological,
)
from dolfinx.fem.dofmap import DofMap
from dolfinx.fem.element import CoordinateElement, coordinate_element
from dolfinx.fem.element import CoordinateElement, FiniteElement, coordinate_element, finite_element
from dolfinx.fem.forms import (
Form,
compile_form,
Expand Down Expand Up @@ -91,7 +91,11 @@ def create_interpolation_data(
"""
return _PointOwnershipData(
_create_interpolation_data(
V_to.mesh._cpp_object.geometry, V_to.element, V_from.mesh._cpp_object, cells, padding
V_to.mesh._cpp_object.geometry,
V_to.element._cpp_object,
V_from.mesh._cpp_object,
cells,
padding,
)
)

Expand Down Expand Up @@ -169,6 +173,7 @@ def compute_integration_domains(
"DofMap",
"ElementMetaData",
"Expression",
"FiniteElement",
"Form",
"Function",
"FunctionSpace",
Expand All @@ -189,6 +194,7 @@ def compute_integration_domains(
"dirichletbc",
"discrete_gradient",
"extract_function_spaces",
"finite_element",
"form",
"form_cpp_class",
"functionspace",
Expand Down
163 changes: 162 additions & 1 deletion python/dolfinx/fem/element.py
schnellerhase marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Copyright (C) 2024 Garth N. Wells
# Copyright (C) 2024 Garth N. Wells and Paul T. Kühner
#
# This file is part of DOLFINx (https://www.fenicsproject.org)
#
Expand All @@ -12,6 +12,8 @@
import numpy.typing as npt

import basix
import ufl
import ufl.finiteelement
from dolfinx import cpp as _cpp


Expand Down Expand Up @@ -160,3 +162,162 @@ def _(e: basix.finite_element.FiniteElement):
return CoordinateElement(_cpp.fem.CoordinateElement_float32(e._e))
except TypeError:
return CoordinateElement(_cpp.fem.CoordinateElement_float64(e._e))


class FiniteElement:
_cpp_object: typing.Union[_cpp.fem.FiniteElement_float32, _cpp.fem.FiniteElement_float64]

def __init__(self, cpp_object):
"""Creates a Python wrapper for the exported finite element class.

Note:
Do not use this constructor directly. Instead use :func:`finite_element`.

schnellerhase marked this conversation as resolved.
Show resolved Hide resolved
Args:
The underlying cpp instance that this object will wrap.
"""
self._cpp_object = cpp_object

def __eq__(self, other):
return self._cpp_object == other._cpp_object

@property
def dtype(self):
"""Geometry type of the Mesh that the FunctionSpace is defined on."""
return self._cpp_object.dtype

@property
def basix_element(self):
"""Return underlying Basix element (if it exists).

schnellerhase marked this conversation as resolved.
Show resolved Hide resolved
Raises:
Runtime error if Basix element does not exist.
"""
return self._cpp_object.basix_element

@property
def num_sub_elements(self):
"""Number of sub elements (for a mixed or blocked element)."""
return self._cpp_object.num_sub_elements

@property
def value_shape(self):
"""Value shape of the finite element field.

The value shape describes the shape of the finite element field, e.g. `{}` for a scalar,
`{2}` for a vector in 2D, `{3, 3}` for a rank-2 tensor in 3D, etc.

Returns:
The value shape.
"""
return self._cpp_object.value_shape

@property
def interpolation_points(self):
"""Points on the reference cell at which an expression needs to be evaluated in order to
interpolate the expression in the finite element space.

Note:
For Lagrange elements the points will just be the nodal positions. For other elements
the points will typically be the quadrature points used to evaluate moment degrees of
freedom.

Returns:
Interpolation point coordinates on the reference cell, returning the (0) coordinates
data (row-major) storage and (1) the shape `(num_points, tdim)`.
schnellerhase marked this conversation as resolved.
Show resolved Hide resolved
"""
return self._cpp_object.interpolation_points

@property
def interpolation_ident(self):
"""Check if interpolation into the finite element space is an identity operation given the
evaluation on an expression at specific points, i.e. the degree-of-freedom are equal to
point evaluations. The function will return `true` for Lagrange elements.

Returns:
True if interpolation is an identity operation"""
return self._cpp_object.interpolation_ident

@property
def space_dimension(self):
"""Dimension of the finite element function space (the number of degrees-of-freedom for the
element).

For 'blocked' elements, this function returns the dimension of the full element rather than
the dimension of the base element.

Returns:
Dimension of the finite element space.
"""
return self._cpp_object.space_dimension

@property
def needs_dof_transformations(self):
"""Check if DOF transformations are needed for this element.

DOF transformations will be needed for elements which might not be continuous when two
neighbouring cells disagree on the orientation of a shared sub-entity, and when this cannot
be corrected for by permuting the DOF numbering in the dofmap.

For example, Raviart-Thomas elements will need DOF transformations, as the neighbouring
cells may disagree on the orientation of a basis function, and this orientation cannot be
corrected for by permuting the DOF numbers on each cell.

Returns:
True if DOF transformations are required.
"""
return self._cpp_object.needs_dof_transformations

@property
def signature(self):
"""String identifying the finite element.

Returns:
Element signature
schnellerhase marked this conversation as resolved.
Show resolved Hide resolved
"""
return self._cpp_object.signature

def T_apply(self, x, cell_permutations, dim):
"""Transform basis functions from the reference element ordering and orientation to the
globally consistent physical element ordering and orientation.
schnellerhase marked this conversation as resolved.
Show resolved Hide resolved
"""
self._cpp_object.T_apply(x, cell_permutations, dim)

def Tt_apply(self, x, cell_permutations, dim):
"""Apply the transpose of the operator applied by T_apply()."""
self._cpp_object.Tt_apply(x, cell_permutations, dim)

def Tt_inv_apply(self, x, cell_permutations, dim):
"""Apply the inverse transpose of the operator applied by T_apply()."""
self._cpp_object.Tt_apply(x, cell_permutations, dim)


def finite_element(
cell_type: _cpp.mesh.CellType,
ufl_e: ufl.finiteelement,
dtype: np.dtype,
) -> FiniteElement:
"""Create a DOLFINx element from a basix.ufl element."""
if np.issubdtype(dtype, np.float32):
schnellerhase marked this conversation as resolved.
Show resolved Hide resolved
CppElement = _cpp.fem.FiniteElement_float32
elif np.issubdtype(dtype, np.float64):
schnellerhase marked this conversation as resolved.
Show resolved Hide resolved
CppElement = _cpp.fem.FiniteElement_float64
else:
raise ValueError(f"Unsupported dtype: {dtype}")

if ufl_e.is_mixed:
elements = [finite_element(cell_type, e, dtype)._cpp_object for e in ufl_e.sub_elements]
return FiniteElement(CppElement(elements))
elif ufl_e.is_quadrature:
return FiniteElement(
CppElement(
cell_type,
ufl_e.custom_quadrature()[0],
ufl_e.reference_value_shape,
ufl_e.is_symmetric,
)
)
else:
basix_e = ufl_e.basix_element._e
value_shape = ufl_e.reference_value_shape if ufl_e.block_size > 1 else None
return FiniteElement(CppElement(basix_e, value_shape, ufl_e.is_symmetric))
45 changes: 9 additions & 36 deletions python/dolfinx/fem/function.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from dolfinx import cpp as _cpp
from dolfinx import default_scalar_type, jit, la
from dolfinx.fem import dofmap
from dolfinx.fem.element import FiniteElement, finite_element
from dolfinx.geometry import PointOwnershipData

if typing.TYPE_CHECKING:
Expand Down Expand Up @@ -461,7 +462,7 @@ def _(e0: Expression):
# u0 is callable
assert callable(u0)
x = _cpp.fem.interpolation_coords(
self._V.element, self._V.mesh.geometry._cpp_object, cells0
self._V.element._cpp_object, self._V.mesh.geometry._cpp_object, cells0
)
self._cpp_object.interpolate(np.asarray(u0(x), dtype=self.dtype), cells0) # type: ignore

Expand Down Expand Up @@ -560,32 +561,6 @@ class ElementMetaData(typing.NamedTuple):
symmetry: typing.Optional[bool] = None


def _create_dolfinx_element(
cell_type: _cpp.mesh.CellType,
ufl_e: ufl.FiniteElementBase,
dtype: np.dtype,
) -> typing.Union[_cpp.fem.FiniteElement_float32, _cpp.fem.FiniteElement_float64]:
"""Create a DOLFINx element from a basix.ufl element."""
if np.issubdtype(dtype, np.float32):
CppElement = _cpp.fem.FiniteElement_float32
elif np.issubdtype(dtype, np.float64):
CppElement = _cpp.fem.FiniteElement_float64
else:
raise ValueError(f"Unsupported dtype: {dtype}")

if ufl_e.is_mixed:
elements = [_create_dolfinx_element(cell_type, e, dtype) for e in ufl_e.sub_elements]
return CppElement(elements)
elif ufl_e.is_quadrature:
return CppElement(
cell_type, ufl_e.custom_quadrature()[0], ufl_e.reference_value_shape, ufl_e.is_symmetric
)
else:
basix_e = ufl_e.basix_element._e
value_shape = ufl_e.reference_value_shape if ufl_e.block_size > 1 else None
return CppElement(basix_e, value_shape, ufl_e.is_symmetric)


def functionspace(
mesh: Mesh,
element: typing.Union[ufl.FiniteElementBase, ElementMetaData, tuple[str, int, tuple, bool]],
Expand Down Expand Up @@ -614,18 +589,18 @@ def functionspace(
raise ValueError("Non-matching UFL cell and mesh cell shapes.")

# Create DOLFINx objects
cpp_element = _create_dolfinx_element(mesh.topology.cell_type, ufl_e, dtype)
cpp_dofmap = _cpp.fem.create_dofmap(mesh.comm, mesh.topology._cpp_object, cpp_element)
element = finite_element(mesh.topology.cell_type, ufl_e, dtype)
cpp_dofmap = _cpp.fem.create_dofmap(mesh.comm, mesh.topology._cpp_object, element._cpp_object)

assert np.issubdtype(
mesh.geometry.x.dtype, cpp_element.dtype
mesh.geometry.x.dtype, element.dtype
), "Mesh and element dtype are not compatible."

# Initialize the cpp.FunctionSpace
try:
cppV = _cpp.fem.FunctionSpace_float64(mesh._cpp_object, cpp_element, cpp_dofmap)
cppV = _cpp.fem.FunctionSpace_float64(mesh._cpp_object, element._cpp_object, cpp_dofmap)
except TypeError:
cppV = _cpp.fem.FunctionSpace_float32(mesh._cpp_object, cpp_element, cpp_dofmap)
cppV = _cpp.fem.FunctionSpace_float32(mesh._cpp_object, element._cpp_object, cpp_dofmap)

return FunctionSpace(mesh, ufl_e, cppV)

Expand Down Expand Up @@ -746,11 +721,9 @@ def ufl_function_space(self) -> ufl.FunctionSpace:
return self

@property
def element(
self,
) -> typing.Union[_cpp.fem.FiniteElement_float32, _cpp.fem.FiniteElement_float64]:
def element(self) -> FiniteElement:
"""Function space finite element."""
return self._cpp_object.element # type: ignore
return FiniteElement(self._cpp_object.element)
Copy link
Member

@garth-wells garth-wells Nov 30, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This creates a new FiniteElement wrapper each time. Would it be better to wrap the element in the initialiser?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That would require an additional state variable of the wrapper class FunctionSpace keeping track of the FiniteElement, which contradicts it being a wrapper to provide access only to the cpp_object, as we would then have two finite_element's around to manage.

Also the FiniteElement object itself is only a wrapper, it performs no computations or produces additional states, so it's fine to be created whenever we expose a FiniteElement.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems to add exactly what we would need here - however I am not convinced we satisfy the

Useful for expensive computed properties of instances that are otherwise effectively immutable.

part of it. Calling the constructor of FiniteElement is very lightweight and would then compete with the cached lookup.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With non-cached approach calling the constructor each time, won't this break expected outcome of

assert V.element is V.element

?

Copy link
Contributor

@nate-sime nate-sime Dec 3, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another aspect to consider regarding cached_property:

Transform a method of a class into a property whose value is computed once and then cached as a normal attribute for the life of the instance.

In the current implementation, what is the lifespan of all these instantiated FiniteElements?

Copy link
Contributor

@nate-sime nate-sime Dec 3, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As I look through the Python layer, this situation/question crops up a lot. Perhaps this can be addressed in a future PR.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good, will file an issue when this gets merged.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In dolfinx.mesh.Mesh, we store the Python-wrappers: https://github.com/FEniCS/dolfinx/blob/main/python/dolfinx/mesh.py#L275-L278

I guess we need to decide on a unified structure for this.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Returning, possibly many, different instances that wrap the same C++ FiniteElement is clumsy. cached_property looks like a neat way to deal with it.

The issue should be fixed in this PR, not later.


@property
def dofmap(self) -> dofmap.DofMap:
Expand Down
Loading