Source code for cashocs.nonlinear_solvers.ts

# Copyright (C) 2020-2026 Fraunhofer ITWM and 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/>.

"""Interface for the PETSc TS solver for pseudo time stepping."""

from __future__ import annotations

import copy
from typing import cast, TYPE_CHECKING

import fenics
import numpy as np
from petsc4py import PETSc

from cashocs import _exceptions
from cashocs import _utils
from cashocs import log

try:
    import ufl_legacy as ufl
except ImportError:
    import ufl

if TYPE_CHECKING:
    from cashocs import _typing

default_ts_pseudo_options: _typing.KspOption = {
    "ts_type": "beuler",
    "ts_dt": 1e-2,
    "ts_max_steps": 1000,
    "snes_type": "ksponly",
}


[docs] class TSPseudoSolver: """Interface for using PETSc's TS solver for pseudo time stepping. This class implements only pseudo time stepping for nonlinear equations, it should not be used for transient simulations. """ def __init__( self, nonlinear_form: ufl.Form, u: fenics.Function, bcs: fenics.DirichletBC | list[fenics.DirichletBC], derivative: ufl.Form | None = None, petsc_options: _typing.KspOption | None = None, shift: ufl.Form | None = None, rtol: float | None = None, atol: float | None = None, max_iter: int | None = None, A_tensor: fenics.PETScMatrix | None = None, # pylint: disable=invalid-name b_tensor: fenics.PETScVector | None = None, preconditioner_form: ufl.Form | None = None, excluded_from_time_derivative: list[int] | None = None, ) -> None: """Initializes the TS pseudo time stepping solver. Args: nonlinear_form (ufl.Form): The variational form of the nonlinear problem to be solved by Newton's method. u (fenics.Function): 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 (fenics.DirichletBC | list[fenics.DirichletBC]): A list of DirichletBCs for the nonlinear variational problem. derivative (ufl.Form | None, optional): The Jacobian of nonlinear_form, used for the Newton method. Default is None, and in this case the Jacobian is computed automatically with AD. petsc_options (_typing.KspOption | None, optional): The options for PETSc TS object. Defaults to None. shift (ufl.Form | None, optional): A shift term, if the right-hand side of the nonlinear problem is not zero, but shift. Defaults to None. rtol (float | None, optional): Relative tolerance of the solver. If this is set to a float, the float is used as relative tolerance. If this is set to None, then the relative tolerance of the SNES object is used, which can be defined with the petsc options `snes_rtol rtol`. Defaults to None. atol (float | None, optional): Absolute tolerance of the solver. If this is set to a float, the float is used as absolute tolerance. If this is set to None, then the absolute tolerance of the SNES object is used, which can be defined with the petsc options `snes_atol atol`. Defaults to None. max_iter (int | None, optional): Maximum number of iterations carried out by the method. Overrides the specification in the petsc_options. Defaults to None. A_tensor (fenics.PETScMatrix | None, optional): A fenics.PETScMatrix for storing the left-hand side of the linear sub-problem. Defaults to None. b_tensor (fenics.PETScVector | None, optional): A fenics.PETScVector for storing the right-hand side of the linear sub-problem. Defaults to None. preconditioner_form (ufl.Form | None, optional): A UFL form which defines the preconditioner matrix. Defaults to None. excluded_from_time_derivative (list[int] | None, optional): A list of indices for those components that are not included in the time derivative. Example: The pressure for incompressible Navier-Stokes. Default is None, so that all components are included for the time derivative. """ self.nonlinear_form = nonlinear_form self.u = u self.space = self.u.function_space() self.mesh = self.space.mesh() self.comm = self.mesh.mpi_comm() self.bcs = _utils.enlist(bcs) self.shift = shift self.rtol = rtol self.atol = atol self.dtol = 1e4 self.max_iter = max_iter self.res_current = -1.0 if petsc_options is None: self.petsc_options: _typing.KspOption = copy.deepcopy( default_ts_pseudo_options ) self.petsc_options.update(_utils.linalg.direct_ksp_options) else: self.petsc_options = petsc_options 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 temp_derivative = derivative or fenics.derivative(self.nonlinear_form, self.u) self.derivative = _utils.bilinear_boundary_form_modification([temp_derivative])[ 0 ] self.assembler = fenics.SystemAssembler( self.derivative, self.nonlinear_form, self.bcs ) self.assembler.keep_diagonal = True if A_tensor is not None: self.A_petsc = A_tensor.mat() # pylint: disable=invalid-name, else: self.A_petsc = fenics.PETScMatrix(self.comm).mat() if b_tensor is not None: self.residual_petsc = b_tensor.vec() else: self.residual_petsc = fenics.PETScVector(self.comm).vec() self.residual_convergence = fenics.PETScVector(self.comm) self.mass_matrix_petsc = fenics.PETScMatrix(self.comm).mat() self.mass_application_petsc = fenics.Function(self.space).vector().vec() 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.P_petsc = fenics.PETScMatrix( # pylint: disable=invalid-name self.comm ).mat() else: self.P_petsc = None self.assembler_shift: fenics.SystemAssembler | None = None self.residual_shift: fenics.PETScVector | None = 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.is_preassembled = False if excluded_from_time_derivative is None: self.excluded_from_time_derivative = [] else: self.excluded_from_time_derivative = excluded_from_time_derivative self._setup_mass_matrix() self.has_monitor_output = "ts_monitor" in self.petsc_options self.ts_converged_its = "ts_converged_its" in self.petsc_options def _setup_mass_matrix(self) -> None: """Sets up the mass matrix for the time derivative.""" space = self.u.function_space() trial = fenics.TrialFunction(space) test = fenics.TestFunction(space) dx = ufl.Measure("dx", domain=self.mesh) split_trial = ufl.split(trial) split_test = ufl.split(test) split_trial = tuple( fun for i, fun in enumerate(split_trial) if i not in self.excluded_from_time_derivative ) split_test = tuple( fun for i, fun in enumerate(split_test) if i not in self.excluded_from_time_derivative ) form_list = [] for i in range(len(split_trial)): test_shape = split_test[i].ufl_shape if len(test_shape) == 1: mass_matrix_part = ufl.dot(split_trial[i], split_test[i]) * dx elif len(test_shape) == 2: mass_matrix_part = ufl.inner(split_trial[i], split_test[i]) * dx else: mass_matrix_part = split_trial[i] * split_test[i] * dx form_list.append(mass_matrix_part) form = _utils.summation(form_list) M = fenics.PETScMatrix(self.mass_matrix_petsc) # pylint: disable=invalid-name, zero_form = ufl.inner(fenics.Constant(np.zeros(test.ufl_shape)), test) * dx fenics.assemble_system(form, zero_form, self.bcs, A_tensor=M) self.M_petsc = self.mass_matrix_petsc.copy() # pylint: disable=invalid-name, if self.preconditioner_form is not None: self.MP_petsc = ( # pylint: disable=invalid-name, self.mass_matrix_petsc.copy() ) else: self.MP_petsc = None
[docs] @log.profile_execution_time("assembling the residual for pseudo time stepping") def assemble_residual( self, ts: PETSc.TS, # pylint: disable=unused-argument t: float, # pylint: disable=unused-argument u: PETSc.Vec, f: PETSc.Vec, ) -> None: """Interface for PETSc TSSetRHSFunction. Args: ts (PETSc.TS): The TS solver. t (float): The current time step. u (PETSc.Vec): The current iterate. f (PETSc.Vec): The vector in which the residual is stored. """ self.u.vector().vec().aypx(0.0, u) self.u.vector().apply("") f_fenics = fenics.PETScVector(f) self.assembler.assemble(f_fenics, 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()) f[:] -= self.residual_shift[:] f.scale(-1)
[docs] @log.profile_execution_time("assembling the Jacobian for pseudo time stepping") def assemble_jacobian( self, ts: PETSc.TS, # pylint: disable=unused-argument t: float, # pylint: disable=unused-argument u: PETSc.Vec, J: PETSc.Mat, # pylint: disable=invalid-name, P: PETSc.Mat, # pylint: disable=invalid-name, ) -> None: """Interface for PETSc TSSetRHSJacobian. Args: ts (PETSc.TS): The TS solver. t (float): The current time step. u (PETSc.Vec): The current iterate. J (PETSc.Mat): The matrix for storing the Jacobian. P (PETSc.Mat): The matrix for storing the preconditioner. """ self.u.vector().vec().aypx(0.0, u) self.u.vector().apply("") J = fenics.PETScMatrix(J) # pylint: disable=invalid-name self.assembler.assemble(J) J.ident_zeros() J_petsc = fenics.as_backend_type(J).mat() # pylint: disable=invalid-name, J_petsc.scale(-1) J_petsc.assemble() if self.preconditioner_form is not None: P = fenics.PETScMatrix(P) # pylint: disable=invalid-name self.assembler_pc.assemble(P) P.ident_zeros() P_petsc = fenics.as_backend_type(P).mat() # pylint: disable=invalid-name, P_petsc.scale(-1) P_petsc.assemble()
[docs] def assemble_i_function( self, ts: PETSc.TS, # pylint: disable=unused-argument t: float, # pylint: disable=unused-argument u: PETSc.Vec, # pylint: disable=unused-argument u_dot: PETSc.Vec, f: PETSc.Vec, ) -> None: """Interface for PETSc TSSetIFunction - this describes the time derivative. Args: ts (PETSc.TS): The TS solver. t (float): The current time step. u (PETSc.Vec): The current iterate. u_dot (PETSc.Vec): The time derivative. f (PETSc.Vec): The vector for storing the IFunction. """ self.mass_matrix_petsc.mult(u_dot, f) f.assemble()
[docs] def assemble_mass_matrix( self, ts: PETSc.TS, # pylint: disable=unused-argument t: float, # pylint: disable=unused-argument u: PETSc.Vec, # pylint: disable=unused-argument u_dot: PETSc.Vec, # pylint: disable=unused-argument sigma: float, A: PETSc.Mat, # pylint: disable=invalid-name, B: PETSc.Mat, # pylint: disable=invalid-name, ) -> None: """Interface for PETSc TSSetIJacobian. This describes the Jacobian of the time derivative. Here, the IJacobian and its preconditioner are given by the FEM mass matrices. Args: ts (PETSc.TS): The TS solver. t (float): The current time step. u (PETSc.Vec): The current iterate. u_dot (PETSc.Vec): The time derivative of the current iterate. sigma (float): See PETSc documentation. A (PETSc.Mat): The matrix for storing the IJacobian. B (PETSc.Mat): The matrix for storing the preconditioner of the IJacobian. """ A.aypx(0.0, self.mass_matrix_petsc) A.scale(sigma) A.assemble() if self.preconditioner_form is not None: B.aypx(0.0, self.mass_matrix_petsc) B.scale(sigma) B.assemble()
[docs] def compute_nonlinear_residual(self, u: PETSc.Vec) -> float: """Computes the residual of the nonlinear equation. Args: u (PETSc.Vec): The current iterate. Returns: float: The norm of the nonlinear residual. """ self.u.vector().vec().aypx(0.0, u) self.u.vector().apply("") self.assembler.assemble(self.residual_convergence, self.u.vector()) residual_norm: float = self.residual_convergence.norm("l2") return residual_norm
[docs] def monitor( self, ts: PETSc.TS, i: int, t: float, u: PETSc.Vec, # pylint: disable=unused-argument ) -> None: """The monitoring function for the pseudo time stepping. Args: ts (PETSc.TS): The TS solver. i (int): The current iteration number. t (float): The current time step. u (PETSc.Vec): The current iterate. """ self.res_current = self.compute_nonlinear_residual(u) self.equation_residuals_current = np.array( _utils.compute_equation_residuals( self.residual_convergence.vec(), self.space ) ) if self.res_initial == 0: relative_residual = self.res_current else: relative_residual = self.res_current / self.res_initial dt = ts.getTimeStep() monitor_str = ( f"{i:d} TS dt {dt:g} time {t:g} " f"relative resid {relative_residual:.6e} " f"absolute resid {self.res_current:.6e}" ) if self.comm.rank == 0 and self.has_monitor_output: print(monitor_str, flush=True) for j, res in enumerate(self.equation_residuals_current): res_init = self.equation_residuals_initial[j] if res_init == 0: res_rel = res else: res_rel = res / res_init log.debug( f"Equation {j:d} relative resid {res_rel:.3e} absolute resid {res:.3e}" ) self.rtol = cast(float, self.rtol) self.atol = cast(float, self.atol) if self.res_current < np.maximum(self.rtol * self.res_initial, self.atol): ts.setConvergedReason(PETSc.TS.ConvergedReason.CONVERGED_USER) max_time = ts.getMaxTime() ts.setTime(max_time) elif self.res_current > self.dtol * self.res_initial: if hasattr(PETSc, "garbage_cleanup"): self._destroy_petsc_objects() ts.destroy() PETSc.garbage_cleanup(comm=self.comm) raise _exceptions.PETScTSError(-5)
def _destroy_petsc_objects(self) -> None: self.residual_convergence.vec().destroy() self.M_petsc.destroy() self.mass_matrix_petsc.destroy() if self.P_petsc is not None: self.P_petsc.destroy() if self.residual_shift is not None: self.residual_shift.destroy() if self.MP_petsc is not None: self.MP_petsc.destroy()
[docs] def solve(self) -> fenics.Function: """Solves the (nonlinear) problem with pseudo time stepping. Returns: The solution obtained by the solver. """ log.begin("Solving the PDE system with pseudo time stepping.", level=log.DEBUG) ts = PETSc.TS().create(self.comm) ts.setProblemType(ts.ProblemType.NONLINEAR) ts.setIFunction(self.assemble_i_function, self.mass_application_petsc) ts.setIJacobian(self.assemble_mass_matrix, self.M_petsc, self.MP_petsc) ts.setRHSFunction(self.assemble_residual, self.residual_petsc) ts.setRHSJacobian(self.assemble_jacobian, self.A_petsc, self.P_petsc) ksp = ts.getSNES().getKSP() _utils.linalg.setup_fieldsplit_preconditioner(self.u, ksp, self.petsc_options) if fenics.PETScMatrix(self.A_petsc).empty(): self.assemble_i_function( ts, 0.0, None, self.u.vector().vec(), self.mass_application_petsc, ) self.assemble_mass_matrix( ts, 0.0, self.u.vector().vec(), self.u.vector().vec(), 0.0, self.M_petsc, self.MP_petsc, ) self.assemble_residual(ts, 0.0, self.u.vector().vec(), self.residual_petsc) self.assemble_jacobian( ts, 0.0, self.u.vector().vec(), self.A_petsc, self.P_petsc ) self.res_initial = self.compute_nonlinear_residual(self.u.vector().vec()) self.equation_residuals_initial = np.array( _utils.compute_equation_residuals( self.residual_convergence.vec(), self.space ) ) ts.setTime(0.0) ts.setMonitor(self.monitor) reduced_petsc_options = { key: value for key, value in self.petsc_options.items() if key not in ["ts_monitor", "ts_converged_its"] } _utils.setup_petsc_options([ts], [reduced_petsc_options]) if self.rtol is None: self.rtol = ts.getSNES().rtol if self.atol is None: self.atol = ts.getSNES().atol if self.max_iter is not None: ts.setMaxSteps(self.max_iter) x = fenics.Function(self.space) x.vector().vec().aypx(0.0, self.u.vector().vec()) x.vector().apply("") ts.solve(x.vector().vec()) converged_reason = ts.getConvergedReason() self.u.vector().vec().aypx(0.0, x.vector().vec()) self.u.vector().apply("") log.end() if hasattr(PETSc, "garbage_cleanup"): self._destroy_petsc_objects() ts.destroy() PETSc.garbage_cleanup(comm=self.comm) if converged_reason < 0: raise _exceptions.PETScTSError(converged_reason) elif converged_reason == PETSc.TS.ConvergedReason.CONVERGED_ITS: if not self.ts_converged_its: raise _exceptions.PETScTSError(converged_reason) return self.u
[docs] def ts_pseudo_solve( nonlinear_form: ufl.Form, u: fenics.Function, bcs: fenics.DirichletBC | list[fenics.DirichletBC], derivative: ufl.Form | None = None, petsc_options: _typing.KspOption | None = None, shift: ufl.Form | None = None, rtol: float | None = None, atol: float | None = None, max_iter: int | None = None, A_tensor: fenics.PETScMatrix | None = None, # pylint: disable=invalid-name b_tensor: fenics.PETScVector | None = None, preconditioner_form: ufl.Form | None = None, excluded_from_time_derivative: list[int] | None = None, ) -> fenics.Function: """Solve a nonlinear PDE problem with PETSc TS and pseudo time stepping. An overview over possible PETSc command line options for the TS can be found at `<https://petsc.org/release/manualpages/TS/>`_ Args: nonlinear_form (ufl.Form): The variational form of the nonlinear problem to be solved by Newton's method. u (fenics.Function): 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 (fenics.DirichletBC | list[fenics.DirichletBC]): A list of DirichletBCs for the nonlinear variational problem. derivative (ufl.Form | None, optional): The Jacobian of nonlinear_form, used for the Newton method. Default is None, and in this case the Jacobian is computed automatically with AD. petsc_options (_typing.KspOption | None, optional): The options for PETSc TS object. Defaults to None. shift (ufl.Form | None, optional): A shift term, if the right-hand side of the nonlinear problem is not zero, but shift. Defaults to None. rtol (float | None, optional): Relative tolerance of the solver. If this is set to a float, the float is used as relative tolerance. If this is set to None, then the relative tolerance of the SNES object is used, which can be defined with the petsc options `snes_rtol rtol`. Defaults to None. atol (float | None, optional): Absolute tolerance of the solver. If this is set to a float, the float is used as absolute tolerance. If this is set to None, then the absolute tolerance of the SNES object is used, which can be defined with the petsc options `snes_atol atol`. Defaults to None. max_iter (int | None, optional): Maximum number of iterations carried out by the method. Overrides the specification in the petsc_options. Defaults to None. A_tensor (fenics.PETScMatrix | None, optional): A fenics.PETScMatrix for storing the left-hand side of the linear sub-problem. Defaults to None. b_tensor (fenics.PETScVector | None, optional): A fenics.PETScVector for storing the right-hand side of the linear sub-problem. Defaults to None. preconditioner_form (ufl.Form | None, optional): A UFL form which defines the preconditioner matrix. Defaults to None. excluded_from_time_derivative (list[int] | None, optional): A list of indices for those components that are not included in the time derivative. Example: The pressure for incompressible Navier-Stokes. Default is None, so that all components are included for the time derivative. Returns: The solution in form of a FEniCS Function. """ solver = TSPseudoSolver( nonlinear_form, u, bcs, derivative=derivative, petsc_options=petsc_options, shift=shift, rtol=rtol, atol=atol, max_iter=max_iter, A_tensor=A_tensor, b_tensor=b_tensor, preconditioner_form=preconditioner_form, excluded_from_time_derivative=excluded_from_time_derivative, ) solution = solver.solve() return solution