Source code for cashocs.verification

# 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/>.

"""Taylor tests for verifying gradient correctness."""

from __future__ import annotations

from typing import TYPE_CHECKING

import fenics
import numpy as np

from cashocs import io
from cashocs import log

if TYPE_CHECKING:
    from cashocs._optimization import optimal_control
    from cashocs._optimization import shape_optimization


def _initialize_control_variable(
    ocp: optimal_control.OptimalControlProblem, u: list[fenics.Function] | None
) -> list[fenics.Function]:
    """Initializes the control variable, so that it can be restored later.

    Args:
        ocp: The corresponding optimal control problem.
        u: The control variable.

    Returns:
        A copy of the control variables

    """
    if u is None:
        u = []
        for j in range(len(ocp.db.function_db.controls)):
            temp = fenics.Function(ocp.db.function_db.control_spaces[j])
            temp.vector().vec().aypx(0.0, ocp.db.function_db.controls[j].vector().vec())
            temp.vector().apply("")
            u.append(temp)

    # check if u and ocp.controls coincide, if yes, make a deepcopy
    ids_u = [fun.id() for fun in u]
    ids_controls = [fun.id() for fun in ocp.db.function_db.controls]
    if ids_u == ids_controls:
        u = []
        for j in range(len(ocp.db.function_db.controls)):
            temp = fenics.Function(ocp.db.function_db.control_spaces[j])
            temp.vector().vec().aypx(0.0, ocp.db.function_db.controls[j].vector().vec())
            temp.vector().apply("")
            u.append(temp)

    return u


def _create_control_perturbation(
    ocp: optimal_control.OptimalControlProblem,
    h: list[fenics.Function] | None,
    custom_rng: np.random.RandomState,
) -> list[fenics.Function]:
    if h is None:
        h = []
        for function_space in ocp.db.function_db.control_spaces:
            temp = fenics.Function(function_space)
            temp.vector().vec().setValues(
                range(function_space.dim()), custom_rng.rand(function_space.dim())
            )
            temp.vector().apply("")
            h.append(temp)

    return h


