Creating a custom unary operator within ufl

Dear community,

I would like to create a custom unary operator, similar to the MathFunction module within ufl that respects dolfinx.fem.form and with a custom behaviour under ufl.derivative.

What I would like to do is to define a custom grad_z operator which is evaluated to zero for a linear form F(u_0) and it is evaluated to 1j *k * u for its derivative. That is, if we consider for instance the linear form

w = dolfinx.fem.Function(V)
v = ufl.TestFunction(V)
u = ufl.TrialFunction(V)
a_form = ufl.inner(grad_z(w,k=2.0), v) * ufl.dx
# This should be equivalent to ufl.inner(0 * w, v) * ufl.dx
J_form = ufl.derivative(a_form, w, u) # This should be equivalent to ufl.inner(1j  * 2 * u, v)

I’ve seen that there is the package dolfinx-external-operator, however, it would be better if I could do it within dolfinx+python modifications. I have tried to create custom Operators inheriting from ufl.core.operator.Operator or other differential operators but with no success (most of my attempts did not compile under dolfinx.fem.form). Do you have any ideas / advices?

Thank you in advance!

I would suggest you add your attempt here as well, to make life easier for others that would like to help you.

To allow differentiation, you need to register a differentiation rule, similar to ufl/ufl/algorithms/apply_derivatives.py at main · FEniCS/ufl · GitHub

Thank you very much. I am currently using dolfinx 0.9.0 and ufl 2024.2.0, so I do use the latest modifications with DAGTransverser.

My latest attempt, based on your suggestion is:

import dolfinx
import ufl
from ufl import diff, grad, dx, TrialFunction, TestFunction
from dolfinx import fem, mesh
from mpi4py import MPI
import numpy as np

# Create mesh and function space
domain = mesh.create_unit_square(MPI.COMM_WORLD, 10, 10)
V = fem.functionspace(domain, ("Lagrange", 1))

# Usage
u = TrialFunction(V)
v = TestFunction(V)
w = dolfinx.fem.Function(V)
q_guess = ufl.as_vector([2.0])
w.interpolate(dolfinx.fem.Expression(q_guess, V.element.interpolation_points(),comm=domain.comm))

def dz(f, k):
    f = ufl.as_ufl(f)
    return 0.0 * f
def dz_der(self, o, df):
    """Differentiate a dz."""
    f, k = o.ufl_operands
    return k * df
# UFL will automatically use _ufl_derivative_ method
from ufl.algorithms.apply_derivatives import GenericDerivativeRuleset
GenericDerivativeRuleset.dz = dz_der

a_form = ufl.inner(dz(w, 2.0), v) * ufl.dx
F = dolfinx.fem.form(a_form)
J_form = ufl.derivative(a_form, w, u)
J = dolfinx.fem.form(J_form)

However, doing so, within a_form it raises:

  File "/lib/python3.10/site-packages/ufl/measure.py", line 422, in __rmul__
    raise ValueError("This integral is missing an integration domain.")
ValueError: This integral is missing an integration domain.

because dz(w, k) is ufl.Zero. In addition if I would have defined

def dz(f,k):
    f = ufl.as_ufl(f)
    return 1e-30 * f

: , J_form = ufl.derivative(a_form, w, u) is not the derivative I would like but

'{ d/dfj { 1e-30 * f * (conj((v_0))) }, with fh=ExprList(*(f,)), dfh/dfj = ExprList(*(v_1,)), and coefficient derivatives ExprMapping(*()) } * dx(<Mesh #0>[everywhere], {})'

which I have confirmed that is not the behaviour I wanted by evaluating:

a_form = ufl.inner(dz(w, 2.0), w) * ufl.dx
J_form = ufl.derivative(a_form, w, u)
J_form = ufl.action(J_form, w)
J = dolfinx.fem.form(J_form)
print(dolfinx.fem.assemble_scalar(J))

which gives 8e-30. The desired behaviour would give something of order unity

Thanks, for the help.

Here is a function that does what you want:

from mpi4py import MPI
import basix.ufl
import ufl
import dolfinx.fem
import numpy as np
from ufl.constantvalue import as_ufl, Zero
from ufl.core.operator import Operator
from ufl.core.ufl_type import ufl_type
from ufl.algorithms.apply_derivatives import GenericDerivativeRuleset
from ufl.corealg.dag_traverser import DAGTraverser

@ufl_type(num_ops=1, inherit_shape_from_operand=0, inherit_indices_from_operand=0)
class DZ(Operator):
    """Custom operator."""

    __slots__ = ("_z", "_f")


    def __init__(self, f, z):
        """Create a custom operator."""
        self._f = as_ufl(f)
        self._z = as_ufl(z)
    
    def __str__(self):
        return f"dz({self._f}, {self._z})"

    @property
    def ufl_operands(self):
        return (self._f, self._z)

    @property
    def _hash(self)->int:
        return hash(("dz", self._f,self._z))

    def __hash__(self)-> int:
        return self._hash()


def dz(f, z):
    """Create a custom operator."""
    return DZ(f, z)

@DAGTraverser.postorder
def custom_derivative(self, o, df, dk):
    f, k, = o.ufl_operands
    return k*df

