示例#1
0
def _newton_solve(z, E, scale, tolerance=1e-6, armijo=1e-4, max_iterations=50):
    F = derivative(E, z)
    H = derivative(F, z)

    Q = z.function_space()
    bc = firedrake.DirichletBC(Q, 0, 'on_boundary')
    p = firedrake.Function(Q)
    for iteration in range(max_iterations):
        firedrake.solve(H == -F, p, bc,
            solver_parameters={'ksp_type': 'preonly', 'pc_type': 'lu'})

        dE_dp = assemble(action(F, p))
        α = 1.0
        E0 = assemble(E)
        Ez = assemble(replace(E, {z: z + firedrake.Constant(α) * p}))
        while (Ez > E0 + armijo * α * dE_dp) or np.isnan(Ez):
            α /= 2
            Ez = assemble(replace(E, {z: z + firedrake.Constant(α) * p}))

        z.assign(z + firedrake.Constant(α) * p)
        if abs(dE_dp) < tolerance * assemble(scale):
            return z

    raise ValueError("Newton solver failed to converge after {0} iterations"
                     .format(max_iterations))
示例#2
0
    def _setup(self, **kwargs):
        for name, field in kwargs.items():
            if name in self._fields.keys():
                self._fields[name].assign(field)
            else:
                if isinstance(field, firedrake.Constant):
                    self._fields[name] = firedrake.Constant(field)
                elif isinstance(field, firedrake.Function):
                    self._fields[name] = field.copy(deepcopy=True)
                else:
                    raise TypeError(
                        "Input %s field has type %s, must be Constant or Function!"
                        % (name, type(field))
                    )

        # Create symbolic representations of the flux and sources of damage
        dt = firedrake.Constant(1.0)
        flux = self.model.flux(**self.fields)

        # Create the finite element mass matrix
        D = self.fields["damage"]
        Q = D.function_space()
        φ, ψ = firedrake.TrialFunction(Q), firedrake.TestFunction(Q)
        M = φ * ψ * dx

        L1 = -dt * flux
        D1 = firedrake.Function(Q)
        D2 = firedrake.Function(Q)
        L2 = firedrake.replace(L1, {D: D1})
        L3 = firedrake.replace(L1, {D: D2})

        dD = firedrake.Function(Q)

        parameters = {
            "solver_parameters": {
                "ksp_type": "preonly",
                "pc_type": "bjacobi",
                "sub_pc_type": "ilu",
            }
        }

        problem1 = LinearVariationalProblem(M, L1, dD)
        problem2 = LinearVariationalProblem(M, L2, dD)
        problem3 = LinearVariationalProblem(M, L3, dD)
        solver1 = LinearVariationalSolver(problem1, **parameters)
        solver2 = LinearVariationalSolver(problem2, **parameters)
        solver3 = LinearVariationalSolver(problem3, **parameters)

        self._solvers = [solver1, solver2, solver3]
        self._stages = [D1, D2]
        self._damage_change = dD
        self._timestep = dt
示例#3
0
    def step(self):
        r"""Perform a backtracking line search for the next value of the
        solution and compute the search direction for the next step"""
        E = self.problem.E
        u = self.problem.u
        v = self.v
        t = self.t

        t.assign(1.)
        E_0 = self.problem.assemble(E)
        slope = self.problem.assemble(self.dE_dv)
        if slope > 0:
            raise firedrake.ConvergenceError(
                'Minimization solver has invalid search direction. This is '
                'likely due to a negative thickness or friction coefficient or'
                'otherwise physically invalid input data.')

        E_t = firedrake.replace(E, {u: u + t * v})

        armijo = self.armijo
        contraction = self.contraction
        while self.problem.assemble(E_t) > E_0 + armijo * float(t) * slope:
            t.assign(t * contraction)

        u.assign(u + t * v)
        self.search_direction_solver.solve()
        self.iteration += 1
