Source code for cashocs.nonlinear_solvers.newton_solver

# Copyright (C) 2020-2024 Sebastian Blauth
#
# This file is part of cashocs.
#
# cashocs is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# cashocs is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with cashocs.  If not, see <https://www.gnu.org/licenses/>.

"""Newton solver for nonlinear PDEs."""

from __future__ import annotations

import copy
from typing import List, Optional, TYPE_CHECKING, Union

import fenics
import numpy as np
from typing_extensions import Literal

try:
    import ufl_legacy as ufl
except ImportError:
    import ufl

from cashocs import _exceptions
from cashocs import _utils

if TYPE_CHECKING:
    from cashocs import _typing


class _NewtonSolver:
    """A Newton solver."""

    def __init__(
        self,
        nonlinear_form: ufl.Form,
        u: fenics.Function,
        bcs: Union[fenics.DirichletBC, List[fenics.DirichletBC]],
        derivative: Optional[ufl.Form] = None,
        shift: Optional[ufl.Form] = None,
        rtol: float = 1e-10,
        atol: float = 1e-10,
        max_iter: int = 50,
        convergence_type: Literal["combined", "rel", "abs"] = "combined",
        norm_type: Literal["l2", "linf"] = "l2",
        damped: bool = True,
        inexact: bool = True,
        verbose: bool = True,
        ksp_options: Optional[_typing.KspOption] = None,
        A_tensor: Optional[fenics.PETScMatrix] = None,  # pylint: disable=invalid-name
        b_tensor: Optional[fenics.PETScVector] = None,
        is_linear: bool = False,
        preconditioner_form: Optional[ufl.Form] = None,
        linear_solver: Optional[_utils.linalg.LinearSolver] = None,
    ) -> None:
        r"""Initializes self.

        Args:
            nonlinear_form: The variational form of the nonlinear problem to be solved
                by Newton's method.
            u: The sought solution / initial guess. It is not assumed that the initial
                guess satisfies the Dirichlet boundary conditions, they are applied
                automatically. The method overwrites / updates this Function.
            bcs: A list of DirichletBCs for the nonlinear variational problem.
            derivative: The Jacobian of nonlinear_form, used for the Newton method.
                Default is None, and in this case the Jacobian is computed automatically
                with AD.
            shift: A shift term, if the right-hand side of the nonlinear problem is not
                zero, but shift.
            rtol: Relative tolerance of the solver if convergence_type is either
                ``'combined'`` or ``'rel'`` (default is ``rtol = 1e-10``).
            atol: Absolute tolerance of the solver if convergence_type is either
                ``'combined'`` or ``'abs'`` (default is ``atol = 1e-10``).
            max_iter: Maximum number of iterations carried out by the method (default is
                ``max_iter = 50``).
            convergence_type: Determines the type of stopping criterion that is used.
            norm_type: Determines which norm is used in the stopping criterion.
            damped: If ``True``, then a damping strategy is used. If ``False``, the
                classical Newton-Raphson iteration (without damping) is used (default is
                ``True``).
            inexact: A boolean flag which indicates, whether an inexact Newton\'s method
                is used.
            verbose: If ``True``, prints status of the iteration to the console (default
                is ``True``).
            ksp_options: The list of options for the linear solver.
            A_tensor: A fenics.PETScMatrix for storing the left-hand side of the linear
                sub-problem.
            b_tensor: A fenics.PETScVector for storing the right-hand side of the linear
                sub-problem.
            is_linear: A boolean flag, which indicates whether the problem is actually
                linear.
            preconditioner_form: A UFL form which defines the preconditioner matrix.
            linear_solver: The linear solver (KSP) which is used to solve the linear
                systems arising from the discretized PDE

        """
        self.nonlinear_form = nonlinear_form
        self.u = u
        self.bcs = _utils.enlist(bcs)
        self.shift = shift
        self.rtol = rtol
        self.atol = atol
        self.max_iter = max_iter
        self.convergence_type = convergence_type
        self.norm_type = norm_type
        self.damped = damped
        self.inexact = inexact
        self.A_tensor = A_tensor  # pylint: disable=invalid-name
        self.b_tensor = b_tensor
        self.is_linear = is_linear
        if preconditioner_form is not None:
            if len(preconditioner_form.arguments()) == 1:
                self.preconditioner_form = fenics.derivative(
                    preconditioner_form, self.u
                )
            else:
                self.preconditioner_form = preconditioner_form
        else:
            self.preconditioner_form = None

        self.verbose = verbose if not self.is_linear else False

        temp_derivative = derivative or fenics.derivative(self.nonlinear_form, self.u)
        self.derivative = _utils.bilinear_boundary_form_modification([temp_derivative])[
            0
        ]

        # Setup increment and function for monotonicity test
        self.function_space = u.function_space()
        self.du = fenics.Function(self.function_space)
        self.ddu = fenics.Function(self.function_space)
        self.u_save = fenics.Function(self.function_space)
        self.ksp_options = ksp_options

        if ksp_options is None:
            self.ksp_options = copy.deepcopy(_utils.linalg.direct_ksp_options)
        else:
            self.ksp_options = ksp_options

        self.iterations = 0

        # inexact newton parameters
        self.eta: float | None = 1.0
        self.eta_max = 0.9999
        self.eta_a = 1.0
        self.gamma = 0.9
        self.lmbd = 1.0

        self.assembler = fenics.SystemAssembler(
            self.derivative, self.nonlinear_form, self.bcs
        )
        self.assembler.keep_diagonal = True

        if self.preconditioner_form is not None:
            self.assembler_pc = fenics.SystemAssembler(
                self.preconditioner_form, self.nonlinear_form, self.bcs
            )
            self.assembler_pc.keep_diagonal = True

        self.comm = self.u.function_space().mesh().mpi_comm()
        # pylint: disable=invalid-name
        self.A_fenics = self.A_tensor or fenics.PETScMatrix(self.comm)
        self.residual = self.b_tensor or fenics.PETScVector(self.comm)
        self.b = fenics.as_backend_type(self.residual).vec()
        self.A_matrix = fenics.as_backend_type(self.A_fenics).mat()

        self.P_fenics = fenics.PETScMatrix(self.comm)

        if linear_solver is None:
            self.linear_solver = _utils.linalg.LinearSolver(self.comm)
        else:
            self.linear_solver = linear_solver

        self.assembler_shift: Optional[fenics.SystemAssembler] = None
        self.residual_shift: Optional[fenics.PETScVector] = None
        if self.shift is not None:
            self.assembler_shift = fenics.SystemAssembler(
                self.derivative, self.shift, self.bcs
            )
            self.residual_shift = fenics.PETScVector(self.comm)

        self.breakdown = False
        self.res = 1.0
        self.res_0 = 1.0
        self.tol = 1.0

    def _print_output(self) -> None:
        """Prints the output of the current iteration to the console."""
        if self.verbose and fenics.MPI.rank(fenics.MPI.comm_world) == 0:
            prefix = "Newton solver:  "

            if self.iterations % 10 == 0:
                info_str = (
                    f"\n{prefix}iter,  "
                    f"abs. residual (abs. tol),  "
                    f"rel. residual (rel. tol)\n\n"
                )
            else:
                info_str = ""

            print_str = (
                f"{prefix}{self.iterations:4d},  "
                f"{self.res:>13.3e} ({self.atol:.2e}),  "
                f"{self.res/self.res_0:>13.3e} ({self.rtol:.2e})"
            )

            print(info_str + print_str, flush=True)
        fenics.MPI.barrier(fenics.MPI.comm_world)

    def _assemble_matrix(self) -> None:
        """Assembles the matrix for solving the linear problem."""
        self.assembler.assemble(self.A_fenics)
        self.A_fenics.ident_zeros()
        self.A_matrix = fenics.as_backend_type(  # pylint: disable=invalid-name
            self.A_fenics
        ).mat()

        if self.preconditioner_form is not None:
            self.assembler_pc.assemble(self.P_fenics)
            self.P_fenics.ident_zeros()
            self.P_matrix = fenics.as_backend_type(  # pylint: disable=invalid-name
                self.P_fenics
            ).mat()
        else:
            self.P_matrix = None

    def _compute_eta_inexact(self) -> None:
        """Computes the parameter ``eta`` for the inexact Newton method."""
        if self.inexact and isinstance(self.eta, float):
            if self.iterations == 1:
                eta_new = self.eta_max
            elif self.gamma * pow(self.eta, 2) <= 0.1:
                eta_new = np.minimum(self.eta_max, self.eta_a)
            else:
                eta_new = np.minimum(
                    self.eta_max,
                    np.maximum(self.eta_a, self.gamma * pow(self.eta, 2)),
                )

            self.eta = np.minimum(
                self.eta_max,
                np.maximum(eta_new, 0.5 * self.tol / self.res),
            )
        else:
            self.eta = None

        if self.is_linear:
            self.eta = None

    def _check_for_nan_residual(self) -> None:
        """Checks, whether the residual is nan. If yes, raise a NotConvergedError."""
        if np.isnan(self.res):
            raise _exceptions.NotConvergedError("newton solver", "Residual is nan.")

    def solve(self) -> fenics.Function:
        r"""Solves the (nonlinear) problem with Newton\'s method.

        Returns:
            A solution of the nonlinear problem.

        """
        self._compute_residual()

        self.res_0 = self.residual.norm(self.norm_type)
        if self.res_0 == 0.0:  # pragma: no cover
            if self.verbose and fenics.MPI.rank(fenics.MPI.comm_world) == 0:
                print("Residual vanishes, input is already a solution.", flush=True)
            fenics.MPI.barrier(fenics.MPI.comm_world)
            return self.u

        self.res = self.res_0
        self._check_for_nan_residual()
        self._print_output()

        self.tol = self._compute_convergence_tolerance()

        # While loop until termination
        while self.res > self.tol and self.iterations < self.max_iter:
            self._assemble_matrix()

            self.iterations += 1
            self.lmbd = 1.0
            self.breakdown = False
            self.u_save.vector().vec().aypx(0.0, self.u.vector().vec())
            self.u_save.vector().apply("")

            self._compute_eta_inexact()
            self.linear_solver.solve(
                A=self.A_matrix,
                b=self.b,
                fun=self.du,
                ksp_options=self.ksp_options,
                rtol=self.eta,
                atol=self.atol / 10.0,
                P=self.P_matrix,
            )

            if self.is_linear:
                self.u.vector().vec().axpy(-1.0, self.du.vector().vec())
                self.u.vector().apply("")
                break

            self._backtracking_line_search()
            self._compute_residual()

            res_prev = self.res
            self.res = self.residual.norm(self.norm_type)
            self._check_for_nan_residual()
            self.eta_a = self.gamma * pow(self.res / res_prev, 2)
            self._print_output()

            if self._check_for_convergence():
                break

        self._check_if_successful()

        return self.u

    def _check_for_convergence(self) -> bool:
        """Checks, whether the desired convergence tolerance has been reached."""
        if self.res <= self.tol:
            if self.verbose and fenics.MPI.rank(fenics.MPI.comm_world) == 0:
                print(
                    f"\nNewton Solver converged "
                    f"after {self.iterations:d} iterations.\n",
                    flush=True,
                )
            fenics.MPI.barrier(fenics.MPI.comm_world)
            return True

        else:
            return False

    def _check_if_successful(self) -> None:
        """Checks, whether the attempted solve was successful."""
        if self.res > self.tol and not self.is_linear:
            raise _exceptions.NotConvergedError(
                "Newton solver",
                f"The Newton solver did not converge after "
                f"{self.iterations:d} iterations.",
            )

    def _check_for_divergence(self) -> None:
        """Checks, whether the Newton solver diverged."""
        if self.breakdown:
            raise _exceptions.NotConvergedError(
                "Newton solver", "Stepsize for increment too low."
            )

        if self.iterations == self.max_iter:
            raise _exceptions.NotConvergedError(
                "Newton solver",
                "Maximum number of iterations were exceeded.",
            )

    def _compute_residual(self) -> None:
        """Computes the residual of the nonlinear system."""
        self.residual = fenics.PETScVector(self.comm)
        self.assembler.assemble(self.residual, self.u.vector())
        if (
            self.shift is not None
            and self.assembler_shift is not None
            and self.residual_shift is not None
        ):
            self.assembler_shift.assemble(self.residual_shift, self.u.vector())
            self.residual[:] -= self.residual_shift[:]

        self.b = fenics.as_backend_type(self.residual).vec()

    def _compute_convergence_tolerance(self) -> float:
        """Computes the tolerance for the Newton solver.

        Returns:
            The computed tolerance.

        """
        if self.convergence_type.casefold() == "abs":
            return self.atol
        elif self.convergence_type.casefold() == "rel":
            return self.rtol * self.res_0
        else:
            return self.rtol * self.res_0 + self.atol

    def _backtracking_line_search(self) -> None:
        """Performs a backtracking line search for the damped Newton method."""
        if self.damped:
            while True:
                self.u.vector().vec().axpy(-self.lmbd, self.du.vector().vec())
                self.u.vector().apply("")
                self._compute_residual()
                self.linear_solver.solve(
                    A=self.A_matrix,
                    b=self.b,
                    fun=self.ddu,
                    ksp_options=self.ksp_options,
                    rtol=self.eta,
                    atol=self.atol / 10.0,
                    P=self.P_matrix,
                )

                if (
                    self.ddu.vector().norm(self.norm_type)
                    / self.du.vector().norm(self.norm_type)
                    <= 1
                ):
                    break
                else:
                    self.u.vector().vec().aypx(0.0, self.u_save.vector().vec())
                    self.u.vector().apply("")
                    self.lmbd /= 2

                if self.lmbd < 1e-6:
                    self.breakdown = True
                    break
        else:
            self.u.vector().vec().axpy(-1.0, self.du.vector().vec())
            self.u.vector().apply("")