GenericDerivativeRuleset.process.register(DZ, custom_derivative)


domain = dolfinx.mesh.create_unit_interval(MPI.COMM_WORLD, 10)

s_el = basix.ufl.element("Lagrange", basix.CellType.interval, 2)
V = dolfinx.fem.functionspace(domain, s_el)
u = dolfinx.fem.Function(V)
u.interpolate(lambda x: x[0])

q = dz(u, 2.0)
a = q*ufl.dx(domain=domain)

du = ufl.TestFunction(V)
J = ufl.derivative(a, u, du)

J_compiled = dolfinx.fem.form(J)

vec = dolfinx.fem.assemble_vector(J_compiled)

ref = dolfinx.fem.assemble_vector(dolfinx.fem.form(2*du*ufl.dx(domain=domain)))

np.testing.assert_allclose(vec.array, ref.array)

please note that you should never try to assemlble the a form, as you know it is zero, and there is no easy way of ensuring that this gets passed correctly through ufl (as ufl will automatically remove zero values, before we get to differentiate).

I am not really sure what you what this operation for, as it seems rather strange to have a zero-operator whose derivative is non-zero.

Thank you very much dokken!

A naive question in __slots__ does the order of the arguments matter? Because in your answer, the arguments are in the opposite order to the __init__ or ufl_operands.

In addition, there were several issues with the Operator DZ, when trying to compile dolfinx.fem.form(a). I solved that by including a handle for ffcx and a lambda function.

I leave here the complete minimal example


import dolfinx
from mpi4py import MPI
import basix.ufl
import ufl
import dolfinx.fem
import numpy as np
from ufl.constantvalue import as_ufl, Zero
from ufl.core.operator import Operator
from ufl.core.ufl_type import ufl_type
from ufl.algorithms.apply_derivatives import GenericDerivativeRuleset
from ufl.corealg.dag_traverser import DAGTraverser
from ffcx.ir.analysis import reconstruct
from ufl.constantvalue import (
    ComplexValue,
    ConstantValue,
    FloatValue,
    IntValue,
    RealValue,
    Zero,
    as_ufl,
    is_true_ufl_scalar,
)
from ffcx.codegeneration import lnodes


@ufl_type(num_ops=1, inherit_shape_from_operand=0, inherit_indices_from_operand=0)
class DZ(Operator):
    """Custom operator."""
    __slots__ = ("_f", "_k")
    
    def __init__(self, f, k):
        """Create a custom operator."""
        self._f = as_ufl(f)
        self._k = as_ufl(k)

    @property
    def ufl_operands(self):
        return (self._f, self._k)

    @property
    def _hash(self)->int:
        return hash(("dz", self._f,self._k))

    def __hash__(self)-> int:
        return self._hash()


def dz(f, k):
    """Create a custom operator."""
    k = as_ufl(k)
    f = as_ufl(f)
    return DZ(f, k)

@DAGTraverser.postorder
def custom_derivative(self, o, df, dk):
    f, k, = o.ufl_operands
    return k*df

def handle_dz(o, ops):
    """Handle a binary operator."""
    if len(ops) != 2:
        raise RuntimeError("Expecting two operands.")
    return [o._ufl_expr_reconstruct_(a, b) for a, b in zip(ops[0], ops[1])]

# Need to register the derivative, the reconstruction handle for ffcx and the lambda function (lnodes)
lnodes._ufl_call_lookup[DZ] = lambda x, a, b: 0.0 * a
reconstruct._reconstruct_call_lookup[DZ] = handle_dz
GenericDerivativeRuleset.process.register(DZ, custom_derivative)
domain = dolfinx.mesh.create_unit_interval(MPI.COMM_WORLD, 10)

s_el = basix.ufl.element("Lagrange", basix.CellType.interval, 2, shape=(4,))
V = dolfinx.fem.functionspace(domain, s_el)
u = dolfinx.fem.Function(V)
u.interpolate(lambda x: [x[0], x[0], x[1], x[1]])

q = dz(u[0], 2.0)
a = q*ufl.dx(domain=domain)
F = dolfinx.fem.form(a)
f = dolfinx.fem.assemble_scalar(F)

du = ufl.TestFunction(V)
J = ufl.derivative(a, u, du)
J_compiled = dolfinx.fem.form(J)
vec = dolfinx.fem.assemble_vector(J_compiled)
ref = dolfinx.fem.assemble_vector(dolfinx.fem.form(2*du[0]*ufl.dx(domain=domain)))

np.testing.assert_allclose(vec.array, ref.array)
np.testing.assert_allclose(f, 0.0)

Thanks!

The motivation for the definition of a new ufl operator it was to account for the Fourier derivative of the linearization of nonlinear equations that are homogeneous in the z direction. For instance, if we consider a 2D field f_0(x,y) but we want to analyse a linearization of this field

f(x,y,z) \mapsto f_0(x,y) + \epsilon f'(x,y) \exp (i \beta z) for \beta \in \mathbb{R} and \epsilon \ll 1,

it induces a slow derivative \partial_z f \mapsto i \beta f'. However, the evaluation of \partial_z f_0 \mapsto 0.

It shouldn’t matter

Good that you found a solution, I didn’t have the time to start patching/attaching data to ffcx handlers.