示例#4
0
 def reconstruct(self, field=None, V=None, subu=None, u=None, row_field=None, col_field=None, action_x=None, use_split=False):
     subu = subu or self.u
     row_field = row_field or field
     col_field = col_field or field
     # define W and form
     if field is None:
         # Returns self
         W = self._function_space
         form = self.f
     else:
         assert V is not None, "`V` can not be `None` when `field` is not `None`"
         W = self.as_subspace(field, V, use_split)
         if W is None:
             return
         rank = len(self.f.arguments())
         splitter = ExtractSubBlock()
         if rank == 1:
             form = splitter.split(self.f, argument_indices=(row_field, ))
         elif rank == 2:
             form = splitter.split(self.f, argument_indices=(row_field, col_field))
         if u is not None:
             form = replace(form, {self.u: u})
     if action_x is not None:
         assert len(form.arguments()) == 2, "rank of self.f must be 2 when using action_x parameter"
         form = ufl_expr.action(form, action_x)
     ebc = EquationBCSplit(form, subu, self.sub_domain, method=self.method, V=W)
     for bc in self.bcs:
         if isinstance(bc, DirichletBC):
             ebc.add(bc.reconstruct(V=W, g=bc.function_arg, sub_domain=bc.sub_domain, method=bc.method, use_split=use_split))
         elif isinstance(bc, EquationBCSplit):
             bc_temp = bc.reconstruct(field=field, V=V, subu=subu, u=u, row_field=row_field, col_field=col_field, action_x=action_x, use_split=use_split)
             # Due to the "if index", bc_temp can be None
             if bc_temp is not None:
                 ebc.add(bc_temp)
     return ebc
示例#5
0
 def reconstruct(self, field=None, V=None, subu=None, u=None, row_field=None, col_field=None, action_x=None, use_split=False):
     subu = subu or self.u
     row_field = row_field or field
     col_field = col_field or field
     # define W and form
     if field is None:
         # Returns self
         W = self._function_space
         form = self.f
     else:
         assert V is not None, "`V` can not be `None` when `field` is not `None`"
         W = self.as_subspace(field, V, use_split)
         if W is None:
             return
         rank = len(self.f.arguments())
         splitter = ExtractSubBlock()
         if rank == 1:
             form = splitter.split(self.f, argument_indices=(row_field, ))
         elif rank == 2:
             form = splitter.split(self.f, argument_indices=(row_field, col_field))
         if u is not None:
             form = replace(form, {self.u: u})
     if action_x is not None:
         assert len(form.arguments()) == 2, "rank of self.f must be 2 when using action_x parameter"
         form = ufl_expr.action(form, action_x)
     ebc = EquationBCSplit(form, subu, self.sub_domain, method=self.method, V=W)
     for bc in self.bcs:
         if isinstance(bc, DirichletBC):
             ebc.add(bc.reconstruct(V=W, g=bc.function_arg, sub_domain=bc.sub_domain, method=bc.method, use_split=use_split))
         elif isinstance(bc, EquationBCSplit):
             bc_temp = bc.reconstruct(field=field, V=V, subu=subu, u=u, row_field=row_field, col_field=col_field, action_x=action_x, use_split=use_split)
             # Due to the "if index", bc_temp can be None
             if bc_temp is not None:
                 ebc.add(bc_temp)
     return ebc
示例#6
0
    def initialize(self, init_solution):
        self.solution_old.assign(init_solution)
        z_theta = (1-self.theta)*self.solution_old + self.theta*self.solution

        self._fields = []
        for fields in self.fields:
            cfields = fields.copy()
            for field_name, field_expr in fields.items():
                if isinstance(field_expr, float):
                    continue
                cfields[field_name] = firedrake.replace(field_expr, {self.solution: z_theta})
            self._fields.append(cfields)

        F = 0
        for test, u, u_old, eq, mass_term, fields, bcs in zip(self.test, firedrake.split(self.solution), firedrake.split(self.solution_old), self.equations, self.mass_terms, self._fields, self.bcs):
            if mass_term:
                F += eq.mass_term(test, u-u_old)

            u_theta = (1-self.theta)*u_old + self.theta*u
            F -= self.dt_const * eq.residual(test, u_theta, u_theta, fields, bcs=bcs)

        self.problem = firedrake.NonlinearVariationalProblem(F, self.solution, bcs=self.strong_bcs)
        self.solver = firedrake.NonlinearVariationalSolver(self.problem,
                                                           solver_parameters=self.solver_parameters,
                                                           options_prefix=self.name)
        self._initialized = True
