Custom inequality constraint: Tape and adjoint?

Hello everyone,

I’m using the example from the dolfin-adjoint documentation about topology optimization of the heat conduction problem.
I would like to add a custom inequality constraint to it, but I did not succeed up to now …

The constrained value is a scalar resulting from multiple operations such as the result of a variational form, integral over the domain, etc…
Is there a way to implement such a constraint, with its gradient/jacobian computed with the tools for automatic derivative provided by dolfin-adjoint?

My first tentative and first error are the following:
I tried to use the function UFLInequalityConstraint(), but it returns an error stating:
ValueError: Form must depend on control. It seems like the dependencies on the previous operations are not accounted for. Aren’t they recorded on the Tape?

My second tentative and second error are the following:
I tried to create my own InequalityConstraint class (named CustomConstraint) as in the example but without success either … I get the following error:
AttributeError: 'numpy.ndarray' object has no attribute 'block_variable'

Here is a minimal working example. To have the first or second error appear, please set flag_showError1 or flag_showError2 to True individually:

from fenics import *
from fenics_adjoint import *
import matplotlib.pyplot as plt

try:
    from pyadjoint import ipopt  # noqa: F401
except ImportError:
    print("""This example depends on IPOPT and Python ipopt bindings. \
  When compiling IPOPT, make sure to link against HSL, as it \
  is a necessity for practical problems.""")
    raise

# turn off redundant output in parallel
parameters["std_out_all_processes"] = False

flag_showError1 = False
flag_showError2 = False


Vlim = Constant(0.4)  # volume bound on the control
Qlim = 1e-4
p = Constant(5)  # power used in the solid isotropic material with penalisation (SIMP) rule, to encourage the control solution to attain either 0 or 1
eps = Constant(1.0e-3)  # epsilon used in the solid isotropic material
alpha = Constant(1.0e-8)  # regularisation coefficient in functional


def k(a):
    return eps + (1 - eps) * a ** p


n = 250
mesh = UnitSquareMesh(n, n)
A = FunctionSpace(mesh, "CG", 1)  # function space for control
P = FunctionSpace(mesh, "CG", 1)  # function space for solution

class WestNorth(SubDomain):
    """The top and left boundary of the unitsquare, used to enforce the Dirichlet boundary condition."""
    def inside(self, x, on_boundary):
        return (x[0] == 0.0 or x[1] == 1.0) and on_boundary


bc = [DirichletBC(P, 0.0, WestNorth())] # the Dirichlet BC; the Neumann BC will be implemented implicitly by dropping the surface integral after integration by parts
f = interpolate(Constant(1.0e-2), P)  # the volume source term for the PDE

def forward(a):
    """Solve the forward problem for a given material distribution a(x)."""
    T = Function(P)
    v = TestFunction(P)
    F = inner(grad(v), k(a) * grad(T)) * dx - f * v * dx
    solve(F == 0, T, bc, solver_parameters={"newton_solver": {"absolute_tolerance": 1.0e-7, "maximum_iterations": 20}})
    return T

    
#-------------------------------------------------
class CustomConstraint(InequalityConstraint):
    """A class that enforces the volume constraint g(a) = V - a*dx >= 0."""
    def __init__(self, Qlim):
        self.Qlim = float(Qlim)
        self.tmpvec = Function(A)

    def function(self, m):
        from pyadjoint.reduced_functional_numpy import set_local
        set_local(self.tmpvec, m)
        
        T = forward(self.tmpvec)
        Q = assemble(inner(grad(T),k(self.tmpvec)*grad(T)) * dx)
        return [-self.Qlim + Q]

    def jacobian(self, m):
        from pyadjoint.reduced_functional_numpy import set_local
        set_local(self.tmpvec, m)
        T = forward(self.tmpvec)
        Q = assemble(inner(grad(T),k(self.tmpvec)*grad(T)) * dx)
        dQdm = compute_gradient(Q, m)
        return [dQdm]

    def output_workspace(self):
        return [0.0]

    def length(self):
        """Return the number of components in the constraint vector (here, one)."""
        return 1
#-------------------------------------------------

if __name__ == "__main__":
    a = interpolate(Vlim, A)  # initial guess.
    T = forward(a)  # solve the forward problem once.
    
    J = assemble(f * T * dx + alpha * inner(grad(a), grad(a)) * dx)
    m = Control(a)
    Jhat = ReducedFunctional(J, m)
    
    lb = 0.0; ub = 1.0
    
    volume_constraint = UFLInequalityConstraint((Vlim - a)*dx, m)
    
    if flag_showError1:
        losses_constraint = UFLInequalityConstraint((-Qlim + inner(grad(forward(T)),k(T)*grad(forward(T))))*dx, m)
        problem = MinimizationProblem(Jhat, bounds=(lb, ub), constraints=[volume_constraint,losses_constraint])
    elif flag_showError2:
        problem = MinimizationProblem(Jhat, bounds=(lb, ub), constraints=[volume_constraint,CustomConstraint(Qlim)])
    else:
        problem = MinimizationProblem(Jhat, bounds=(lb, ub), constraints=volume_constraint)


    solver = IPOPTSolver(problem, parameters={"acceptable_tol": 1.0e-3, "maximum_iterations": 100})
    a_opt = solver.solve()
    T_opt = forward(a_opt)
    
    pl, ax = plt.subplots(); fig = plt.gcf(); fig.set_size_inches(16, 4)
    plt.subplot(1, 2, 1); p = plot(a_opt,title='Topology',mode='color',vmin=0,vmax=1.0); p.set_cmap("Greys"); cbar = plt.colorbar(p); 
    plt.subplot(1, 2, 2); p = plot(T_opt,title='Temperature',mode='color'); p.set_cmap("coolwarm"); cbar = plt.colorbar(p);  

Is there a way to fix my errors or a more efficient was to implement such a constraint?

Thank you very much for your time!