diff --git a/firedrake/assemble.py b/firedrake/assemble.py index 497e596edf..2c3707cb74 100644 --- a/firedrake/assemble.py +++ b/firedrake/assemble.py @@ -18,7 +18,6 @@ import finat.ufl from firedrake import (extrusion_utils as eutils, matrix, parameters, solving, tsfc_interface, utils) -from firedrake.formmanipulation import split_form from firedrake.adjoint_utils import annotate_assemble from firedrake.ufl_expr import extract_unique_domain from firedrake.bcs import DirichletBC, EquationBC, EquationBCSplit @@ -570,36 +569,9 @@ def base_form_assembly_visitor(self, expr, tensor, *args): rank = len(expr.arguments()) if rank > 2: raise ValueError("Cannot assemble an Interpolate with more than two arguments") - # If argument numbers have been swapped => Adjoint. - arg_operand = ufl.algorithms.extract_arguments(operand) - is_adjoint = (arg_operand and arg_operand[0].number() == 0) - # Get the target space V = v.function_space().dual() - # Dual interpolation from mixed source - if is_adjoint and len(V) > 1: - cur = 0 - sub_operands = [] - components = numpy.reshape(operand, (-1,)) - for Vi in V: - sub_operands.append(ufl.as_tensor(components[cur:cur+Vi.value_size].reshape(Vi.value_shape))) - cur += Vi.value_size - - # Component-split of the primal operands interpolated into the dual argument-split - split_interp = sum(reconstruct_interp(sub_operands[i], v=vi) for (i,), vi in split_form(v)) - return assemble(split_interp, tensor=tensor) - - # Dual interpolation into mixed target - if is_adjoint and len(arg_operand[0].function_space()) > 1 and rank == 1: - V = arg_operand[0].function_space() - tensor = tensor or firedrake.Cofunction(V.dual()) - - # Argument-split of the Interpolate gets assembled into the corresponding sub-tensor - for (i,), sub_interp in split_form(expr): - assemble(sub_interp, tensor=tensor.subfunctions[i]) - return tensor - # Get the interpolator interp_data = expr.interp_data.copy() default_missing_val = interp_data.pop('default_missing_val', None) diff --git a/firedrake/formmanipulation.py b/firedrake/formmanipulation.py index c24bda8cc1..eb830493e1 100644 --- a/firedrake/formmanipulation.py +++ b/firedrake/formmanipulation.py @@ -2,7 +2,7 @@ import numpy import collections -from ufl import as_vector, split +from ufl import as_tensor, as_vector, split from ufl.classes import Zero, FixedIndex, ListTensor, ZeroBaseForm from ufl.algorithms.map_integrands import map_integrand_dags from ufl.algorithms import expand_derivatives @@ -14,6 +14,7 @@ from firedrake.petsc import PETSc from firedrake.functionspace import MixedFunctionSpace from firedrake.cofunction import Cofunction +from firedrake.ufl_expr import Coargument from firedrake.matrix import AssembledMatrix @@ -133,6 +134,17 @@ def argument(self, o): args.extend(Zero() for j in numpy.ndindex(V[i].value_shape)) return self._arg_cache.setdefault(o, as_vector(args)) + def coargument(self, o): + V = o.function_space() + + if len(V) == 1: + # Not on a mixed space, just return ourselves. + return o + + indices = self.blocks[o.number()] + W = subspace(V, indices) + return Coargument(W, number=o.number(), part=o.part()) + def cofunction(self, o): V = o.function_space() @@ -171,6 +183,42 @@ def matrix(self, o): bcs = () return AssembledMatrix(tuple(args), bcs, submat) + def zero_base_form(self, o): + return ZeroBaseForm(tuple(map(self, o.arguments()))) + + def interpolate(self, o, operand): + if isinstance(operand, Zero): + return self(ZeroBaseForm(o.arguments())) + + dual_arg, _ = o.argument_slots() + if len(dual_arg.arguments()) == 1 or len(dual_arg.arguments()[-1].function_space()) == 1: + # The dual argument has been contracted or does not need to be split + return o._ufl_expr_reconstruct_(operand, dual_arg) + + if not isinstance(dual_arg, Coargument): + raise NotImplementedError(f"I do not know how to split an Interpolate with a {type(dual_arg).__name__}.") + + indices = self.blocks[dual_arg.number()] + V = dual_arg.function_space() + + # Split the target (dual) argument + sub_dual_arg = self(dual_arg) + W = sub_dual_arg.function_space() + + # Unflatten the expression into the target shape + cur = 0 + components = [] + for i, Vi in enumerate(V): + if i in indices: + components.extend(operand[i] for i in range(cur, cur+Vi.value_size)) + cur += Vi.value_size + + operand = as_tensor(numpy.reshape(components, W.value_shape)) + if isinstance(operand, Zero): + return self(ZeroBaseForm(o.arguments())) + + return o._ufl_expr_reconstruct_(operand, sub_dual_arg) + SplitForm = collections.namedtuple("SplitForm", ["indices", "form"]) diff --git a/firedrake/interpolation.py b/firedrake/interpolation.py index 40f35e18a7..4552cd6ee0 100644 --- a/firedrake/interpolation.py +++ b/firedrake/interpolation.py @@ -262,10 +262,18 @@ class Interpolator(abc.ABC): """ def __new__(cls, expr, V, **kwargs): - if isinstance(expr, ufl.Interpolate): - expr, = expr.ufl_operands + V_target = V if isinstance(V, ufl.FunctionSpace) else V.function_space() + if not isinstance(expr, ufl.Interpolate): + expr = interpolate(expr, V_target) + + arguments = expr.arguments() + has_mixed_arguments = any(len(a.function_space()) > 1 for a in arguments) + if len(arguments) == 2 and has_mixed_arguments: + return object.__new__(MixedInterpolator) + + operand, = expr.ufl_operands target_mesh = as_domain(V) - source_mesh = extract_unique_domain(expr) or target_mesh + source_mesh = extract_unique_domain(operand) or target_mesh submesh_interp_implemented = \ all(isinstance(m.topology, firedrake.mesh.MeshTopology) for m in [target_mesh, source_mesh]) and \ target_mesh.submesh_ancesters[-1] is source_mesh.submesh_ancesters[-1] and \ @@ -275,6 +283,8 @@ def __new__(cls, expr, V, **kwargs): else: if isinstance(target_mesh.topology, VertexOnlyMeshTopology): return object.__new__(SameMeshInterpolator) + elif has_mixed_arguments or len(V_target) > 1: + return object.__new__(MixedInterpolator) else: return object.__new__(CrossMeshInterpolator) @@ -290,8 +300,7 @@ def __init__( matfree: bool = True ): if not isinstance(expr, ufl.Interpolate): - fs = V if isinstance(V, ufl.FunctionSpace) else V.function_space() - expr = interpolate(expr, fs) + expr = interpolate(expr, V if isinstance(V, ufl.FunctionSpace) else V.function_space()) dual_arg, operand = expr.argument_slots() self.ufl_interpolate = expr self.expr = operand @@ -309,9 +318,10 @@ def __init__( target_mesh = as_domain(V) source_mesh = extract_unique_domain(operand) or target_mesh vom_onto_other_vom = ((source_mesh is not target_mesh) + and isinstance(self, SameMeshInterpolator) and isinstance(source_mesh.topology, VertexOnlyMeshTopology) and isinstance(target_mesh.topology, VertexOnlyMeshTopology)) - if not isinstance(self, SameMeshInterpolator) or vom_onto_other_vom: + if isinstance(self, CrossMeshInterpolator) or vom_onto_other_vom: # For bespoke interpolation, we currently rely on different assembly procedures: # 1) Interpolate(Argument(V1, 1), Argument(V2.dual(), 0)) -> Forward operator (2-form) # 2) Interpolate(Argument(V1, 0), Argument(V2.dual(), 1)) -> Adjoint operator (2-form) @@ -383,13 +393,11 @@ def assemble(self, tensor=None, default_missing_val=None): if needs_adjoint: # Out-of-place Hermitian transpose petsc_mat.hermitianTranspose(out=res) - elif res: - petsc_mat.copy(res) + elif tensor: + petsc_mat.copy(tensor.petscmat) else: res = petsc_mat - if tensor is None: - tensor = firedrake.AssembledMatrix(arguments, self.bcs, res) - return tensor + return tensor or firedrake.AssembledMatrix(arguments, self.bcs, res) else: # Assembling the action cofunctions = () @@ -499,8 +507,6 @@ def __init__( self.src_mesh = src_mesh self.dest_mesh = dest_mesh - self.sub_interpolators = [] - # Create a VOM at the nodes of V_dest in src_mesh. We don't include halo # node coordinates because interpolation doesn't usually include halos. # NOTE: it is very important to set redundant=False, otherwise the @@ -508,53 +514,17 @@ def __init__( # QUESTION: Should any of the below have annotation turned off? ufl_scalar_element = V_dest.ufl_element() if isinstance(ufl_scalar_element, finat.ufl.MixedElement): - if all( - ufl_scalar_element.sub_elements[0] == e - for e in ufl_scalar_element.sub_elements - ): - # For a VectorElement or TensorElement the correct - # VectorFunctionSpace equivalent is built from the scalar - # sub-element. - ufl_scalar_element = ufl_scalar_element.sub_elements[0] - if ufl_scalar_element.reference_value_shape != (): - raise NotImplementedError( - "Can't yet cross-mesh interpolate onto function spaces made from VectorElements or TensorElements made from sub elements with value shape other than ()." - ) - else: - # Build and save an interpolator for each sub-element - # separately for MixedFunctionSpaces. NOTE: since we can't have - # expressions for MixedFunctionSpaces we know that the input - # argument ``expr`` must be a Function. V_dest can be a Function - # or a FunctionSpace, and subfunctions works for both. - if self.nargs == 1: - # Arguments don't have a subfunctions property so I have to - # make them myself. NOTE: this will not be correct when we - # start allowing interpolators created from an expression - # with arguments, as opposed to just being the argument. - expr_subfunctions = [ - firedrake.TestFunction(V_src_sub_func) - for V_src_sub_func in self.expr.function_space().subspaces - ] - elif self.nargs > 1: - raise NotImplementedError( - "Can't yet create an interpolator from an expression with multiple arguments." - ) - else: - expr_subfunctions = self.expr.subfunctions - if len(expr_subfunctions) != len(V_dest.subspaces): - raise NotImplementedError( - "Can't interpolate from a non-mixed function space into a mixed function space." - ) - for input_sub_func, target_subspace in zip( - expr_subfunctions, V_dest.subspaces - ): - self.sub_interpolators.append( - interpolate( - input_sub_func, target_subspace, subset=subset, - access=access, allow_missing_dofs=allow_missing_dofs - ) - ) - return + if type(ufl_scalar_element) is finat.ufl.MixedElement: + raise TypeError("Interpolation matrix with MixedFunctionSpace requires MixedInterpolator") + + # For a VectorElement or TensorElement the correct + # VectorFunctionSpace equivalent is built from the scalar + # sub-element. + ufl_scalar_element, = set(ufl_scalar_element.sub_elements) + if ufl_scalar_element.reference_value_shape != (): + raise NotImplementedError( + "Can't yet cross-mesh interpolate onto function spaces made from VectorElements or TensorElements made from sub elements with value shape other than ()." + ) from firedrake.assemble import assemble V_dest_vec = firedrake.VectorFunctionSpace(dest_mesh, ufl_scalar_element) @@ -665,21 +635,6 @@ def _interpolate( else: output = firedrake.Function(V_dest) - if len(self.sub_interpolators): - # MixedFunctionSpace case - for sub_interpolate, f_src_sub_func, output_sub_func in zip( - self.sub_interpolators, f_src.subfunctions, output.subfunctions - ): - if f_src is self.expr: - # f_src is already contained in self.point_eval_interpolate, - # so the sub_interpolators are already prepared to interpolate - # without needing to be given a Function - assert not self.nargs - assemble(sub_interpolate, tensor=output_sub_func) - else: - assemble(action(sub_interpolate, f_src_sub_func), tensor=output_sub_func) - return output - if not adjoint: if f_src is self.expr: # f_src is already contained in self.point_eval_interpolate @@ -911,10 +866,10 @@ def make_interpolator(expr, V, subset, access, bcs=None, matfree=True): Vrow = arguments[0].function_space() Vcol = arguments[1].function_space() if len(Vrow) > 1 or len(Vcol) > 1: - raise NotImplementedError("Interpolation of mixed expressions with arguments is not supported") + raise TypeError("Interpolation matrix with MixedFunctionSpace requires MixedInterpolator") if isinstance(target_mesh.topology, VertexOnlyMeshTopology) and target_mesh is not source_mesh and not vom_onto_other_vom: if not isinstance(target_mesh.topology, VertexOnlyMeshTopology): - raise NotImplementedError("Can only interpolate onto a Vertex Only Mesh") + raise NotImplementedError("Can only interpolate onto a VertexOnlyMesh") if target_mesh.geometric_dimension() != source_mesh.geometric_dimension(): raise ValueError("Cannot interpolate onto a mesh of a different geometric dimension") if not hasattr(target_mesh, "_parent_mesh") or target_mesh._parent_mesh is not source_mesh: @@ -975,37 +930,33 @@ def callable(): return callable else: loops = [] - if len(V) == 1: - expressions = (expr,) - else: - if (hasattr(operand, "subfunctions") and len(operand.subfunctions) == len(V) - and all(sub_op.ufl_shape == Vsub.value_shape for Vsub, sub_op in zip(V, operand.subfunctions))): - # Use subfunctions if they match the target shapes - operands = operand.subfunctions - else: - # Unflatten the expression into the shapes of the mixed components - offset = 0 - operands = [] - for Vsub in V: - if len(Vsub.value_shape) == 0: - operands.append(operand[offset]) - else: - components = [operand[offset + j] for j in range(Vsub.value_size)] - operands.append(ufl.as_tensor(numpy.reshape(components, Vsub.value_shape))) - offset += Vsub.value_size - # Split the dual argument - if isinstance(dual_arg, Cofunction): - duals = dual_arg.subfunctions - elif isinstance(dual_arg, Coargument): - duals = [Coargument(Vsub, number=dual_arg.number()) for Vsub in dual_arg.function_space()] - else: - duals = [v for _, v in sorted(firedrake.formmanipulation.split_form(dual_arg))] - expressions = map(expr._ufl_expr_reconstruct_, operands, duals) + if access == op2.INC: + loops.append(tensor.zero) + + # Arguments in the operand are allowed to be from a MixedFunctionSpace + # We need to split the target space V and generate separate kernels + if len(arguments) == 2: + # Matrix case assumes that the spaces are not mixed + expressions = {(0,): expr} + elif isinstance(dual_arg, Coargument): + # Split in the coargument + expressions = dict(firedrake.formmanipulation.split_form(expr)) + else: + # Split in the cofunction: split_form can only split in the coargument + # Replace the cofunction with a coargument to construct the Jacobian + interp = expr._ufl_expr_reconstruct_(operand, V) + # Split the Jacobian into blocks + interp_split = dict(firedrake.formmanipulation.split_form(interp)) + # Split the cofunction + dual_split = dict(firedrake.formmanipulation.split_form(dual_arg)) + # Combine the splits by taking their action + expressions = {i: action(interp_split[i], dual_split[i[-1:]]) for i in interp_split} # Interpolate each sub expression into each function space - for Vsub, sub_tensor, sub_expr in zip(V, tensor, expressions): - loops.extend(_interpolator(Vsub, sub_tensor, sub_expr, subset, arguments, access, bcs=bcs)) + for indices, sub_expr in expressions.items(): + sub_tensor = tensor[indices[0]] if rank == 1 else tensor + loops.extend(_interpolator(sub_tensor, sub_expr, subset, access, bcs=bcs)) if bcs and rank == 1: loops.extend(partial(bc.apply, f) for bc in bcs) @@ -1019,10 +970,24 @@ def callable(loops, f): @utils.known_pyop2_safe -def _interpolator(V, tensor, expr, subset, arguments, access, bcs=None): +def _interpolator(tensor, expr, subset, access, bcs=None): + if isinstance(expr, ufl.ZeroBaseForm): + # Zero simplification, avoid code-generation + if access is op2.INC: + return () + elif access is op2.WRITE: + return (partial(tensor.zero, subset=subset),) + # Unclear how to avoid codegen for MIN and MAX + # Reconstruct the expression as an Interpolate + V = expr.arguments()[-1].function_space().dual() + expr = interpolate(ufl.zero(V.value_shape), V) + if not isinstance(expr, ufl.Interpolate): raise ValueError("Expecting to interpolate a ufl.Interpolate") + + arguments = expr.arguments() dual_arg, operand = expr.argument_slots() + V = dual_arg.arguments()[0].function_space() try: to_element = create_element(V.ufl_element()) @@ -1074,8 +1039,6 @@ def _interpolator(V, tensor, expr, subset, arguments, access, bcs=None): parameters['scalar_type'] = utils.ScalarType callables = () - if access == op2.INC: - callables += (tensor.zero,) # For the matfree adjoint 1-form and the 0-form, the cellwise kernel will add multiple # contributions from the facet DOFs of the dual argument. @@ -1720,3 +1683,94 @@ def _wrap_dummy_mat(self): def duplicate(self, mat=None, op=None): return self._wrap_dummy_mat() + + +class MixedInterpolator(Interpolator): + """A reusable interpolation object between MixedFunctionSpaces. + + Parameters + ---------- + expr + The underlying ufl.Interpolate or the operand to the ufl.Interpolate. + V + The :class:`.FunctionSpace` or :class:`.Function` to + interpolate into. + bcs + A list of boundary conditions. + **kwargs + Any extra kwargs are passed on to the sub Interpolators. + For details see :class:`firedrake.interpolation.Interpolator`. + """ + def __init__(self, expr, V, bcs=None, **kwargs): + super(MixedInterpolator, self).__init__(expr, V, bcs=bcs, **kwargs) + expr = self.ufl_interpolate + self.arguments = expr.arguments() + rank = len(self.arguments) + + # We need a Coargument in order to split the Interpolate + needs_action = len([a for a in self.arguments if isinstance(a, Coargument)]) == 0 + if needs_action: + dual_arg, operand = expr.argument_slots() + # Split the dual argument + dual_split = dict(firedrake.formmanipulation.split_form(dual_arg)) + # Create the Jacobian to be split into blocks + expr = expr._ufl_expr_reconstruct_(operand, V) + + Isub = {} + # Split in the arguments of the Interpolate + for indices, form in firedrake.formmanipulation.split_form(expr): + if isinstance(form, ufl.ZeroBaseForm): + # Ensure block sparsity + continue + vi, _ = form.argument_slots() + Vtarget = vi.function_space().dual() + if bcs and rank != 0: + args = form.arguments() + Vsource = args[1-vi.number()].function_space() + sub_bcs = [bc for bc in bcs if bc.function_space() in {Vsource, Vtarget}] + else: + sub_bcs = None + if needs_action: + # Take the action of each sub-cofunction against each block + form = action(form, dual_split[indices[-1:]]) + + Isub[indices] = Interpolator(form, Vtarget, bcs=sub_bcs, **kwargs) + + self._sub_interpolators = Isub + self.callable = self._assemble_matnest + + def __getitem__(self, item): + return self._sub_interpolators[item] + + def __iter__(self): + return iter(self._sub_interpolators) + + def _assemble_matnest(self): + """Assemble the operator.""" + shape = tuple(len(a.function_space()) for a in self.arguments) + blocks = numpy.full(shape, PETSc.Mat(), dtype=object) + # Assemble the sparse block matrix + for i in self: + blocks[i] = self[i].callable().handle + petscmat = PETSc.Mat().createNest(blocks) + tensor = firedrake.AssembledMatrix(self.arguments, self.bcs, petscmat) + return tensor.M + + def _interpolate(self, *function, output=None, adjoint=False, **kwargs): + """Assemble the action.""" + rank = len(self.arguments) + if rank == 0: + result = sum(self[i].assemble(**kwargs) for i in self) + return output.assign(result) if output else result + + if output is None: + output = firedrake.Function(self.arguments[-1].function_space().dual()) + + if rank == 1: + for k, sub_tensor in enumerate(output.subfunctions): + sub_tensor.assign(sum(self[i].assemble(**kwargs) for i in self if i[0] == k)) + elif rank == 2: + for k, sub_tensor in enumerate(output.subfunctions): + sub_tensor.assign(sum(self[i]._interpolate(*function, adjoint=adjoint, **kwargs) + for i in self if i[0] == k)) + return output diff --git a/pyproject.toml b/pyproject.toml index 8b03f6bcc7..21036a2afc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,7 +20,7 @@ dependencies = [ "mpi4py>3; python_version >= '3.13'", "mpi4py; python_version < '3.13'", # TODO RELEASE: use releases - "fenics-ufl @ git+https://github.com/FEniCS/ufl.git", + "fenics-ufl @ git+https://github.com/FEniCS/ufl.git@main", "firedrake-fiat @ git+https://github.com/firedrakeproject/fiat.git", "h5py>3.12.1", "libsupermesh", diff --git a/tests/firedrake/regression/test_interpolate.py b/tests/firedrake/regression/test_interpolate.py index 8a310e4508..47b3dc7a6d 100644 --- a/tests/firedrake/regression/test_interpolate.py +++ b/tests/firedrake/regression/test_interpolate.py @@ -519,3 +519,50 @@ def test_interpolate_logical_not(): a = assemble(interpolate(conditional(Not(x < .2), 1, 0), V)) b = assemble(interpolate(conditional(x >= .2, 1, 0), V)) assert np.allclose(a.dat.data, b.dat.data) + + +@pytest.mark.parametrize("mode", ("forward", "adjoint")) +def test_mixed_matrix(mode): + nx = 3 + mesh = UnitSquareMesh(nx, nx) + + V1 = VectorFunctionSpace(mesh, "CG", 2) + V2 = FunctionSpace(mesh, "CG", 1) + V3 = FunctionSpace(mesh, "CG", 1) + V4 = FunctionSpace(mesh, "DG", 1) + + Z = V1 * V2 + W = V3 * V3 * V4 + + if mode == "forward": + I = Interpolate(TrialFunction(Z), TestFunction(W.dual())) + a = assemble(I) + assert a.arguments()[0].function_space() == W.dual() + assert a.arguments()[1].function_space() == Z + assert a.petscmat.getSize() == (W.dim(), Z.dim()) + assert a.petscmat.getType() == "nest" + + u = Function(Z) + u.subfunctions[0].sub(0).assign(1) + u.subfunctions[0].sub(1).assign(2) + u.subfunctions[1].assign(3) + result_matfree = assemble(Interpolate(u, TestFunction(W.dual()))) + elif mode == "adjoint": + I = Interpolate(TestFunction(Z), TrialFunction(W.dual())) + a = assemble(I) + assert a.arguments()[1].function_space() == W.dual() + assert a.arguments()[0].function_space() == Z + assert a.petscmat.getSize() == (Z.dim(), W.dim()) + assert a.petscmat.getType() == "nest" + + u = Function(W.dual()) + u.subfunctions[0].assign(1) + u.subfunctions[1].assign(2) + u.subfunctions[2].assign(3) + result_matfree = assemble(Interpolate(TestFunction(Z), u)) + else: + raise ValueError(f"Unrecognized mode {mode}") + + result_explicit = assemble(action(a, u)) + for x, y in zip(result_explicit.subfunctions, result_matfree.subfunctions): + assert np.allclose(x.dat.data, y.dat.data) diff --git a/tests/firedrake/regression/test_interpolate_cross_mesh.py b/tests/firedrake/regression/test_interpolate_cross_mesh.py index 52c1746d74..f6c22e48e8 100644 --- a/tests/firedrake/regression/test_interpolate_cross_mesh.py +++ b/tests/firedrake/regression/test_interpolate_cross_mesh.py @@ -299,12 +299,21 @@ def test_interpolate_unitsquare_mixed(): assert not np.allclose(f_src.dat.data_ro[0], cofunc_src.dat.data_ro[0]) assert not np.allclose(f_src.dat.data_ro[1], cofunc_src.dat.data_ro[1]) - # Can't go from non-mixed to mixed + # Interpolate from non-mixed to mixed V_src_2 = VectorFunctionSpace(m_src, "CG", 1) assert V_src_2.value_shape == V_src.value_shape - f_src_2 = Function(V_src_2) - with pytest.raises(NotImplementedError): - assemble(interpolate(f_src_2, V_dest)) + f_src_2 = Function(V_src_2).interpolate(SpatialCoordinate(m_src)) + result_mixed = assemble(interpolate(f_src_2, V_dest)) + + expected_zero_form = 0 + for i in range(len(V_dest)): + expected = assemble(interpolate(f_src_2[i], V_dest[i])) + assert np.allclose(result_mixed.dat.data_ro[i], expected.dat.data_ro) + + expected_zero_form += assemble(action(cofunc_dest.subfunctions[i], expected)) + + mixed_zero_form = assemble(interpolate(f_src_2, cofunc_dest)) + assert np.isclose(mixed_zero_form, expected_zero_form) @pytest.mark.parallel([1, 3])