示例#7
0
    def _setup(self, **kwargs):
        for name, field in kwargs.items():
            if name in self.fields.keys():
                self.fields[name].assign(field)
            else:
                self.fields[name] = utilities.copy(field)

        # Create symbolic representations of the flux and sources of damage
        dt = firedrake.Constant(1.)
        flux = self.model.flux(**self.fields)

        # Create the finite element mass matrix
        D = self.fields.get('damage', self.fields.get('D'))
        Q = D.function_space()
        φ, ψ = firedrake.TrialFunction(Q), firedrake.TestFunction(Q)
        M = φ * ψ * dx

        L1 = -dt * flux
        D1 = firedrake.Function(Q)
        D2 = firedrake.Function(Q)
        L2 = firedrake.replace(L1, {D: D1})
        L3 = firedrake.replace(L1, {D: D2})

        dD = firedrake.Function(Q)

        parameters = {
            'solver_parameters': {
                'ksp_type': 'preonly',
                'pc_type': 'bjacobi',
                'sub_pc_type': 'ilu'
            }
        }

        problem1 = LinearVariationalProblem(M, L1, dD)
        problem2 = LinearVariationalProblem(M, L2, dD)
        problem3 = LinearVariationalProblem(M, L3, dD)
        solver1 = LinearVariationalSolver(problem1, **parameters)
        solver2 = LinearVariationalSolver(problem2, **parameters)
        solver3 = LinearVariationalSolver(problem3, **parameters)

        self._solvers = [solver1, solver2, solver3]
        self._stages = [D1, D2]
        self._damage_change = dD
        self._timestep = dt
示例#8
0
 def derivative_form(self, w):
     if args.discretisation == "pkp0":
         fd.warning(fd.RED % "Using residual without grad-div")
         F = solver.F_nograddiv
     else:
         F = solver.F
     F = fd.replace(F, {F.arguments()[0]: solver.z_adj})
     L = F + self.value_form()
     X = fd.SpatialCoordinate(solver.z.ufl_domain())
     return fd.derivative(L, X, w)
示例#9
0
    def eval_ddJdw(self):
        u = self.u
        v = self.v
        J = self.J
        F = self.F
        X = self.X
        w = self.w
        V = self.V
        L = self.L
        bil_form = self.bil_form
        params = self.params

        s = w
        y_s = Function(V)
        # follow p 65 of Hinze, Pinnau, Ulbrich, Ulbrich
        # Step 1:
        solve(
            assemble(derivative(F, u)),
            y_s,
            assemble(derivative(-F, X, s)),
            solver_parameters=params,
            bcs=self.bc,
        )
        # Step 2:
        Lyy_y_s = assemble(derivative(derivative(L, u), u, y_s))
        Lyu_s = assemble(derivative(derivative(L, u), X, s))

        h1 = Lyy_y_s
        h1 += Lyu_s

        Luy_y_s = assemble(derivative(derivative(L, X), u, y_s))
        Luu_s = assemble(derivative(derivative(L, X), X, s))
        h2 = Luy_y_s
        h2 += Luu_s
        h3_temp = Function(V)
        # Step 3:
        solve(assemble(bil_form),
              h3_temp,
              h1,
              bcs=self.bc,
              solver_parameters=params)
        F_h3_temp = replace(F, {v: h3_temp})
        h3 = assemble(derivative(-F_h3_temp, X))
        res = h2
        res += h3
        return res.vector().inner(w.vector())
示例#10
0
    def _update_func(self, name, val):
        """
        Utility function to update a function in the main attributes.

        Args:
            name (str): The name of the function to update.
            val (Function): The new value for the function.

        Raises:
            AttributeError: If name is not in self.
        """
        if not hasattr(self, name):
            raise AttributeError('Cannot update {}'.format(name))

        attrs = vars(self).copy()
        old_val = getattr(self, name)
        for attr_name, attr_val in attrs.items():
            if isinstance(attr_val, (Form, Integral, Expr)):
                updated_val = replace(attr_val, {old_val: val})
                setattr(self, attr_name, updated_val)
示例#11
0
    def eval_dJdw(self):
        u = self.u
        v = self.v
        J = self.J
        F = self.F
        X = self.X
        w = self.w
        V = self.V
        params = self.params

        solve(self.F == 0, u, bcs=self.bc, solver_parameters=params)
        bil_form = adjoint(derivative(F, u))
        rhs = -derivative(J, u)
        u_adj = Function(V)
        solve(assemble(bil_form),
              u_adj,
              assemble(rhs),
              bcs=self.bc,
              solver_parameters=params)
        L = J + replace(self.F, {v: u_adj})
        self.L = L
        self.bil_form = bil_form
        return assemble(derivative(L, X, w))