[docs] def control_gradient_test( ocp: optimal_control.OptimalControlProblem, u: list[fenics.Function] | None = None, h: list[fenics.Function] | None = None, rng: np.random.RandomState | None = None, verbose: bool = True, ) -> float: """Performs a Taylor test to verify that the computed gradient is correct. Args: ocp: The underlying optimal control problem, for which the gradient of the reduced cost function shall be verified. u: The point, at which the gradient shall be verified. If this is ``None``, then the current controls of the optimization problem are used. Default is ``None``. h: The direction(s) for the directional (Gâteaux) derivative. If this is ``None``, one random direction is chosen. Default is ``None``. rng: A numpy random state for calculating a random direction verbose: Prints the result to the console, if ``True``. Default is ``True``. Returns: The convergence order from the Taylor test. If this is (approximately) 2 or larger, everything works as expected. """ custom_rng = rng or np.random.RandomState() initial_state = [] for j in range(len(ocp.db.function_db.controls)): temp = fenics.Function(ocp.db.function_db.control_spaces[j]) temp.vector().vec().aypx(0.0, ocp.db.function_db.controls[j].vector().vec()) temp.vector().apply("") initial_state.append(temp) u = _initialize_control_variable(ocp, u) h = _create_control_perturbation(ocp, h, custom_rng) for j in range(len(ocp.db.function_db.controls)): ocp.db.function_db.controls[j].vector().vec().aypx(0.0, u[j].vector().vec()) ocp.db.function_db.controls[j].vector().apply("") # Compute the norm of u for scaling purposes. scaling = np.sqrt( ocp.form_handler.scalar_product( ocp.db.function_db.controls, ocp.db.function_db.controls ) ) if scaling < 1e-3: scaling = 1.0 # pylint: disable=protected-access ocp._erase_pde_memory() cost_functional_at_u = ocp.reduced_cost_functional.evaluate() gradient_at_u = ocp.compute_gradient() directional_derivative = ocp.form_handler.scalar_product(gradient_at_u, h) epsilons = [scaling * 1e-2 / 2**i for i in range(4)] residuals = [] for eps in epsilons: for j in range(len(ocp.db.function_db.controls)): ocp.db.function_db.controls[j].vector().vec().aypx(0.0, u[j].vector().vec()) ocp.db.function_db.controls[j].vector().apply("") ocp.db.function_db.controls[j].vector().vec().axpy(eps, h[j].vector().vec()) ocp.db.function_db.controls[j].vector().apply("") # pylint: disable=protected-access ocp._erase_pde_memory() cost_functional_at_v = ocp.reduced_cost_functional.evaluate() res = abs( cost_functional_at_v - cost_functional_at_u - eps * directional_derivative ) residuals.append(res) if np.min(residuals) < 1e-14: log.warning("The Taylor remainder is close to 0, results may be inaccurate.") comm = ocp.db.geometry_db.mpi_comm rates = compute_convergence_rates(epsilons, residuals) if verbose and comm.rank == 0: print(f"Taylor test convergence rate: {rates}", flush=True) comm.barrier() for j in range(len(ocp.db.function_db.controls)): ocp.db.function_db.controls[j].vector().vec().aypx( 0.0, initial_state[j].vector().vec() ) ocp.db.function_db.controls[j].vector().apply("") min_rate: float = rates[-1] return min_rate
[docs] def shape_gradient_test( sop: shape_optimization.ShapeOptimizationProblem, h: list[fenics.Function] | None = None, rng: np.random.RandomState | None = None, verbose: bool = True, ) -> float: """Performs a Taylor test to verify that the computed shape gradient is correct. Args: sop: The underlying shape optimization problem. h: The direction used to compute the directional derivative. If this is ``None``, then a random direction is used (default is ``None``). rng: A numpy random state for calculating a random direction verbose: Prints the result to the console, if ``True``. Default is ``True``. Returns: The convergence order from the Taylor test. If this is (approximately) 2 or larger, everything works as expected. """ comm = sop.db.geometry_db.mpi_comm custom_rng = rng or np.random if h is None: h = [fenics.Function(sop.db.function_db.control_spaces[0])] h[0].vector().set_local(custom_rng.rand(h[0].vector().local_size())) h[0].vector().apply("") # ensure that the shape boundary conditions are applied for bc in sop.form_handler.bcs_shape: bc.apply(h[0].vector()) h[0].vector().apply("") if sop.form_handler.use_fixed_dimensions: h[0].vector().vec()[sop.form_handler.fixed_indices] = np.array( [0.0] * len(sop.form_handler.fixed_indices) ) h[0].vector().apply("") transformation = fenics.Function(sop.db.function_db.control_spaces[0]) # pylint: disable=protected-access sop._erase_pde_memory() current_cost_functional = sop.reduced_cost_functional.evaluate() shape_grad = sop.compute_shape_gradient() shape_derivative_h = sop.form_handler.scalar_product(shape_grad, h) coords = io.mesh.gather_coordinates(sop.mesh_handler.mesh) if comm.rank == 0: box_lower = np.min(coords) box_upper = np.max(coords) else: box_lower = 0.0 box_upper = 0.0 comm.barrier() box_lower = comm.bcast(box_lower, root=0) box_upper = comm.bcast(box_upper, root=0) length = box_upper - box_lower epsilons = [length * 1e-4 / 2**i for i in range(4)] residuals = [] for eps in epsilons: transformation.vector().vec().aypx(0.0, h[0].vector().vec()) transformation.vector().apply("") transformation.vector().vec().scale(eps) transformation.vector().apply("") if sop.mesh_handler.move_mesh(transformation): # pylint: disable=protected-access sop._erase_pde_memory() perturbed_cost_functional = sop.reduced_cost_functional.evaluate() res = abs( perturbed_cost_functional - current_cost_functional - eps * shape_derivative_h ) residuals.append(res) sop.mesh_handler.revert_transformation() else: log.warning( "Deformation did not yield a valid finite element mesh. " "Results of the test are probably not accurate." ) residuals.append(float("inf")) if np.min(residuals) < 1e-14: log.warning("The Taylor remainder is close to 0, results may be inaccurate.") rates = compute_convergence_rates(epsilons, residuals) if verbose and comm.rank == 0: print(f"Taylor test convergence rate: {rates}", flush=True) comm.barrier() result: float = rates[-1] return result
[docs] def compute_convergence_rates( epsilons: list[float], residuals: list[float] ) -> list[float]: """Computes the convergence rate of the Taylor test. Args: epsilons: The step sizes. residuals: The corresponding residuals. Returns: The computed convergence rates """ rates: list[float] = [] for i in range(1, len(epsilons)): rate: float = np.log(residuals[i] / residuals[i - 1]) / np.log( epsilons[i] / epsilons[i - 1] ) rates.append(rate) return rates