"""
Define the Brent class.
Based on implementation of the Brent algorithm in OpenMDAO 2.0 using brentq from scipy.
"""
import os
import networkx as nx
import numpy as np
from scipy.optimize import brentq
from openmdao.recorders.recording_iteration_stack import Recording
from openmdao.solvers.solver import NonlinearSolver
from openmdao.utils.om_warnings import issue_warning
CITATION = """@BOOK{Brent1973-dm,
title = "Algorithms for Minimization Without Derivatives",
author = "Brent, R P",
publisher = "Prentice-Hall",
pages = "3-4",
year = 1973,
address = "Englewood Cliffs, NJ"
}"""
[docs]
class BrentSolver(NonlinearSolver):
"""
Brent solver.
Root finding using Brent's method. This is a specialized solver that can only converge a single
scalar residual. You must specify the name of the implicit state-variable via the `state_target`
option. You must specify `lower_bound` and `upper_bound` for the upper and lower.
Parameters
----------
**kwargs : dict
Options dictionary.
Attributes
----------
state_target : str
Relative openmdao varpath to the state.
upper_target : str or None
Relative openmdao varpath to the upper bound. Only used if the lower bound is computed
somewhere in the model.
lower_target : str or None
Relative openmdao varpath to the lower bound. Only used if the lower bound is computed
somewhere in the model.
norm : float
The current norm.
"""
SOLVER = 'NL: BRENT'
[docs]
def __init__(self, **kwargs):
"""
Initialize all attributes.
"""
super().__init__(**kwargs)
self.state_target = None
self.upper_target = None
self.lower_target = None
self.norm = 1.0
self.cite = CITATION
def _declare_options(self):
"""
Declare options before kwargs are processed in the init method.
"""
super()._declare_options()
self.options.declare('state_target', types=str, allow_none=True, default=None,
desc='Name of the implicit state to be solved')
self.options.declare('lower_bound', default=0.0,
desc='Lower bound for the root search')
self.options.declare('upper_bound', default=100.0,
desc='Upper bound for the root search')
self.options.declare('lower_bound_target', allow_none=True, default=None,
desc='Openmdao path to the lower bound. When specified, this takes '
'precedence over the value specified in lower_bound.')
self.options.declare('upper_bound_target', allow_none=True, default=None,
desc='Openmdao path to the upper bound. When specified, this takes '
'precedence over the value specified in upper_bound.')
# Remove unsupported options.
self.options.undeclare('stall_limit')
self.options.undeclare('stall_tol')
self.options.undeclare('stall_tol_type')
def _setup_solvers(self, system, depth):
"""
Assign system instance, set depth, and optionally perform setup.
Parameters
----------
system : <System>
pointer to the owning system.
depth : int
depth of the current system (already incremented).
"""
super()._setup_solvers(system, depth)
if self.options['state_target'] is None:
msg = f"{self.msginfo}: 'state_target' option in Brent solver must be specified."
raise ValueError(msg)
self.state_target = self.options['state_target']
if self.state_target not in system._outputs:
msg = f"{self.msginfo}: 'state_target' variable '{self.state_target}' not found."
raise ValueError(msg)
self.upper_target = upper = self.options['upper_bound_target']
if upper is not None and upper not in system._outputs:
msg = f"{self.msginfo}: 'upper_bound_target' variable '{upper}' not found."
raise ValueError(msg)
self.lower_target = lower = self.options['lower_bound_target']
if lower is not None and lower not in system._outputs:
msg = f"{self.msginfo}: 'lower_bound_target' variable '{lower}' not found."
raise ValueError(msg)
# Make sure we only have one state.
valid = True
n_imp = 0
from openmdao.core.implicitcomponent import ImplicitComponent
for sub in system.system_iter(recurse=False, typ=ImplicitComponent):
if n_imp > 0:
# Found more than 1 implicitcomponent
valid = False
break
n_imp = len(sub._outputs)
if n_imp > 1:
# This implicitcomponent has more than 1 state
valid = False
break
if not valid:
msg = f"{self.msginfo}: Brent can only solve 1 single implicit state."
raise ValueError(msg)
# Check for cycles without an implicit state. Such a cycle could work in theory if the
# user knew where openmdao broke the cycle, but it is better to break the cycle and
# insert a balance.
graph = system.compute_sys_graph()
if not nx.is_directed_acyclic_graph(graph) and n_imp == 0:
msg = f"{self.msginfo}: Brent does not support cycles."
raise ValueError(msg)
def _solve(self):
"""
Run the iterative solver.
The base class is overridden because scipy brentq controls the iteration.
"""
system = self._system()
atol = self.options['atol']
rtol = self.options['rtol']
iprint = self.options['iprint']
self._mpi_print_header()
self._iter_count = 0
norm0, norm = self._iter_initialize()
self._norm0 = norm0
self._mpi_print(self._iter_count, norm, norm / norm0)
# Brentq is external and controls convergence.
self._run_all_iterations()
# flag for the print statements. we only print on root if USE_PROC_FILES is not set to True
print_flag = system.comm.rank == 0 or os.environ.get('USE_PROC_FILES')
prefix = self._solver_info.prefix + self.SOLVER
norm = self.norm
# Solver terminated early because a Nan in the norm doesn't satisfy the while-loop
# conditionals.
if np.isinf(norm) or np.isnan(norm):
self._inf_nan_failure()
# Solver hit maxiter without meeting desired tolerances.
elif norm > atol and norm / norm0 > rtol:
self._convergence_failure()
# Solver converged
elif print_flag:
if iprint == 1:
print(prefix + ' Converged in {} iterations'.format(self._iter_count))
elif iprint == 2:
print(prefix + ' Converged')
def _iter_initialize(self):
"""
Perform any necessary pre-processing operations.
Returns
-------
float
initial error.
float
error at the first iteration.
"""
system = self._system()
if self.options['debug_print']:
self._err_cache['inputs'] = system._inputs._copy_vars()
self._err_cache['outputs'] = system._outputs._copy_vars()
with Recording(type(self).__name__, 0, self) as rec:
self._solver_info.append_solver()
# should call the subsystems solve before computing the first residual
self._gs_iter()
self._solver_info.pop()
self._run_apply()
norm = self._iter_get_norm()
rec.abs = norm
norm0 = norm if norm != 0.0 else 1.0
rec.rel = norm / norm0
return norm0, norm
def _run_all_iterations(self):
"""
Perform the operations in the iteration loop.
"""
system = self._system()
lower = self.options['lower_bound']
if self.lower_target is not None:
lower = system._outputs[self.lower_target][0]
upper = self.options['upper_bound']
if self.upper_target is not None:
upper = system._outputs[self.upper_target][0]
kwargs = {
'maxiter': self.options['maxiter'],
'a': lower,
'b': upper,
'full_output': False, # False, because we don't use the info, so just wastes operations
'args': (system),
'xtol': self.options['atol'],
'rtol': self.options['rtol'],
}
try:
xstar = brentq(self._eval, **kwargs)
# Run the final point because last brentq point is a bracketing point.
self._eval(xstar, system)
except RuntimeError as err:
# Let OpenMDAO handle nonconvergence.
# Print actual error text in case it has some useful info from scipy.
issue_warning(f"RuntimError from scipy brentq. Error was: {err}")
def _eval(self, x, system):
"""
Call from scipy to evaluate the model at the requested point.
"""
system._outputs[self.state_target] = x
norm0 = self._norm0
# Run the model.
with Recording(type(self).__name__, self._iter_count, self) as rec:
self._solver_info.append_solver()
self._gs_iter()
self._solver_info.pop()
self._iter_count += 1
self._run_apply()
norm = self._iter_get_norm()
# Save the norm values in the context manager so they can also be recorded.
rec.abs = norm
if norm0 == 0:
norm0 = 1
rec.rel = norm / norm0
self.norm = norm
self._mpi_print(self._iter_count, norm, norm / norm0)
return system._residuals[self.state_target][0]