示例#12
0
    def split(self, fields):
        from firedrake import replace, as_vector, split
        from firedrake import NonlinearVariationalProblem as NLVP
        fields = tuple(tuple(f) for f in fields)
        splits = self._splits.get(tuple(fields))
        if splits is not None:
            return splits

        splits = []
        problem = self._problem
        splitter = ExtractSubBlock()
        for field in fields:
            F = splitter.split(problem.F, argument_indices=(field, ))
            J = splitter.split(problem.J, argument_indices=(field, field))
            us = problem.u.split()
            V = F.arguments()[0].function_space()
            # Exposition:
            # We are going to make a new solution Function on the sub
            # mixed space defined by the relevant fields.
            # But the form may refer to the rest of the solution
            # anyway.
            # So we pull it apart and will make a new function on the
            # subspace that shares data.
            pieces = [us[i].dat for i in field]
            if len(pieces) == 1:
                val, = pieces
                subu = function.Function(V, val=val)
                subsplit = (subu, )
            else:
                val = op2.MixedDat(pieces)
                subu = function.Function(V, val=val)
                # Split it apart to shove in the form.
                subsplit = split(subu)
            # Permutation from field indexing to indexing of pieces
            field_renumbering = dict([f, i] for i, f in enumerate(field))
            vec = []
            for i, u in enumerate(us):
                if i in field:
                    # If this is a field we're keeping, get it from
                    # the new function. Otherwise just point to the
                    # old data.
                    u = subsplit[field_renumbering[i]]
                if u.ufl_shape == ():
                    vec.append(u)
                else:
                    for idx in numpy.ndindex(u.ufl_shape):
                        vec.append(u[idx])

            # So now we have a new representation for the solution
            # vector in the old problem. For the fields we're going
            # to solve for, it points to a new Function (which wraps
            # the original pieces). For the rest, it points to the
            # pieces from the original Function.
            # IOW, we've reinterpreted our original mixed solution
            # function as being made up of some spaces we're still
            # solving for, and some spaces that have just become
            # coefficients in the new form.
            u = as_vector(vec)
            F = replace(F, {problem.u: u})
            J = replace(J, {problem.u: u})
            if problem.Jp is not None:
                Jp = splitter.split(problem.Jp, argument_indices=(field, field))
                Jp = replace(Jp, {problem.u: u})
            else:
                Jp = None
            bcs = []
            for bc in problem.bcs:
                Vbc = bc.function_space()
                if Vbc.parent is not None and isinstance(Vbc.parent.ufl_element(), VectorElement):
                    index = Vbc.parent.index
                else:
                    index = Vbc.index
                cmpt = Vbc.component
                # TODO: need to test this logic
                if index in field:
                    if len(field) == 1:
                        W = V
                    else:
                        W = V.sub(field_renumbering[index])
                    if cmpt is not None:
                        W = W.sub(cmpt)
                    bcs.append(type(bc)(W,
                                        bc.function_arg,
                                        bc.sub_domain,
                                        method=bc.method))
            new_problem = NLVP(F, subu, bcs=bcs, J=J, Jp=Jp,
                               form_compiler_parameters=problem.form_compiler_parameters)
            new_problem._constant_jacobian = problem._constant_jacobian
            splits.append(type(self)(new_problem, mat_type=self.mat_type, pmat_type=self.pmat_type,
                                     appctx=self.appctx))
        return self._splits.setdefault(tuple(fields), splits)