[docs] def newton_solve( nonlinear_form: ufl.Form, u: fenics.Function, bcs: Union[fenics.DirichletBC, List[fenics.DirichletBC]], derivative: Optional[ufl.Form] = None, shift: Optional[ufl.Form] = None, rtol: float = 1e-10, atol: float = 1e-10, max_iter: int = 50, convergence_type: Literal["combined", "rel", "abs"] = "combined", norm_type: Literal["l2", "linf"] = "l2", damped: bool = True, inexact: bool = True, verbose: bool = True, ksp_options: Optional[_typing.KspOption] = None, A_tensor: Optional[fenics.PETScMatrix] = None, # pylint: disable=invalid-name b_tensor: Optional[fenics.PETScVector] = None, is_linear: bool = False, preconditioner_form: Optional[ufl.Form] = None, linear_solver: Optional[_utils.linalg.LinearSolver] = None, ) -> fenics.Function: r"""Solves a nonlinear problem with Newton\'s method. Args: nonlinear_form: The variational form of the nonlinear problem to be solved by Newton's method. u: The sought solution / initial guess. It is not assumed that the initial guess satisfies the Dirichlet boundary conditions, they are applied automatically. The method overwrites / updates this Function. bcs: A list of DirichletBCs for the nonlinear variational problem. derivative: The Jacobian of nonlinear_form, used for the Newton method. Default is None, and in this case the Jacobian is computed automatically with AD. shift: A shift term, if the right-hand side of the nonlinear problem is not zero, but shift. rtol: Relative tolerance of the solver if convergence_type is either ``'combined'`` or ``'rel'`` (default is ``rtol = 1e-10``). atol: Absolute tolerance of the solver if convergence_type is either ``'combined'`` or ``'abs'`` (default is ``atol = 1e-10``). max_iter: Maximum number of iterations carried out by the method (default is ``max_iter = 50``). convergence_type: Determines the type of stopping criterion that is used. norm_type: Determines which norm is used in the stopping criterion. damped: If ``True``, then a damping strategy is used. If ``False``, the classical Newton-Raphson iteration (without damping) is used (default is ``True``). inexact: If ``True``, an inexact Newton\'s method is used. Default is ``True``. verbose: If ``True``, prints status of the iteration to the console (default is ``True``). ksp_options: The list of options for the linear solver. A_tensor: A fenics.PETScMatrix for storing the left-hand side of the linear sub-problem. b_tensor: A fenics.PETScVector for storing the right-hand side of the linear sub-problem. is_linear: A boolean flag, which indicates whether the problem is actually linear. preconditioner_form: A UFL form which defines the preconditioner matrix. linear_solver: The linear solver (KSP) which is used to solve the linear systems arising from the discretized PDE. Returns: The solution of the nonlinear variational problem, if converged. This overwrites the input function u. Examples: Consider the problem .. math:: \begin{alignedat}{2} - \Delta u + u^3 &= 1 \quad &&\text{ in } \Omega=(0,1)^2 \\ u &= 0 \quad &&\text{ on } \Gamma. \end{alignedat} This is solved with the code :: from fenics import * import cashocs mesh, _, boundaries, dx, _, _ = cashocs.regular_mesh(25) V = FunctionSpace(mesh, 'CG', 1) u = Function(function_space) v = TestFunction(function_space) F = inner(grad(u), grad(v))*dx + pow(u,3)*v*dx - Constant(1)*v*dx bcs = cashocs.create_dirichlet_bcs(V, Constant(0.0), boundaries, [1,2,3,4]) cashocs.newton_solve(F, u, bcs) """ solver = _NewtonSolver( nonlinear_form, u, bcs, derivative=derivative, shift=shift, rtol=rtol, atol=atol, max_iter=max_iter, convergence_type=convergence_type, norm_type=norm_type, damped=damped, inexact=inexact, verbose=verbose, ksp_options=ksp_options, A_tensor=A_tensor, b_tensor=b_tensor, is_linear=is_linear, preconditioner_form=preconditioner_form, linear_solver=linear_solver, ) solution = solver.solve() return solution