"""
OpenMDAO Wrapper for the modOpt optimization library.
modOpt is a modular optimization framework providing interfaces to various
gradient-based and gradient-free optimization algorithms.
Available Optimizers
--------------------
Gradient-Based:
- SLSQP: Sequential Least Squares Programming (supports all constraint types)
- PySLSQP: Pure Python implementation of SLSQP
- BFGS: Broyden-Fletcher-Goldfarb-Shanno (unconstrained)
- LBFGSB: Limited-memory BFGS with bounds
- TrustConstr: Trust-region constrained algorithm
- SNOPT: Sparse Nonlinear Optimizer (requires license)
- IPOPT: Interior Point Optimizer (requires separate installation)
- OpenSQP: A sequential quadratic programming optimizer built into modOpt
Gradient-Free:
- COBYLA: Constrained Optimization BY Linear Approximation
- COBYQA: Constrained Optimization BY Quadratic Approximation
- NelderMead: Nelder-Mead simplex algorithm (unconstrained)
Notes
-----
- SLSQP is the default optimizer and supports gradients, bounds, and all constraint types
- SNOPT and IPOPT offer high performance but require separate installation/licenses
- Linear constraints are handled efficiently by pre-computing their Jacobians
- Gradient-free methods (COBYLA, COBYQA, NelderMead) are useful when derivatives are unavailable
See the modOpt documentation at https://modopt.readthedocs.io for detailed information
on algorithm-specific options and capabilities.
"""
import sys
import numpy as np
import json
from collections import OrderedDict
from openmdao.core.constants import _DEFAULT_REPORTS_DIR, _ReprClass
from openmdao.core.driver import Driver, RecordingDebugging, filter_by_meta
from openmdao.utils.om_warnings import issue_warning
from openmdao.utils.mpi import MPI
from openmdao.core.group import Group
try:
# modopt.core.visualization calls matplotlib.use('TkAgg') at import time.
# Save and restore the backend so modopt does not permanently change it,
# which would break OpenMDAO visualization tools in headless environments.
try:
import matplotlib as _mpl
_mpl_backend = _mpl.get_backend()
except ImportError:
_mpl_backend = None
import modopt as mo
problem = mo.Problem
if _mpl_backend is not None:
try:
import matplotlib as _mpl
_mpl.use(_mpl_backend)
except Exception:
pass
except ImportError:
mo = None
problem = object
except Exception as err:
mo = err
problem = object
# Gradient-based algorithms from modOpt
_gradient_optimizers = {
'SLSQP', 'PySLSQP', 'BFGS', 'LBFGSB', 'TrustConstr',
'SNOPT', 'IPOPT', 'OpenSQP',
}
# Algorithms that support constraints (inequality and/or equality)
_constraint_optimizers = {
'SLSQP', 'PySLSQP', 'COBYLA', 'TrustConstr', 'COBYQA',
'SNOPT', 'IPOPT', 'OpenSQP',
}
# Algorithms that support equality constraints
_eq_constraint_optimizers = {
'SLSQP', 'PySLSQP', 'TrustConstr', 'SNOPT', 'IPOPT',
'COBYQA', 'OpenSQP',
}
# Algorithms that support bounds
_bounds_optimizers = {
'SLSQP', 'PySLSQP', 'LBFGSB', 'TrustConstr', 'COBYLA',
'COBYQA', 'SNOPT', 'IPOPT', 'OpenSQP',
}
# Gradient-based algorithms that also support constraints (intersection of both sets)
_constraint_grad_optimizers = _gradient_optimizers & _constraint_optimizers
# Optimizers that use solver_options argument (different API from others)
_solver_options_optimizers = {
'SLSQP', 'PySLSQP', 'COBYLA', 'BFGS', 'LBFGSB', 'NelderMead', 'COBYQA',
'TrustConstr', 'SNOPT', 'IPOPT', 'ConvexQPSolvers',
}
# All available optimizers (excluding CVXOPT and ConvexQPSolvers which require Hessian)
_all_optimizers = {
'SLSQP', 'PySLSQP', 'COBYLA', 'BFGS', 'LBFGSB', 'NelderMead',
'COBYQA', 'TrustConstr', 'OpenSQP', 'SNOPT', 'IPOPT', 'CVXOPT',
'ConvexQPSolvers',
}
CITATIONS = """
@article{modopt,
author = {Joshy, Anugrah J. and Hwang, John T.},
title = "{modOpt: A Modular development environment and library for optimization algorithms}",
journal = {Advances in Engineering Software},
volume = {213},
month = feb,
year = {2026},
articleno = {104084},
doi = {10.1016/j.advengsoft.2025.104084}
}
"""
[docs]
class modOptProblem(problem):
"""
modOpt Problem that delegates objective and constraint evaluation to an OpenMDAO driver.
This class wraps an OpenMDAO problem as a modOpt optimization problem, translating
between modOpt's interface and OpenMDAO's driver interface.
Parameters
----------
driver : modOptDriver
The OpenMDAO driver managing the optimization.
x_info : OrderedDict
Dictionary with design variable names as keys and dictionaries containing
'init', 'lower', and 'upper' values as the values.
lin_con_jac : dict or None
Pre-computed Jacobian for linear constraints in dictionary format with
(constraint_name, design_var_name) tuples as keys, or None if no linear constraints.
lin_con_bounds : dict
Dictionary with linear constraint names as keys and dictionaries containing
'lower', 'upper', and 'size' as values.
nl_con_bounds : dict
Dictionary with nonlinear constraint names as keys and dictionaries containing
'lower', 'upper', and 'size' as values.
nl_con_jac_sparsity : dict
Sparsity pattern for nonlinear constraint Jacobian with (constraint_name, design_var_name)
tuples as keys and dictionaries containing 'rows' and 'cols' arrays as values.
Attributes
----------
driver : modOptDriver
Reference to the OpenMDAO driver.
x_info : OrderedDict
Design variable metadata including initial values and bounds.
lin_con_jac : dict or None
Pre-computed linear constraint Jacobian.
lin_con_bounds : dict
Linear constraint bounds.
nl_con_bounds : dict
Nonlinear constraint bounds.
nl_con_jac_sparsity : dict
Sparsity pattern for nonlinear constraint Jacobian.
all_nl_relevant_dvs : set
Set of all design variable names that are relevant to any nonlinear constraint.
obj_name : str
Name of the objective function.
_con_cache : dict or None
Cached constraint values to avoid redundant evaluations.
_all_constraint_names : list of str
Ordered list of all constraint names (linear followed by nonlinear).
"""
[docs]
def __init__(self, driver, x_info, lin_con_jac, lin_con_bounds,
nl_con_bounds, nl_con_jac_sparsity, all_nl_relevant_dvs):
"""
Initialize the modOptProblem.
Parameters
----------
driver : modOptDriver
The OpenMDAO driver managing the optimization.
x_info : OrderedDict
Dictionary with design variable names as keys and dictionaries containing
'init', 'lower', and 'upper' values as the values.
lin_con_jac : dict or None
Pre-computed Jacobian for linear constraints in dictionary format with
(constraint_name, design_var_name) tuples as keys.
lin_con_bounds : dict
Dictionary with linear constraint names as keys and dictionaries containing
'lower', 'upper', and 'size' as values.
nl_con_bounds : dict
Dictionary with nonlinear constraint names as keys and dictionaries containing
'lower', 'upper', and 'size' as values.
nl_con_jac_sparsity : dict
Sparsity pattern for nonlinear constraint Jacobian with (constraint_name,
design_var_name) tuples as keys and dictionaries containing 'rows' and 'cols'
arrays defining the sparse structure.
all_nl_relevant_dvs : set
Set of all design variable names that are relevant to any nonlinear constraint.
"""
self.driver = driver
self.x_info = x_info
self.lin_con_jac = lin_con_jac
self.lin_con_bounds = lin_con_bounds
self.nl_con_bounds = nl_con_bounds
self.nl_con_jac_sparsity = nl_con_jac_sparsity
self.all_nl_relevant_dvs = all_nl_relevant_dvs
# modOpt does not support multiple objectives
self.obj_name = list(self.driver._objs)[0]
self._con_cache = None
self._all_constraint_names = list(self.lin_con_bounds) + list(self.nl_con_bounds)
super().__init__()
[docs]
def initialize(self):
"""
Set the modOpt problem name.
Called by the modOpt Problem base class during initialization.
"""
self.problem_name = 'modOpt_problem'
[docs]
def setup(self):
"""
Add design variables, constraints, and objective to the modOpt Problem.
"""
for name, info in self.x_info.items():
shape = info['init'].shape if isinstance(info['init'], np.ndarray) else (1,)
self.add_design_variables(
name=name,
shape=shape,
lower=info['lower'],
upper=info['upper'],
vals=info['init'],
)
self.add_objective(name=self.obj_name)
for name, info in self.lin_con_bounds.items():
self.add_constraints(
name=name,
shape=(info['size'],),
lower=info['lower'],
upper=info['upper'],
)
for name, info in self.nl_con_bounds.items():
self.add_constraints(
name=name,
shape=(info['size'],),
lower=info['lower'],
upper=info['upper'],
)
[docs]
def setup_derivatives(self):
"""
Declare objective and constraint gradients to the modOpt problem.
This method informs modOpt about which derivatives are available.
For linear constraints, the Jacobian is provided directly. For nonlinear
constraints, derivatives are computed on-demand, with sparsity information
if available from OpenMDAO. Only relevant Jacobians (based on OpenMDAO's
relevance analysis) are declared.
"""
for des_var in self.x_info.keys():
# Objective gradient doesnt seem to support sparsity declaration
self.declare_objective_gradient(wrt=des_var)
# Set fixed linear jacobians - only for relevant design variable/constraint pairs
for lin_con in self.lin_con_bounds.keys():
# Only declare if this pair exists (i.e., is relevant)
if (lin_con, des_var) in self.lin_con_jac:
self.declare_constraint_jacobian(
of=lin_con,
wrt=des_var,
vals=self.lin_con_jac[lin_con, des_var]
)
# Declare nonlinear constraint Jacobian with sparsity if available
# Only for relevant design variable/constraint pairs
for nl_con in self.nl_con_bounds.keys():
# Only declare if this pair exists (i.e., is relevant)
if (nl_con, des_var) in self.nl_con_jac_sparsity:
self.declare_constraint_jacobian(
of=nl_con,
wrt=des_var,
rows=self.nl_con_jac_sparsity[nl_con, des_var]['rows'],
cols=self.nl_con_jac_sparsity[nl_con, des_var]['cols'],
)
[docs]
def compute_objective(self, dvs, obj):
"""
Evaluate the objective function at the given design point.
This method updates the OpenMDAO model with new design variables,
runs the model, and retrieves the objective value. Constraint values
are also cached for efficiency.
Parameters
----------
dvs : <array_manager.core.native_formats.vector.Vector>
Vector with the current design variable names and values.
obj : dict
Dictionary to store the computed objective value.
"""
model = self.driver._problem().model
try:
self._update_desvar_values(dvs)
self._run_model()
# Get the objective function evaluations
f_new = next(iter(self.driver.get_objective_values().values()))
self._con_cache = self.driver.get_constraint_values()
obj[self.obj_name] = f_new
except Exception:
# Clean up solver print stack and store exception for re-raising later
self._handle_callback_exception(model)
obj[self.obj_name] = np.nan
[docs]
def compute_constraints(self, dvs, cons):
"""
Compute constraint values.
Uses cached constraint values if available, otherwise evaluates model.
Parameters
----------
dvs : <array_manager.core.native_formats.vector.Vector>
Vector with the current design variable names and values.
cons : dict
Dictionary to store the computed constraint values.
"""
model = self.driver._problem().model
try:
self._update_desvar_values(dvs)
# Use cached constraint values from compute_objective if available
if self._con_cache is None:
self._run_model()
vals = self.driver.get_constraint_values()
else:
vals = self._con_cache
self._con_cache = None # Clear cache after use
for name in self._all_constraint_names:
cons[name] = vals[name].flatten()
except Exception:
# Clean up solver print stack and store exception for re-raising later
self._handle_callback_exception(model)
for name in self._all_constraint_names:
size = self._get_constraint_size(name)
cons[name] = np.full(size, np.nan)
[docs]
def compute_objective_gradient(self, dvs, grad):
"""
Compute the gradient of the objective function.
Parameters
----------
dvs : <array_manager.core.native_formats.vector.Vector>
Vector with the current design variable names and values.
grad : dict
Dictionary to store the gradient values. Keys are design variable names,
values are gradient arrays with respect to the objective.
"""
model = self.driver._problem().model
try:
totals = self.driver._problem().compute_totals(
of=[self.obj_name],
wrt=list(self.x_info),
)
# First time through, check for zero row/col.
if self.driver._check_obj_grad and self.driver._total_jac is not None:
for subsys in model.system_iter(include_self=True, recurse=True, typ=Group):
if subsys._has_approx:
break
else:
raise_error = self.driver.options['singular_jac_behavior'] == 'error'
self.driver._total_jac.check_total_jac(raise_error=raise_error,
tol=self.driver.options['singular_jac_tol'])
self.driver._check_obj_grad = False
for des_var in self.x_info.keys():
grad[des_var] = totals[self.obj_name, des_var]
except Exception:
# Clean up solver print stack and store exception for re-raising later
self._handle_callback_exception(model)
for des_var, info in self.x_info.items():
grad[des_var] = np.full_like(info['init'], np.nan)
[docs]
def compute_constraint_jacobian(self, dvs, jac):
"""
Compute the Jacobian of nonlinear constraints.
Linear constraint Jacobians are pre-computed and provided during setup.
Only nonlinear constraint Jacobians are computed here. Only computes Jacobians
for relevant design variable/constraint pairs based on relevance analysis.
Parameters
----------
dvs : <array_manager.core.native_formats.vector.Vector>
Vector with the current design variable names and values.
jac : dict
Dictionary to store the computed constraint Jacobians.
"""
model = self.driver._problem().model
try:
# Only need derivatives for the nonlinear constraints
if self.nl_con_bounds and self.all_nl_relevant_dvs:
totals = self.driver._problem().compute_totals(
of=list(self.nl_con_bounds),
wrt=list(self.all_nl_relevant_dvs),
)
# First time through, check for zero row/col.
if self.driver._check_nl_jac and self.driver._total_jac is not None:
for subsys in model.system_iter(include_self=True, recurse=True, typ=Group):
if subsys._has_approx:
break
else:
raise_error = self.driver.options['singular_jac_behavior'] == 'error'
self.driver._total_jac.check_total_jac(raise_error=raise_error,
tol=self.driver.options['singular_jac_tol'])
self.driver._check_nl_jac = False
# Extract and store Jacobians for relevant (constraint, design_var) pairs
for (nl_con, des_var), info in self.nl_con_jac_sparsity.items():
rows = info['rows']
cols = info['cols']
if rows is not None and cols is not None:
jac[nl_con, des_var] = totals[nl_con, des_var][rows, cols]
else:
jac[nl_con, des_var] = totals[nl_con, des_var]
except Exception:
# Clean up solver print stack and store exception for re-raising later
self._handle_callback_exception(model)
for (nl_con, des_var), info in self.nl_con_jac_sparsity.items():
rows = info['rows']
cols = info['cols']
if rows is not None and cols is not None:
jac[nl_con, des_var] = np.full(len(rows), np.nan)
else:
con_size = self.nl_con_bounds[nl_con]['size']
dv_size = len(self.x_info[des_var]['init'])
jac[nl_con, des_var] = np.full((con_size, dv_size), np.nan)
def _update_desvar_values(self, dvs):
"""
Update OpenMDAO design variables from modOpt's design vector.
Sets design variable values in OpenMDAO from modOpt's design vector.
Parameters
----------
dvs : <array_manager.core.native_formats.vector.Vector>
Vector with the current design variable names and values.
"""
# dvs isn't a dictionary so we can't set _vectors['design_var'] directly
dv_vec = self.driver._vectors['design_var']
x_new = np.concatenate([np.asarray(dvs[name]).flatten() for name in self.x_info.keys()])
dv_vec.set_data(x_new, driver_scaling=True)
self.driver._set_design_vars(list(self.x_info.keys()), driver_scaling=True)
def _get_constraint_size(self, name):
"""
Get the size of a constraint by name.
Parameters
----------
name : str
Constraint name.
Returns
-------
int
Size of the constraint.
"""
if name in self.lin_con_bounds:
return self.lin_con_bounds[name]['size']
return self.nl_con_bounds[name]['size']
def _run_model(self):
"""
Execute the OpenMDAO model with proper recording and relevance handling.
On the first iteration relevance filtering is inactive so the full model
is evaluated. On subsequent iterations relevance filtering is active for
efficiency.
"""
model = self.driver._problem().model
with RecordingDebugging(self.driver._get_name(), self.driver.iter_count, self.driver):
self.driver.iter_count += 1
with model._relevance.nonlinear_active('iter', active=self.driver._model_ran):
self.driver._run_solve_nonlinear()
self.driver._model_ran = True
def _handle_callback_exception(self, model):
"""
Handle exceptions for modOpt callbacks.
Clears solver print stack and stores exception info for re-raising after
optimization.
Parameters
----------
model : System
The model to clear iprint on.
"""
model._clear_iprint()
if self.driver._exc_info is None:
self.driver._exc_info = sys.exc_info()
[docs]
class modOptDriver(Driver):
"""
Driver wrapper for the modOpt optimization library.
modOpt provides interfaces to various gradient-based and gradient-free optimization
algorithms including SLSQP, IPOPT, SNOPT, COBYLA, and others.
Inequality and equality constraints are supported by several optimizers.
Refer to the modOpt documentation for algorithm-specific capabilities.
modOptDriver supports the following:
- equality_constraints
- inequality_constraints
- two_sided_constraints
- linear_constraints (with pre-computed Jacobians for efficiency)
Parameters
----------
**kwargs : dict of keyword arguments
Keyword arguments that will be mapped into the Driver options.
Attributes
----------
fail : bool
Flag that indicates failure of most recent optimization.
iter_count : int
Counter for function evaluations.
opt_settings : dict
Dictionary of optimizer-specific options. See the modOpt documentation
for algorithm-specific settings.
_check_obj_grad : bool
Used internally to control when to perform singular checks on computed
objective gradient.
_check_nl_jac : bool
Used internally to control when to perform singular checks on computed
nonlinear constraint jacobian.
_con_cache : dict
Cached result of constraint evaluations.
_lincongrad_cache : np.ndarray or None
Pre-calculated gradients of linear constraints.
_model_ran : bool
Flag indicating whether the model has been evaluated at least once,
used to activate relevance filtering on subsequent iterations.
_total_jac_sparsity : dict or None
User-specified total Jacobian sparsity pattern.
_mo_prob : <modOpt Problem object>
The modOpt problem object that is built and fed to the Optimizer.
"""
[docs]
def __init__(self, **kwargs):
"""
Initialize the modOptDriver.
Parameters
----------
**kwargs : dict of keyword arguments
Keyword arguments that will be mapped into the Driver options.
"""
if mo is None:
raise RuntimeError('modOptDriver is not available, modOpt is not'
' installed.')
if isinstance(mo, Exception):
# there is some other issue with the modOpt installation
raise mo
super().__init__(**kwargs)
# What we support
self.supports['optimization'] = True
self.supports['inequality_constraints'] = True
self.supports['equality_constraints'] = True
self.supports['two_sided_constraints'] = True
self.supports['linear_constraints'] = True
self.supports['simultaneous_derivatives'] = True
self.supports['linear_only_designvars'] = True
self.supports['total_jac_sparsity'] = True
# What we don't support
self.supports['multiple_objectives'] = False
self.supports['active_set'] = False
self.supports['integer_design_vars'] = False
self.supports['distributed_design_vars'] = False
self.supports._read_only = True
# The user places optimizer-specific settings in here.
self.opt_settings = {}
self._check_obj_grad = False
self._check_nl_jac = False
self._total_jac_sparsity = None
self._model_ran = False
self._mo_prob = None
self.cite = CITATIONS
def _declare_options(self):
"""
Declare options before kwargs are processed in the init method.
"""
self.options.declare('optimizer', 'SLSQP', values=_all_optimizers,
desc='Name of optimizer to use')
self.options.declare('maxiter', 200, lower=0,
desc='Maximum number of iterations.')
self.options.declare('disp', default=True, types=(int, bool),
desc='Controls optimizer output verbosity. Can be bool (True/False) '
'or int for fine control (0=quiet, higher=more verbose). '
'Automatically maps to optimizer-specific settings. If '
'optimizer specific verbosity settings are provided in the '
'"opt_settings" attribute, then this option will be ignored '
'and those settings will be used instead.')
self.options.declare('singular_jac_behavior', default='warn',
values=['error', 'warn', 'ignore'],
desc='Defines behavior of a zero row/col check after first call to '
'compute_totals: '
'error - raise an error. '
'warn - raise a warning. '
"ignore - don't perform check.")
self.options.declare('singular_jac_tol', default=1e-16,
desc='Tolerance for zero row/column check.')
self.options.declare('turn_off_outputs', default=False, types=bool,
desc='If True, prevents modOpt from generating any output files.')
self.options.declare('output_dir', types=(str, _ReprClass), default=_DEFAULT_REPORTS_DIR,
allow_none=True,
desc='The directory to store all the output files generated '
'from the optimization.')
def _get_name(self):
"""
Get the name of this driver.
Returns
-------
str
Driver name in the format 'modOpt_<optimizer_name>'.
"""
return f"modOpt_{self.options['optimizer']}"
def _setup_verbosity(self, opt):
"""
Configure optimizer-specific verbosity settings based on the driver's disp option.
Different optimizers use different option names and scales for controlling output
verbosity. This method translates the driver's 'disp' option to the appropriate
optimizer-specific settings if user hasn't set them already.
Parameters
----------
opt : str
Name of the optimizer being used.
"""
disp = self.options['disp']
opt_keys_lower = [k.lower() for k in self.opt_settings]
# Map disp option to optimizer-specific settings, skipping if the user
# has already specified the relevant key in opt_settings. Optimizer
# specific display setting by the user takes precedence over "disp" option.
# CVXOPT and ConvexQPSolvers are rejected at runtime due to Hessian requirements
if opt == 'IPOPT':
if 'print_level' not in opt_keys_lower:
if isinstance(disp, int):
self.opt_settings['print_level'] = min(max(disp, 0), 12)
else:
self.opt_settings['print_level'] = 5 if disp else 0
elif opt == 'SNOPT':
if 'major print level' not in opt_keys_lower \
and 'minor print level' not in opt_keys_lower:
if isinstance(disp, int):
level = min(max(disp, 0), 10)
self.opt_settings['Major print level'] = level
self.opt_settings['Minor print level'] = level
else:
self.opt_settings['Major print level'] = 1 if disp else 0
self.opt_settings['Minor print level'] = 0
elif opt == 'TrustConstr':
if 'verbose' not in opt_keys_lower:
if isinstance(disp, int):
self.opt_settings['verbose'] = min(max(disp, 0), 3)
else:
self.opt_settings['verbose'] = 1 if disp else 0
elif opt == 'PySLSQP':
if 'iprint' not in opt_keys_lower:
if isinstance(disp, int):
self.opt_settings['iprint'] = disp
else:
self.opt_settings['iprint'] = 1 if disp else 0
elif opt == 'LBFGSB':
if 'iprint' not in opt_keys_lower:
if isinstance(disp, int):
self.opt_settings['iprint'] = disp
else:
self.opt_settings['iprint'] = 0 if disp else -1
elif opt == 'OpenSQP':
if 'verbosity' not in opt_keys_lower:
if isinstance(disp, int):
self.opt_settings['verbosity'] = disp
else:
self.opt_settings['verbosity'] = 1 if disp else 0
else:
# Most SciPy-based optimizers (SLSQP, COBYLA, COBYQA, BFGS, NelderMead)
# use 'disp' as boolean
if 'disp' not in opt_keys_lower:
if isinstance(disp, int):
self.opt_settings['disp'] = disp > 0
else:
self.opt_settings['disp'] = disp
def _setup_driver(self, problem):
"""
Prepare the driver for execution.
This method configures optimizer-specific support flags based on the selected
algorithm's capabilities, validates the problem formulation, and sets up sparsity
structures. Called during problem setup.
Parameters
----------
problem : Problem
The OpenMDAO Problem being optimized.
"""
super()._setup_driver(problem)
opt = self.options['optimizer']
# Update support flags based on optimizer capabilities
self.supports._read_only = False
self.supports['gradients'] = opt in _gradient_optimizers
self.supports['inequality_constraints'] = opt in _constraint_optimizers
self.supports['two_sided_constraints'] = opt in _constraint_optimizers
self.supports['equality_constraints'] = opt in _eq_constraint_optimizers
self.supports._read_only = True
# Validate problem formulation
if not self.supports['multiple_objectives'] and len(self._objs) > 1:
msg = '{} currently does not support multiple objectives.'
raise RuntimeError(msg.format(self.msginfo))
# CVXOPT and ConvexQPSolvers require Hessian information which is not yet supported
if opt.lower() in ["convexqpsolvers", 'cvxopt']:
msg = ('{} currently does not support CVXOPT and ConvexQPSolvers '
'due to the requirement of Hessian information.')
raise RuntimeError(msg.format(self.msginfo))
if MPI:
msg = ('{} currently does not support running under MPI.')
raise RuntimeError(msg.format(self.msginfo))
self._model_ran = False
self._setup_tot_jac_sparsity()
[docs]
def run(self):
"""
Optimize the problem using the selected modOpt optimizer.
The optimization process performs the following steps:
1. Initial model evaluation
2. Extract and format design variables, bounds, and constraints
3. Pre-compute linear constraint Jacobians for efficiency
4. Create modOptProblem wrapper around the OpenMDAO problem
5. Instantiate and run the selected optimizer
6. Extract optimal design variables and update the model
7. Perform final evaluation at the optimal point
The optimization uses modOpt's Problem API, which handles callbacks for
objective and constraint evaluations as well as derivative computations.
Returns
-------
bool
Success flag; True if optimization succeeded, False if it failed.
"""
self.result.reset()
prob = self._problem()
opt = self.options['optimizer']
model = prob.model
self.iter_count = 0
self._total_jac = None
self._total_jac_linear = None
self._check_for_missing_objective()
self._check_for_invalid_desvar_values()
self._check_obj_grad = self.options['singular_jac_behavior'] in ['error', 'warn']
self._check_nl_jac = self.options['singular_jac_behavior'] in ['error', 'warn']
# Perform initial model evaluation
with RecordingDebugging(self._get_name(), self.iter_count, self):
self._run_solve_nonlinear()
model_ran = True
self.iter_count += 1
self._model_ran = model_ran
self._coloring_info.run_model = not model_ran
self._con_cache = self.get_constraint_values()
desvar_vals = self.get_design_var_values()
# Configure optimizer-specific verbosity settings
self._setup_verbosity(opt)
# Set maxiter for optimizer (unless already specified in opt_settings)
if 'maxiter' not in self.opt_settings:
if opt == 'IPOPT':
# IPOPT uses 'max_iter' instead of 'maxiter'
self.opt_settings['max_iter'] = self.options['maxiter']
else:
self.opt_settings['maxiter'] = self.options['maxiter']
# Determine total number of design variables
ndesvar = 0
for name, meta in self._designvars.items():
size = meta['global_size'] if meta['distributed'] else meta['size']
ndesvar += size
if ndesvar == 0:
raise RuntimeError('Problem has no design variables.')
# Collect design variable information (initial values and bounds)
x_info = OrderedDict()
lower_dv, upper_dv, _ = self._autoscaler.get_bounds_scaling('design_var')
use_bounds = (opt in _bounds_optimizers)
for name, meta in self._designvars.items():
x_info[name] = {}
x_info[name]['init'] = desvar_vals[name]
if use_bounds:
x_info[name]['lower'] = lower_dv[name]
x_info[name]['upper'] = upper_dv[name]
else:
x_info[name]['lower'] = None
x_info[name]['upper'] = None
# compute dynamic simul deriv coloring
prob.get_total_coloring(self._coloring_info, run_model=not model_ran)
# Check for constraints that don't depend on any design variables
# relevance._no_dv_responses contains outputs that are not affected by any design variables
relevance = model._relevance
bad_resps = [n for n in relevance._no_dv_responses if n in self._cons]
bad_cons = [n for n, m in self._cons.items() if m['source'] in bad_resps]
if bad_cons:
issue_warning(f"Constraint(s) {sorted(bad_cons)} do not depend on any design "
"variables and were not added to the optimization.")
for name in bad_cons:
del self._cons[name]
del self._responses[name]
# Collect constraint information
lincongrad = None
nl_con_bounds = dict()
lin_con_bounds = dict()
nl_con_jac_sparsity = dict()
all_nl_relevant_dvs = set()
if opt in _constraint_optimizers:
# Identify linear constraints and pre-compute their Jacobians if optimizer
# uses gradients
if opt in _constraint_grad_optimizers:
lincons = [name for name, meta in self._cons.items() if meta.get('linear')]
else:
lincons = []
# Use relevance to determine which design variables affect linear constraints
# Relevance works by setting a "seed" (the output we care about) and then checking
# which inputs (design variables) are "relevant" (affect that output).
# This is like reverse-mode AD: start from output, trace back to find affecting inputs.
if lincons:
# Collect all design variables that affect any linear constraint
all_relevant_dvs = set()
for name, meta in self._cons.items():
if meta.get('linear'):
# Set this constraint as a reverse seed to find relevant design variables
# meta['source'] is the absolute path of the constraint output variable
with relevance.seeds_active(rev_seeds=(meta['source'],)):
# Check each design variable to see if it affects this constraint
# dv_meta['source'] is the absolute path of the design variable
for dv_name, dv_meta in self._designvars.items():
if relevance.is_relevant(dv_meta['source']):
all_relevant_dvs.add(dv_name)
# Compute Jacobians only for relevant design variables
if all_relevant_dvs:
lincongrad = self._lincongrad_cache = \
self._compute_totals(of=lincons, wrt=list(all_relevant_dvs))
else:
lincongrad = {}
self._lincongrad_cache = None
else:
self._lincongrad_cache = None
# Process constraints and organize into linear and nonlinear categories
lower_con, upper_con, equals_con = self._autoscaler.get_bounds_scaling('constraint')
for name, meta in self._cons.items():
if meta['indices'] is not None:
meta['size'] = size = meta['indices'].indexed_src_size
else:
size = meta['global_size'] if meta['distributed'] else meta['size']
# Separate linear and nonlinear constraints
if meta['linear']:
if meta['equals'] is not None:
lin_con_bounds[name] = {
'lower': equals_con[name],
'upper': equals_con[name],
'size': size
}
else:
lin_con_bounds[name] = {
'lower': lower_con[name],
'upper': upper_con[name],
'size': size
}
else:
if meta['equals'] is not None:
nl_con_bounds[name] = {
'lower': equals_con[name],
'upper': equals_con[name],
'size': size
}
else:
nl_con_bounds[name] = {
'lower': lower_con[name],
'upper': upper_con[name],
'size': size
}
# Initialize sparsity structure for nonlinear constraint Jacobians
# Use relevance to only include design variables that affect this constraint.
# For each nonlinear constraint, set it as a reverse seed and check which
# design variables are relevant (i.e., affect this constraint).
with relevance.seeds_active(rev_seeds=(meta['source'],)):
for x_name, x_meta in self._designvars.items():
# Only declare Jacobian if this design variable affects this constraint
if relevance.is_relevant(x_meta['source']):
nl_con_jac_sparsity[name, x_name] = {}
# Populate sparsity information from OpenMDAO's coloring
# Sparsity (rows/cols) describes which specific elements are nonzero
if name in self._con_subjacs and x_name in self._con_subjacs[name]:
# Extract rows/cols from COO format sparsity data
rows, cols, _ = self._con_subjacs[name][x_name]['coo']
nl_con_jac_sparsity[name, x_name]['rows'] = rows
nl_con_jac_sparsity[name, x_name]['cols'] = cols
else:
# No sparsity info available - use dense (None means dense
# to modOpt)
nl_con_jac_sparsity[name, x_name]['rows'] = None
nl_con_jac_sparsity[name, x_name]['cols'] = None
# Track all design variables relevant to any nonlinear constraint
all_nl_relevant_dvs.add(x_name)
# Run optimization
try:
# Build modOpt Problem wrapper
self._mo_prob = modOptProblem(
driver=self,
x_info=x_info,
lin_con_jac=lincongrad,
lin_con_bounds=lin_con_bounds,
nl_con_bounds=nl_con_bounds,
nl_con_jac_sparsity=nl_con_jac_sparsity,
all_nl_relevant_dvs=all_nl_relevant_dvs,
)
# Resolve output directory. modOpt raises ValueError if out_dir is set
# while turn_off_outputs=True, so pass None in that case.
if self.options['turn_off_outputs']:
out_dir = None
elif self.options['output_dir'] in (None, _DEFAULT_REPORTS_DIR):
out_dir = str(self._problem().get_outputs_dir(mkdir=True))
else:
out_dir = self.options['output_dir']
# Instantiate and run optimizer
optimizer_cls = getattr(mo, opt)
if opt in _solver_options_optimizers:
optimizer = optimizer_cls(
problem=self._mo_prob,
turn_off_outputs=self.options['turn_off_outputs'],
out_dir=out_dir,
solver_options=self.opt_settings,
)
else:
optimizer = optimizer_cls(
problem=self._mo_prob,
turn_off_outputs=self.options['turn_off_outputs'],
out_dir=out_dir,
**self.opt_settings,
)
result = optimizer.solve()
# Extract optimal design variables and success flag from optimizer result
# Different optimizers return results in different formats
if hasattr(result, 'x'):
x_opt = result.x
self.fail = not result.success
elif 'x' in result:
x_opt = result['x']
self.fail = not result['success']
else:
# Fallback for optimizers with non-standard result format
x_opt = self._mo_prob.dvs['x'].get_data()
self.fail = False
print(f'{"-" * 40}\n {opt} does not return success status in '
f'a consistent, easily readable way, so defaulting to '
f'self.fail=False. \n{"-" * 40}\n')
# Update OpenMDAO design variables with optimal values
self._vectors['design_var'].set_data(x_opt, driver_scaling=True)
self._set_design_vars(desvar_names=list(x_info.keys()), driver_scaling=True)
# Final model evaluation at optimal point
with RecordingDebugging(self._get_name(), self.iter_count, self):
self._run_solve_nonlinear()
self._model_ran = model_ran
self.iter_count += 1
if self.options['disp']:
if prob.comm.rank == 0:
print('Optimization Complete')
print('-' * 35)
except Exception:
# If an exception occurred in one of our callbacks, re-raise it with
# the original context rather than modOpt's generic exception message
if self._exc_info is None:
raise
if self._exc_info is not None:
self._reraise()
return self.fail
def _setup_tot_jac_sparsity(self, coloring=None):
"""
Set up total Jacobian sub-Jacobian sparsity pattern.
This method extracts sparsity information from coloring objects or user-specified
sparsity patterns and stores it for use during optimization.
Parameters
----------
coloring : Coloring or None
Coloring object containing sparsity information, or None to use
user-specified _total_jac_sparsity.
"""
total_sparsity = None
self._con_subjacs = {}
coloring = coloring if coloring is not None else self._get_static_coloring()
# Extract sparsity from coloring or user-specified sparsity
if coloring is not None:
total_sparsity = coloring.get_subjac_sparsity()
if self._total_jac_sparsity is not None:
raise RuntimeError("Total jac sparsity was set in both _total_coloring"
" and _setup_tot_jac_sparsity.")
elif self._total_jac_sparsity is not None:
# Load sparsity from file if provided as string path
if isinstance(self._total_jac_sparsity, str):
with open(self._total_jac_sparsity, 'r') as f:
self._total_jac_sparsity = json.load(f)
total_sparsity = self._total_jac_sparsity
if total_sparsity is None:
return
use_approx = self._problem().model._owns_approx_of is not None
nl_dvs = self._get_nl_dvs()
# Build sparsity structure for nonlinear constraints only
# (linear constraints have pre-computed Jacobians)
for con, conmeta in filter_by_meta(self._cons.items(), 'linear', exclude=True):
self._con_subjacs[con] = {}
consrc = conmeta['source']
for dv, dvmeta in nl_dvs.items():
if use_approx:
dvsrc = dvmeta['source']
rows, cols, shape = total_sparsity[consrc][dvsrc]
else:
rows, cols, shape = total_sparsity[con][dv]
self._con_subjacs[con][dv] = {
'coo': [rows, cols, np.zeros(rows.size)],
'shape': shape,
}