示例#13
0
    def split(self, fields):
        from firedrake import replace, as_vector, split
        from firedrake_ts.ts_solver import DAEProblem
        from firedrake.bcs import DirichletBC, EquationBC

        fields = tuple(tuple(f) for f in fields)
        splits = self._splits.get(tuple(fields))
        if splits is not None:
            return splits

        splits = []
        problem = self._problem
        splitter = ExtractSubBlock()
        for field in fields:
            F = splitter.split(problem.F, argument_indices=(field, ))
            J = splitter.split(problem.J, argument_indices=(field, field))
            us = problem.u.split()
            V = F.arguments()[0].function_space()
            # Exposition:
            # We are going to make a new solution Function on the sub
            # mixed space defined by the relevant fields.
            # But the form may refer to the rest of the solution
            # anyway.
            # So we pull it apart and will make a new function on the
            # subspace that shares data.
            pieces = [us[i].dat for i in field]
            if len(pieces) == 1:
                (val, ) = pieces
                subu = function.Function(V, val=val)
                subsplit = (subu, )
            else:
                val = op2.MixedDat(pieces)
                subu = function.Function(V, val=val)
                # Split it apart to shove in the form.
                subsplit = split(subu)
            # Permutation from field indexing to indexing of pieces
            field_renumbering = dict([f, i] for i, f in enumerate(field))
            vec = []
            for i, u in enumerate(us):
                if i in field:
                    # If this is a field we're keeping, get it from
                    # the new function. Otherwise just point to the
                    # old data.
                    u = subsplit[field_renumbering[i]]
                if u.ufl_shape == ():
                    vec.append(u)
                else:
                    for idx in numpy.ndindex(u.ufl_shape):
                        vec.append(u[idx])

            # So now we have a new representation for the solution
            # vector in the old problem. For the fields we're going
            # to solve for, it points to a new Function (which wraps
            # the original pieces). For the rest, it points to the
            # pieces from the original Function.
            # IOW, we've reinterpreted our original mixed solution
            # function as being made up of some spaces we're still
            # solving for, and some spaces that have just become
            # coefficients in the new form.
            u = as_vector(vec)
            F = replace(F, {problem.u: u})
            J = replace(J, {problem.u: u})
            if problem.Jp is not None:
                Jp = splitter.split(problem.Jp,
                                    argument_indices=(field, field))
                Jp = replace(Jp, {problem.u: u})
            else:
                Jp = None
            bcs = []
            for bc in problem.bcs:
                if isinstance(bc, DirichletBC):
                    bc_temp = bc.reconstruct(
                        field=field,
                        V=V,
                        g=bc.function_arg,
                        sub_domain=bc.sub_domain,
                        method=bc.method,
                    )
                elif isinstance(bc, EquationBC):
                    bc_temp = bc.reconstruct(field, V, subu, u)
                if bc_temp is not None:
                    bcs.append(bc_temp)
            new_problem = DAEProblem(
                F,
                subu,
                problem.udot,
                problem.tspan,
                bcs=bcs,
                J=J,
                Jp=Jp,
                form_compiler_parameters=problem.form_compiler_parameters,
            )
            new_problem._constant_jacobian = problem._constant_jacobian
            splits.append(
                type(self)(
                    new_problem,
                    mat_type=self.mat_type,
                    pmat_type=self.pmat_type,
                    appctx=self.appctx,
                    transfer_manager=self.transfer_manager,
                ))
        return self._splits.setdefault(tuple(fields), splits)
示例#14
0
 def f(t):
     p_t.assign(p + firedrake.Constant(t) * q)
     u_t.assign(self._forward_solve(p_t))
     return self._assemble(replace(self._J, {u: u_t, p: p_t}))
示例#15
0
def newton_search(E, u, bc, tolerance, scale,
                  max_iterations=50, armijo=1e-4, contraction_factor=0.5,
                  form_compiler_parameters={},
                  solver_parameters={'ksp_type': 'preonly', 'pc_type': 'lu'}):
    r"""Find the minimizer of a convex functional

    Parameters
    ----------
    E : firedrake.Form
        The functional to be minimized
    u0 : firedrake.Function
        Initial guess for the minimizer
    tolerance : float
        Stopping criterion for the optimization procedure
    scale : firedrake.Form
        A positive scale functional by which to measure the objective
    max_iterations : int, optional
        Optimization procedure will stop at this many iterations regardless
        of convergence
    armijo : float, optional
        The constant in the Armijo condition (see Nocedal and Wright)
    contraction_factor : float, optional
        The amount by which to backtrack in the line search if the Armijo
        condition is not satisfied
    form_compiler_parameters : dict, optional
        Extra options to pass to the firedrake form compiler
    solver_parameters : dict, optional
        Extra options to pass to the linear solver

    Returns
    -------
    firedrake.Function
        The approximate minimizer of `E` to within tolerance
    """
    F = firedrake.derivative(E, u)
    H = firedrake.derivative(F, u)
    v = firedrake.Function(u.function_space())
    dE_dv = firedrake.action(F, v)

    def assemble(*args, **kwargs):
        return firedrake.assemble(
            *args, **kwargs, form_compiler_parameters=form_compiler_parameters)

    problem = firedrake.LinearVariationalProblem(H, -F, v, bc,
                  form_compiler_parameters=form_compiler_parameters,
                  constant_jacobian=False)
    solver = firedrake.LinearVariationalSolver(problem,
                 solver_parameters=solver_parameters)

    n = 0
    while True:
        # Compute a search direction
        solver.solve()

        # Compute the directional derivative, check if we're done
        slope = assemble(dE_dv)
        assert slope < 0
        if (abs(slope) < assemble(scale) * tolerance) or (n >= max_iterations):
            return u

        # Backtracking search
        E0 = assemble(E)
        α = firedrake.Constant(1)
        Eα = firedrake.replace(E, {u: u + α * v})
        while assemble(Eα) > E0 + armijo * α.values()[0] * slope:
            α.assign(α * contraction_factor)

        u.assign(u + α * v)
        n += 1
示例#16
0
 def f(t):
     s.assign(t)
     u_s.assign(self._forward_solve(p_s))
     return self._assemble(replace(self._J, {u: u_s, p: p_s}))
示例#17
0
    def solve(self, dt, D0, u, A, D_inflow=None, **kwargs):
        r"""Propogate the damage forward by one timestep

        This function uses a Runge-Kutta scheme to upwind damage
        (limiting damage diffusion) while sourcing and sinking
        damage assocaited with crevasse opening/crevasse healing

        Parameters
        ----------
        dt : float
            Timestep
        D0 : firedrake.Function
            initial damage feild should be discontinuous
        u : firedrake.Function
            Ice velocity
        A : firedrake.Function
            fluidity parameter
        D_inflow : firedrake.Function
            Damage of the upstream ice that advects into the domain

        Returns
        -------
        D : firedrake.Function
            Ice damage at `t + dt`
        """

        D_inflow = D_inflow if D_inflow is not None else D0
        Q = D0.function_space()
        dD, φ = firedrake.TrialFunction(Q), firedrake.TestFunction(Q)
        d = φ * dD * dx
        D = D0.copy(deepcopy=True)

        n = firedrake.FacetNormal(Q.mesh())

        un = 0.5 * (inner(u, n) + abs(inner(u, n)))
        L1 = dt * (D * div(φ * u) * dx - φ * max_value(inner(u, n), 0) * D * ds
                   - φ * min_value(inner(u, n), 0) * D_inflow * ds -
                   (φ('+') - φ('-')) *
                   (un('+') * D('+') - un('-') * D('-')) * dS)
        D1 = firedrake.Function(Q)
        D2 = firedrake.Function(Q)
        L2 = firedrake.replace(L1, {D: D1})
        L3 = firedrake.replace(L1, {D: D2})

        dq = firedrake.Function(Q)

        # Three-stage strong structure-preserving Runge Kutta (SSPRK3) method
        params = {
            'ksp_type': 'preonly',
            'pc_type': 'bjacobi',
            'sub_pc_type': 'ilu'
        }
        prob1 = firedrake.LinearVariationalProblem(d, L1, dq)
        solv1 = firedrake.LinearVariationalSolver(prob1,
                                                  solver_parameters=params)
        prob2 = firedrake.LinearVariationalProblem(d, L2, dq)
        solv2 = firedrake.LinearVariationalSolver(prob2,
                                                  solver_parameters=params)
        prob3 = firedrake.LinearVariationalProblem(d, L3, dq)
        solv3 = firedrake.LinearVariationalSolver(prob3,
                                                  solver_parameters=params)

        solv1.solve()
        D1.assign(D + dq)
        solv2.solve()
        D2.assign(0.75 * D + 0.25 * (D1 + dq))
        solv3.solve()
        D.assign((1.0 / 3.0) * D + (2.0 / 3.0) * (D2 + dq))

        # Increase/decrease damage depending on stress and strain rates
        ε = sym(grad(u))
        ε_1 = eigenvalues(ε)[0]

        σ = M(ε, A)
        σ_e = sqrt(inner(σ, σ) - det(σ))

        ε_h = firedrake.Constant(self.healing_strain_rate)
        σ_d = firedrake.Constant(self.damage_stress)
        γ_h = firedrake.Constant(self.healing_rate)
        γ_d = firedrake.Constant(self.damage_rate)

        healing = γ_h * min_value(ε_1 - ε_h, 0)
        fracture = γ_d * conditional(σ_e - σ_d > 0, ε_1, 0.) * (1 - D)

        # Clamp damage field to [0, 1]
        D.project(min_value(max_value(D + dt * (healing + fracture), 0), 1))
        return D