diff --git a/pyomo/contrib/mindtpy/MindtPy.py b/pyomo/contrib/mindtpy/MindtPy.py index 50587f1234b..9dc6dbcf1d6 100644 --- a/pyomo/contrib/mindtpy/MindtPy.py +++ b/pyomo/contrib/mindtpy/MindtPy.py @@ -89,10 +89,22 @@ class MindtPySolver: CONFIG = _get_MindtPy_config() def available(self, exception_flag=True): - """Check if solver is available.""" + """Check whether the solver interface is available. + + Parameters + ---------- + exception_flag : bool, optional + Included for API compatibility and ignored by this implementation. + + Returns + ------- + bool + Always ``True`` for the MindtPy interface. + """ return True def license_is_valid(self): + """Report whether the MindtPy solver interface is licensed.""" return True def version(self): @@ -101,11 +113,19 @@ def version(self): @document_kwargs_from_configdict(CONFIG) def solve(self, model, **kwds): - """Solve the model. - - Args: - model (Block): a Pyomo model or block to be solved - + """Solve a model with the configured MindtPy strategy. + + Parameters + ---------- + model : Block + Pyomo model or block to solve. + **kwds + Additional keyword arguments forwarded to the selected algorithm. + + Returns + ------- + SolverResults + Results object returned by the selected MindtPy algorithm. """ # The algorithm should have been specified as an argument to the solve # method. We will instantiate an ephemeral instance of the correct @@ -124,7 +144,19 @@ def solve(self, model, **kwds): # Support 'with' statements. # def __enter__(self): + """Return this solver instance for context-manager support.""" return self def __exit__(self, t, v, traceback): + """Exit the context manager without additional teardown actions. + + Parameters + ---------- + t : type or None + Exception type if an exception was raised in the context block. + v : BaseException or None + Exception value if an exception was raised in the context block. + traceback : traceback or None + Traceback object if an exception was raised in the context block. + """ pass diff --git a/pyomo/contrib/mindtpy/__init__.py b/pyomo/contrib/mindtpy/__init__.py index f25cc919347..abcaf9024cb 100644 --- a/pyomo/contrib/mindtpy/__init__.py +++ b/pyomo/contrib/mindtpy/__init__.py @@ -7,4 +7,6 @@ # software. This software is distributed under the 3-clause BSD License. # ____________________________________________________________________________________ +"""MindtPy solver package for Pyomo MINLP decomposition methods.""" + __version__ = (1, 0, 0) diff --git a/pyomo/contrib/mindtpy/algorithm_base_class.py b/pyomo/contrib/mindtpy/algorithm_base_class.py index 11b34e47d8e..eae990ec7db 100644 --- a/pyomo/contrib/mindtpy/algorithm_base_class.py +++ b/pyomo/contrib/mindtpy/algorithm_base_class.py @@ -84,12 +84,15 @@ class _MindtPyAlgorithm: + """Common base implementation shared by all MindtPy algorithms.""" + def __init__(self, **kwds): - """ - This is a common init method for all the MindtPy algorithms, so that we - correctly set up the config arguments and initialize the generic parts - of the algorithm state. + """Initialize common state shared by MindtPy algorithm variants. + Parameters + ---------- + **kwds + Reserved keyword arguments for compatibility with solver factories. """ self.working_model = None self.original_model = None @@ -155,18 +158,43 @@ def __init__(self, **kwds): # Support use as a context manager under current solver API def __enter__(self): + """Return this solver instance for context-manager support.""" return self def __exit__(self, t, v, traceback): + """Exit the context manager without additional teardown actions. + + Parameters + ---------- + t : type or None + Exception type if an exception was raised in the context block. + v : BaseException or None + Exception value if an exception was raised in the context block. + traceback : traceback or None + Traceback object if an exception was raised in the context block. + """ pass def available(self, exception_flag=True): - """Solver is always available. Though subsolvers may not be, they will - raise an error when the time comes. + """Report whether the MindtPy interface is available. + + Though subsolvers may not be available or licensed, those checks are + deferred until they are instantiated. + + Parameters + ---------- + exception_flag : bool, optional + Included for API compatibility and ignored by this implementation. + + Returns + ------- + bool + Always ``True`` for the MindtPy interface. """ return True def license_is_valid(self): + """Report whether the MindtPy algorithm interface is licensed.""" return True def version(self): @@ -176,6 +204,7 @@ def version(self): _metasolver = False def _log_solver_intro_message(self): + """Log startup details, configuration, and citation information.""" self.config.logger.info( "Starting MindtPy version %s using %s algorithm" % (".".join(map(str, self.version())), self.config.strategy) @@ -209,7 +238,13 @@ def set_up_logger(self): self.config.logger.addHandler(ch) def _log_header(self, logger): - # TODO: rewrite + """Log the standard per-iteration table header. + + Parameters + ---------- + logger : logging.Logger + Logger used to emit iteration table headers. + """ logger.info( '=================================================================' '============================' @@ -226,6 +261,15 @@ def _log_header(self, logger): ) def create_utility_block(self, model, name): + """Create and initialize the MindtPy utility block on a model. + + Parameters + ---------- + model : Block + Model on which the utility block will be created. + name : str + Attribute name to use for the utility block. + """ created_util_block = False # Create a model block on which to store MindtPy-specific utility # modeling objects. @@ -247,7 +291,7 @@ def create_utility_block(self, model, name): # Save ordered lists of main modeling components, so that data can # be easily transferred between future model clones. self.build_ordered_component_lists(model) - self.add_cuts_components(model) + self.add_cut_and_feas_components(model) def _get_main_objective(self, model, logger=None): """Return the single active objective, adding a dummy one if needed. @@ -571,6 +615,11 @@ def build_ordered_component_lists(self, model): Also attaches ordered lists of the variables, constraints to the model so that they can be used for mapping back and forth. + Parameters + ---------- + model : Block + Model whose active components are collected. + """ util_block = getattr(model, self.util_block_name) var_set = ComponentSet() @@ -645,7 +694,14 @@ def build_ordered_component_lists(self, model): v for v in util_block.variable_list if v in var_set and v.is_continuous() ) - def add_cuts_components(self, model): + def add_cut_and_feas_components(self, model): + """Create cut and feasibility components used during decomposition. + + Parameters + ---------- + model : Block + Model receiving MindtPy cut and feasibility components. + """ config = self.config MindtPy = model.MindtPy_utils @@ -688,7 +744,11 @@ def add_cuts_components(self, model): def get_dual_integral(self): """Calculate the dual integral. - Ref: The confined primal integral. [http://www.optimization-online.org/DB_FILE/2020/07/7910.pdf] + + References + ---------- + The confined primal integral. Optimization Online technical report: + http://www.optimization-online.org/DB_FILE/2020/07/7910.pdf Returns ------- @@ -730,7 +790,11 @@ def get_dual_integral(self): def get_primal_integral(self): """Calculate the primal integral. - Ref: The confined primal integral. [http://www.optimization-online.org/DB_FILE/2020/07/7910.pdf] + + References + ---------- + The confined primal integral. Optimization Online technical report: + http://www.optimization-online.org/DB_FILE/2020/07/7910.pdf Returns ------- @@ -812,14 +876,18 @@ def update_dual_bound(self, bound_value): self.update_gap() def update_suboptimal_dual_bound(self, results): - """If the relaxed problem is not solved to optimality, the dual bound is updated - according to the dual bound of relaxed problem. + """Update dual bound from solver-reported bounds after a suboptimal solve. + + This helper is used whenever a subproblem is feasible but not solved to optimality. + In these cases, MindtPy updates the global dual bound using the best bound reported + by the subsolver. Parameters ---------- results : SolverResults - Results from solving the relaxed problem. - The dual bound of the relaxed problem can only be obtained from the result object. + Results from solving a subproblem. For minimization, the bound is + taken from ``results.problem.lower_bound``; for maximization, it is + taken from ``results.problem.upper_bound``. """ if self.objective_sense == minimize: bound_value = results.problem.lower_bound @@ -830,8 +898,8 @@ def update_suboptimal_dual_bound(self, results): def update_primal_bound(self, bound_value): """Update the primal bound. - Call after solve fixed NLP subproblem. - Use the optimal primal bound of the relaxed problem to update the dual bound. + Call after solving a primal-feasible NLP subproblem. + Uses the candidate objective value to update the global primal bound. Parameters ---------- @@ -1066,13 +1134,15 @@ def MindtPy_initialization(self): self.fp_loop() def init_rNLP(self, add_oa_cuts=True): - """Initialize the problem by solving the relaxed NLP and then store the optimal variable + """Initialize by solving the relaxed NLP and storing variable values. + + Initialize the problem by solving the relaxed NLP and then store the optimal variable values obtained from solving the rNLP. Parameters ---------- - add_oa_cuts : Bool - Whether add OA cuts after solving the relaxed NLP problem. + add_oa_cuts : bool + Whether to add OA cuts after solving the relaxed NLP problem. Raises ------ @@ -1412,7 +1482,9 @@ def handle_nlp_subproblem_tc(self, fixed_nlp, result, cb_opt=None): ) def handle_subproblem_optimal(self, fixed_nlp, cb_opt=None, fp=False): - """This function copies the result of the NLP solver function ('solve_subproblem') to the working model, updates + """Handle an optimal solve of a fixed-NLP subproblem. + + This function copies the result of the NLP solver function ('solve_subproblem') to the working model, updates the bounds, adds OA and no-good cuts, and then stores the new solution if it is the new best solution. This function handles the result of the latest iteration of solving the NLP subproblem given an optimal solution. @@ -1564,7 +1636,9 @@ def handle_subproblem_infeasible(self, fixed_nlp, cb_opt=None): def handle_subproblem_other_termination( self, fixed_nlp, termination_condition, cb_opt=None ): - """Handles the result of the latest iteration of solving the fixed NLP subproblem given + """Handle non-optimal, non-infeasible NLP termination conditions. + + Handles the result of the latest iteration of solving the fixed NLP subproblem given a solution that is neither optimal nor infeasible. Parameters @@ -1574,7 +1648,8 @@ def handle_subproblem_other_termination( termination_condition : Pyomo TerminationCondition The termination condition of the fixed NLP subproblem. cb_opt : SolverFactory, optional - The gurobi_persistent solver, by default None. + Callback-capable persistent MIP optimizer used when adding + no-good cuts in callback contexts, by default None. Raises ------ @@ -1582,7 +1657,9 @@ def handle_subproblem_other_termination( MindtPy unable to handle the NLP subproblem termination condition. """ if termination_condition is tc.maxIterations: - # TODO try something else? Reinitialize with different initial value? + # Fallback behavior: treat this iterate as unusable and, when + # enabled, add a no-good cut to avoid revisiting the same + # discrete assignment. self.config.logger.info( 'NLP subproblem failed to converge within iteration limit.' ) @@ -1857,10 +1934,22 @@ def fix_dual_bound(self, last_iter_cuts): ): self.results.solver.termination_condition = tc.optimal - def set_up_tabulist_callback(self): - """Sets up the tabulist using IncumbentCallback. - Currently only support CPLEX. + def set_up_tabulist_callback_cplex(self): + """Register the CPLEX incumbent callback used for tabu-list filtering. + + The callback inspects each candidate incumbent integer assignment and + rejects assignments that are already present in ``self.integer_list``. + This behavior is available only through the CPLEX persistent interface. + Gurobi support is not provided here because this implementation depends + on CPLEX's incumbent-callback reject mechanism. """ + if self.config.mip_solver != 'cplex_persistent': + raise ValueError( + 'Tabu-list incumbent rejection requires cplex_persistent. ' + 'The current implementation relies on the CPLEX incumbent ' + 'callback reject() mechanism and does not provide an ' + 'equivalent path for {}.'.format(self.config.mip_solver) + ) tabulist = self.mip_opt._solver_model.register_callback( tabu_list.IncumbentCallback_cplex ) @@ -1876,8 +1965,9 @@ def set_up_tabulist_callback(self): self.mip_opt._solver_model.set_error_stream(None) def set_up_lazy_OA_callback(self): - """Sets up the lazy OA using LazyConstraintCallback. - Currently only support CPLEX and Gurobi. + """Set up lazy OA callbacks for single-tree solution strategies. + + Currently this supports CPLEX and Gurobi persistent interfaces. """ if self.config.mip_solver == 'cplex_persistent': lazyoa = self.mip_opt._solver_model.register_callback( @@ -1891,21 +1981,28 @@ def set_up_lazy_OA_callback(self): self.mip_opt._solver_model.set_warning_stream(None) self.mip_opt._solver_model.set_log_stream(None) self.mip_opt._solver_model.set_error_stream(None) - if self.config.mip_solver == 'gurobi_persistent': + elif self.config.mip_solver == 'gurobi_persistent': self.mip_opt.set_callback(single_tree.LazyOACallback_gurobi) self.mip_opt.mindtpy_solver = self self.mip_opt.config = self.config + else: + raise ValueError( + 'single_tree lazy OA callbacks are supported only for ' + 'cplex_persistent and gurobi_persistent; got {}.'.format( + self.config.mip_solver + ) + ) ########################################################################################################################################## # mip_solve.py def solve_main(self): - """This function solves the MIP main problem. + """Solve the current main MIP. Returns ------- - self.mip : Pyomo model - The MIP stored in self. + self.mip : Block + Current main MIP model. main_mip_results : SolverResults Results from solving the main MIP. """ @@ -1964,12 +2061,12 @@ def solve_main(self): return self.mip, main_mip_results def solve_fp_main(self): - """This function solves the MIP main problem. + """Solve the feasibility-pump main MIP. Returns ------- - self.mip : Pyomo model - The MIP stored in self. + self.mip : Block + Current feasibility-pump main MIP model. main_mip_results : SolverResults Results from solving the main MIP. """ @@ -2001,12 +2098,12 @@ def solve_fp_main(self): return self.mip, main_mip_results def solve_regularization_main(self): - """This function solves the MIP main problem. + """Solve the regularization main MIP. Returns ------- - self.mip : Pyomo model - The MIP stored in self. + self.mip : Block + Current regularization main MIP model. main_mip_results : SolverResults Results from solving the main MIP. """ @@ -2076,7 +2173,7 @@ def set_up_mip_solver(self): if config.single_tree: self.set_up_lazy_OA_callback() if config.use_tabu_list: - self.set_up_tabulist_callback() + self.set_up_tabulist_callback_cplex() mip_args = dict(config.mip_solver_args) if config.mip_solver in { 'cplex', @@ -2090,17 +2187,20 @@ def set_up_mip_solver(self): # The following functions deal with handling the solution we get from the above MIP solver function def handle_main_optimal(self, main_mip, update_bound=True): - """This function copies the results from 'solve_main' to the working model and updates - the upper/lower bound. This function is called after an optimal solution is found for - the main problem. + """Handle an optimal solve of the main MIP. + + This function validates integer-variable values on ``main_mip`` and + optionally updates the global dual bound using the main-MIP objective + value. Parameters ---------- - main_mip : Pyomo model - The MIP main problem. + main_mip : Block + Current main MIP model. update_bound : bool, optional - Whether to update the bound, by default True. - Bound will not be updated when handling regularization problem. + Whether to update the dual bound, by default True. + The dual bound is not updated when handling the regularization + problem. """ # proceed. Just need integer values MindtPy = main_mip.MindtPy_utils @@ -2135,8 +2235,10 @@ def handle_main_optimal(self, main_mip, update_bound=True): ) def handle_main_infeasible(self): - """This function handles the result of the latest iteration of solving - the MIP problem given an infeasible solution. + """Handle an infeasible solve of the main MIP. + + This updates logging and termination-condition state when no feasible + main solution is available. """ self.config.logger.info( 'MIP main problem is infeasible. ' @@ -2164,15 +2266,19 @@ def handle_main_infeasible(self): self.results.solver.termination_condition = tc.feasible def handle_main_max_timelimit(self, main_mip, main_mip_results): - """This function handles the result of the latest iteration of solving the MIP problem - given that solving the MIP takes too long. + """Handle a time-limited solve of the main MIP. + + This function copies the current ``main_mip`` variable values to + ``fixed_nlp`` for warm starting the NLP subproblem, updates the + suboptimal dual bound from solver-reported bounds, and logs the + time-limit event. Parameters ---------- - main_mip : Pyomo model - The MIP main problem. - main_mip_results : [type] - Results from solving the MIP main subproblem. + main_mip : Block + Current main MIP model. + main_mip_results : SolverResults + Results from solving the main MIP. """ # If we have found a valid feasible solution, we take that. If not, we can at least use the dual bound. MindtPy = main_mip.MindtPy_utils @@ -2196,13 +2302,12 @@ def handle_main_max_timelimit(self, main_mip, main_mip_results): ) def handle_main_unbounded(self, main_mip): - """This function handles the result of the latest iteration of solving the MIP - problem given an unbounded solution due to the relaxation. + """Resolve an unbounded main MIP relaxation by bounding the objective. Parameters ---------- - main_mip : Pyomo model - The MIP main problem. + main_mip : Block + Current main MIP model. Returns ------- @@ -2253,8 +2358,8 @@ def handle_regularization_main_tc(self, main_mip, main_mip_results): Parameters ---------- - main_mip : Pyomo model - The MIP main problem. + main_mip : Block + Current regularization main MIP model. main_mip_results : SolverResults Results from solving the regularization main subproblem. @@ -2451,6 +2556,7 @@ def setup_regularization_main(self): ) def update_result(self): + """Populate the solver results object from algorithm state.""" if self.objective_sense == minimize: self.results.problem.lower_bound = self.dual_bound self.results.problem.upper_bound = self.primal_bound @@ -2469,6 +2575,7 @@ def update_result(self): self.results.solver.primal_dual_gap_integral = self.primal_dual_gap_integral def load_solution(self): + """Copy the best solution found back to the original model.""" # Update values in original model config = self.config MindtPy = self.working_model.MindtPy_utils @@ -2563,6 +2670,12 @@ def check_config(self): if config.add_no_good_cuts: config.integer_to_binary = True if config.use_tabu_list: + if config.mip_solver != 'cplex_persistent': + config.logger.info( + 'Tabu-list incumbent rejection is implemented only for ' + 'cplex_persistent because it relies on CPLEX incumbent ' + 'callback reject() behavior.' + ) config.mip_solver = 'cplex_persistent' if config.threads > 1: config.threads = 1 @@ -2617,8 +2730,9 @@ def solve_fp_subproblem(self): expr=sum(fp_nlp.MindtPy_utils.objective_value[:]) >= self.primal_bound ) - # Add norm_constraint, which guarantees the monotonicity of the norm objective value sequence of all iterations - # Ref: Paper 'A storm of feasibility pumps for nonconvex MINLP' https://doi.org/10.1007/s10107-012-0608-x + # Add norm_constraint, which guarantees the monotonicity of the norm objective value sequence of all iterations. + # A storm of feasibility pumps for nonconvex MINLP. + # DOI: https://doi.org/10.1007/s10107-012-0608-x # the norm type is consistent with the norm obj of the FP-main problem. if config.fp_norm_constraint: generate_norm_constraint(fp_nlp, self.mip, config) @@ -2660,8 +2774,14 @@ def solve_fp_subproblem(self): return fp_nlp, results def handle_fp_subproblem_optimal(self, fp_nlp): - """Copies the solution to the working model, updates bound, adds OA cuts / no-good cuts / - increasing objective cut, calculates the duals and stores incumbent solution if it has been improved. + """Handle an optimal feasibility-pump NLP subproblem solution. + + Copies the fp_nlp solution to the working model and adds orthogonality + cuts to help prevent cycling. If the FP projection has converged, + this function then solves the fixed_NLP subproblem at the current MIP + integer point and delegates primal-bound updates / incumbent handling + to ``handle_subproblem_optimal``. When the primal bound improves, it + tightens the FP improving-objective cut. Parameters ---------- @@ -3022,6 +3142,8 @@ def solve(self, model, **kwds): ---------- model : Pyomo model The MINLP model to be solved. + **kwds + Additional solver keyword options used to configure MindtPy. Returns ------- @@ -3086,7 +3208,7 @@ def solve(self, model, **kwds): # Algorithm main loop with time_code(self.timing, 'main loop'): - self.MindtPy_iteration_loop() + self.run_iteration_loop() # Load solution if self.best_solution_found is not None: @@ -3112,7 +3234,15 @@ def solve(self, model, **kwds): finally: self._cleanup_temporary_original_objective() + def run_iteration_loop(self): + """Dispatch to a strategy-specific outer iteration loop implementation.""" + if hasattr(self, 'feasibility_pump_iteration_loop'): + self.feasibility_pump_iteration_loop() + else: + self.MindtPy_iteration_loop() + def objective_reformulation(self): + """Apply default objective reformulation behavior for base methods.""" # In the process_objective function, as long as the objective function is nonlinear, it will be reformulated and the variable/constraint/objective lists will be updated. # For OA/GOA/LP-NLP algorithm, if the objective function is linear, it will not be reformulated as epigraph constraint. # If the objective function is linear, it will be reformulated as epigraph constraint only if the Feasibility Pump or ROA/RLP-NLP algorithm is activated. (move_objective = True) @@ -3124,6 +3254,20 @@ def objective_reformulation(self): self.process_objective(update_var_con_list=True) def handle_main_mip_termination(self, main_mip, main_mip_results): + """Dispatch handling for main-MIP termination conditions. + + Parameters + ---------- + main_mip : Block + Current main MIP model. + main_mip_results : SolverResults or None + Solver results returned by the main MIP solve. + + Returns + ------- + bool + ``True`` if algorithm execution should terminate after handling. + """ should_terminate = False if main_mip_results is not None: if not self.config.single_tree: @@ -3310,6 +3454,18 @@ def MindtPy_iteration_loop(self): ) def get_solution_name_obj(self, main_mip_results): + """Return sorted solution-pool entries as ``[name, objective]`` pairs. + + Parameters + ---------- + main_mip_results : SolverResults + Results object containing the persistent-solver model handle. + + Returns + ------- + list + Ordered list of ``[solution_name, objective_value]`` pairs. + """ if self.config.mip_solver == 'cplex_persistent': solution_pool_names = ( main_mip_results._solver_model.solution.pool.get_names() @@ -3336,6 +3492,7 @@ def get_solution_name_obj(self, main_mip_results): return solution_name_obj def add_regularization(self): + """Solve regularization main problems when regularization is active.""" if self.best_solution_found is not None: # The main problem might be unbounded, regularization is activated only when a valid bound is provided. if self.dual_bound != self.dual_bound_progress[0]: @@ -3348,6 +3505,7 @@ def add_regularization(self): ) def bounds_converged(self): + """Check whether primal and dual bounds satisfy convergence tolerances.""" # Check bound convergence if self.abs_gap <= self.config.absolute_bound_tolerance: self.config.logger.info( @@ -3372,6 +3530,7 @@ def bounds_converged(self): return False def reached_iteration_limit(self): + """Check whether the configured iteration limit has been reached.""" # Check iteration limit if self.mip_iter >= self.config.iteration_limit: self.config.logger.info( @@ -3392,6 +3551,7 @@ def reached_iteration_limit(self): return False def reached_time_limit(self): + """Check whether the configured time limit has been reached.""" if get_main_elapsed_time(self.timing) >= self.config.time_limit: self.config.logger.info( 'MindtPy unable to converge bounds ' @@ -3411,6 +3571,7 @@ def reached_time_limit(self): return False def reached_stalling_limit(self): + """Check whether primal-bound progress has stalled long enough to stop.""" config = self.config if len(self.primal_bound_progress) >= config.stalling_limit: if ( @@ -3444,6 +3605,7 @@ def reached_stalling_limit(self): return False def iteration_cycling(self): + """Detect repeated integer assignments and flag cycling when enabled.""" config = self.config if config.cycling_check or config.use_tabu_list: self.curr_int_sol = get_integer_solution(self.mip) diff --git a/pyomo/contrib/mindtpy/config_options.py b/pyomo/contrib/mindtpy/config_options.py index 0ac163ccfcc..5b0d884ddfc 100644 --- a/pyomo/contrib/mindtpy/config_options.py +++ b/pyomo/contrib/mindtpy/config_options.py @@ -8,6 +8,8 @@ # ____________________________________________________________________________________ # -*- coding: utf-8 -*- +"""Configuration definitions for MindtPy strategies and solver behavior.""" + import logging from pyomo.common.config import ( ConfigBlock, @@ -138,6 +140,13 @@ def _get_MindtPy_FP_config(): def _add_oa_configs(CONFIG): + """Declare configuration options specific to OA-based methods. + + Parameters + ---------- + CONFIG : ConfigBlock + Configuration block to populate. + """ CONFIG.declare( 'heuristic_nonconvex', ConfigValue( @@ -162,6 +171,13 @@ def _add_oa_configs(CONFIG): def _add_oa_cuts_configs(CONFIG): + """Declare configuration options controlling OA cut generation. + + Parameters + ---------- + CONFIG : ConfigBlock + Configuration block to populate. + """ CONFIG.declare( 'add_slack', ConfigValue( @@ -211,6 +227,13 @@ def _add_oa_cuts_configs(CONFIG): def _add_goa_configs(CONFIG): + """Declare configuration options specific to GOA-based methods. + + Parameters + ---------- + CONFIG : ConfigBlock + Configuration block to populate. + """ CONFIG.declare( 'init_strategy', ConfigValue( @@ -226,15 +249,23 @@ def _add_goa_configs(CONFIG): def _add_ecp_configs(CONFIG): + """Declare configuration options specific to ECP-based methods. + + Parameters + ---------- + CONFIG : ConfigBlock + Configuration block to populate. + """ CONFIG.declare( 'ecp_tolerance', ConfigValue( default=None, domain=PositiveFloat, - description='ECP tolerance', - doc='Feasibility tolerance used to determine the stopping criterion in' - 'the ECP method. As long as nonlinear constraint are violated for ' - 'more than this tolerance, the method will keep iterating.', + description='ECP tolerance (defaults to absolute bound tolerance)', + doc='Feasibility tolerance used to determine the stopping criterion in ' + 'the ECP method. As long as nonlinear constraints are violated by ' + 'more than this tolerance, the method will keep iterating. If not ' + 'specified, this value is normalized to ``absolute_bound_tolerance``.', ), ) CONFIG.declare( @@ -252,6 +283,13 @@ def _add_ecp_configs(CONFIG): def _add_common_configs(CONFIG): + """Declare configuration options shared by all MindtPy strategies. + + Parameters + ---------- + CONFIG : ConfigBlock + Configuration block to populate. + """ CONFIG.declare( 'iteration_limit', ConfigValue( diff --git a/pyomo/contrib/mindtpy/cut_generation.py b/pyomo/contrib/mindtpy/cut_generation.py index 79469830694..aed547319a3 100644 --- a/pyomo/contrib/mindtpy/cut_generation.py +++ b/pyomo/contrib/mindtpy/cut_generation.py @@ -51,6 +51,8 @@ def add_oa_cuts( MIP iteration counter. config : ConfigBlock The specific configurations for MindtPy. + timing : Timing + Timing object used to record cut-generation time. cb_opt : SolverFactory, optional Gurobi_persistent solver, by default None. linearize_active : bool, optional @@ -183,6 +185,23 @@ def add_oa_cuts( def add_oa_cuts_for_grey_box( target_model, jacobians_model, config, objective_sense, mip_iter, cb_opt=None ): + """Add OA cuts contributed by external grey-box output Jacobians. + + Parameters + ---------- + target_model : Block + Target MIP model receiving OA cuts. + jacobians_model : Block + NLP model with duals and Jacobian evaluators. + config : ConfigBlock + MindtPy configuration options. + objective_sense : int + Objective sense indicator (``minimize`` or ``maximize``). + mip_iter : int + Main-problem iteration counter. + cb_opt : SolverFactory, optional + Persistent-solver callback object for lazy-cut injection. + """ sign_adjust = -1 if objective_sense == minimize else 1 if config.add_slack: slack_var = target_model.MindtPy_utils.cuts.slack_vars.add() @@ -406,6 +425,8 @@ def add_affine_cuts(target_model, config, timing): Parameters ---------- + target_model : Pyomo model + The relaxed main model receiving affine cuts. config : ConfigBlock The specific configurations for MindtPy. timing : Timing diff --git a/pyomo/contrib/mindtpy/extended_cutting_plane.py b/pyomo/contrib/mindtpy/extended_cutting_plane.py index a209125fcd0..2cb0f51c7d9 100644 --- a/pyomo/contrib/mindtpy/extended_cutting_plane.py +++ b/pyomo/contrib/mindtpy/extended_cutting_plane.py @@ -9,6 +9,8 @@ # software. This software is distributed under the 3-clause BSD License. # ____________________________________________________________________________________ +"""Extended Cutting Plane strategy implementation for MindtPy.""" + from pyomo.contrib.gdpopt.util import time_code, get_main_elapsed_time from pyomo.contrib.mindtpy.util import calc_jacobians from pyomo.core import ConstraintList @@ -69,8 +71,17 @@ def MindtPy_iteration_loop(self): ) def check_config(self): + """Validate and normalize ECP-specific configuration values. + + If ``ecp_tolerance`` is ``None``, this method sets + ``ecp_tolerance = absolute_bound_tolerance``. + The resulting ``ecp_tolerance`` is then used in ECP nonlinear-constraint + satisfaction checks, where the algorithm continues while any slack is + less than ``-ecp_tolerance``. + """ config = self.config - # if ecp tolerance is not provided use bound tolerance + # Normalize ECP feasibility tolerance to the global absolute bound + # tolerance when the user does not provide an explicit ECP value. if config.ecp_tolerance is None: config.ecp_tolerance = config.absolute_bound_tolerance super().check_config() @@ -87,7 +98,9 @@ def initialize_mip_problem(self): ) def init_rNLP(self): - """Initialize the problem by solving the relaxed NLP and then store the optimal variable + """Initialize by solving the relaxed NLP and storing variable values. + + Initialize the problem by solving the relaxed NLP and then store the optimal variable values obtained from solving the rNLP. Raises @@ -125,6 +138,7 @@ def algorithm_should_terminate(self): ) def all_nonlinear_constraint_satisfied(self): + """Return True when all nonlinear constraints satisfy ECP tolerance.""" # check to see if the nonlinear constraints are satisfied config = self.config MindtPy = self.mip.MindtPy_utils diff --git a/pyomo/contrib/mindtpy/feasibility_pump.py b/pyomo/contrib/mindtpy/feasibility_pump.py index 8f5e75c4466..ba5f69483bd 100644 --- a/pyomo/contrib/mindtpy/feasibility_pump.py +++ b/pyomo/contrib/mindtpy/feasibility_pump.py @@ -9,6 +9,8 @@ # software. This software is distributed under the 3-clause BSD License. # ____________________________________________________________________________________ +"""Feasibility Pump initialization strategy implementation for MindtPy.""" + import logging from pyomo.contrib.mindtpy.config_options import _get_MindtPy_FP_config from pyomo.contrib.mindtpy.algorithm_base_class import _MindtPyAlgorithm @@ -36,6 +38,7 @@ class MindtPy_FP_Solver(_MindtPyAlgorithm): CONFIG = _get_MindtPy_FP_config() def check_config(self): + """Validate and enforce Feasibility Pump specific configuration.""" # feasibility pump alone will lead to iteration_limit = 0, important! self.config.iteration_limit = 0 self.config.move_objective = True @@ -52,6 +55,19 @@ def initialize_mip_problem(self): doc='Outer approximation cuts' ) + def MindtPy_initialization(self): + """Initialize Feasibility Pump state without executing FP iterations. + + Notes + ----- + The base implementation for ``init_strategy == 'FP'`` calls + ``fp_loop()`` during initialization. For ``mindtpy.fp``, + ``feasibility_pump_iteration_loop()`` already dispatches to + ``fp_loop()`` in the main-loop phase. This override performs only the + relaxed-NLP setup so the FP loop executes exactly once. + """ + self.init_rNLP() + def add_cuts( self, dual_values, @@ -60,6 +76,21 @@ def add_cuts( cb_opt=None, nlp=None, ): + """Add cuts for the current feasibility pump iterate. + + Parameters + ---------- + dual_values : list + Dual multipliers from the NLP subproblem. + linearize_active : bool, optional + Whether to linearize active nonlinear constraints. + linearize_violated : bool, optional + Whether to linearize violated nonlinear constraints. + cb_opt : SolverFactory, optional + Callback-capable persistent MIP optimizer. + nlp : Block, optional + NLP model used by strategies requiring model-specific cut data. + """ add_oa_cuts( self.mip, dual_values, @@ -74,5 +105,6 @@ def add_cuts( linearize_violated, ) - def MindtPy_iteration_loop(self): - pass + def feasibility_pump_iteration_loop(self): + """Run the Feasibility Pump-specific outer iteration loop.""" + self.fp_loop() diff --git a/pyomo/contrib/mindtpy/global_outer_approximation.py b/pyomo/contrib/mindtpy/global_outer_approximation.py index 9f3f70bde10..931a09299d6 100644 --- a/pyomo/contrib/mindtpy/global_outer_approximation.py +++ b/pyomo/contrib/mindtpy/global_outer_approximation.py @@ -9,6 +9,7 @@ # software. This software is distributed under the 3-clause BSD License. # ____________________________________________________________________________________ +"""Global Outer Approximation strategy implementation for MindtPy.""" from pyomo.contrib.gdpopt.util import get_main_elapsed_time from pyomo.core import ConstraintList @@ -37,6 +38,7 @@ class MindtPy_GOA_Solver(_MindtPyAlgorithm): CONFIG = _get_MindtPy_GOA_config() def check_config(self): + """Validate and normalize GOA-specific configuration values.""" config = self.config config.add_slack = False config.use_mcpp = True @@ -72,8 +74,8 @@ def initialize_mip_problem(self): def update_primal_bound(self, bound_value): """Update the primal bound. - Call after solve fixed NLP subproblem. - Use the optimal primal bound of the relaxed problem to update the dual bound. + Call after solving a primal-feasible NLP subproblem. + Uses the candidate objective value to update the global primal bound. Parameters ---------- @@ -95,9 +97,31 @@ def add_cuts( cb_opt=None, nlp=None, ): + """Add GOA affine cuts to the current main problem. + + Parameters + ---------- + dual_values : list, optional + Unused for GOA affine cuts. + linearize_active : bool, optional + Unused placeholder for shared strategy interface. + linearize_violated : bool, optional + Unused placeholder for shared strategy interface. + cb_opt : SolverFactory, optional + Unused callback optimizer for GOA affine cuts. + nlp : Block, optional + Unused NLP model placeholder. + """ add_affine_cuts(self.mip, self.config, self.timing) def deactivate_no_good_cuts_when_fixing_bound(self, no_good_cuts): + """Deactivate no-good cuts beyond the incumbent bound checkpoint. + + Parameters + ---------- + no_good_cuts : ConstraintList + No-good cut list stored on the main model. + """ try: valid_no_good_cuts_num = self.num_no_good_cuts_added[self.primal_bound] if self.config.add_no_good_cuts: diff --git a/pyomo/contrib/mindtpy/outer_approximation.py b/pyomo/contrib/mindtpy/outer_approximation.py index cdd54b4a1f5..70463321ded 100644 --- a/pyomo/contrib/mindtpy/outer_approximation.py +++ b/pyomo/contrib/mindtpy/outer_approximation.py @@ -9,6 +9,8 @@ # software. This software is distributed under the 3-clause BSD License. # ____________________________________________________________________________________ +"""Outer Approximation strategy implementation for MindtPy.""" + from pyomo.contrib.mindtpy.util import calc_jacobians from pyomo.core import ConstraintList from pyomo.opt import SolverFactory @@ -38,6 +40,7 @@ class MindtPy_OA_Solver(_MindtPyAlgorithm): CONFIG = _get_MindtPy_OA_config() def check_config(self): + """Validate OA/ROA options and apply dependent defaults.""" config = self.config if config.add_regularization is not None: if config.add_regularization in { @@ -92,7 +95,14 @@ def check_config(self): _MindtPyAlgorithm.check_config(self) def initialize_mip_problem(self): - """Deactivate the nonlinear constraints to create the MIP problem.""" + """Initialize OA data structures on top of base MIP initialization. + + The base implementation prepares solver subproblems by constructing + ``self.mip`` from a clone of the working model and ``self.fixed_nlp`` + from an integer-fixed clone (plus feasibility-subproblem setup). + This OA override then precomputes Jacobians for nonlinear constraints + and creates the OA cut container. + """ super().initialize_mip_problem() self.jacobians = calc_jacobians( self.mip.MindtPy_utils.nonlinear_constraint_list, @@ -110,6 +120,21 @@ def add_cuts( cb_opt=None, nlp=None, ): + """Add OA cuts to the main MIP. + + Parameters + ---------- + dual_values : list + Dual multipliers from the NLP subproblem. + linearize_active : bool, optional + Whether to linearize active nonlinear constraints. + linearize_violated : bool, optional + Whether to linearize violated nonlinear constraints. + cb_opt : SolverFactory, optional + Callback-capable persistent MIP optimizer. + nlp : Block, optional + NLP model used to extract grey-box Jacobian data. + """ add_oa_cuts( self.mip, dual_values, @@ -129,6 +154,13 @@ def add_cuts( ) def deactivate_no_good_cuts_when_fixing_bound(self, no_good_cuts): + """Deactivate the most recent no-good cut when fixing bounds. + + Parameters + ---------- + no_good_cuts : ConstraintList + No-good cut list stored on the main model. + """ # Only deactivate the last OA cuts may not be correct. # Since integer solution may also be cut off by OA cuts due to calculation approximation. if self.config.add_no_good_cuts: @@ -137,6 +169,7 @@ def deactivate_no_good_cuts_when_fixing_bound(self, no_good_cuts): self.integer_list = self.integer_list[:-1] def objective_reformulation(self): + """Configure objective reformulation for OA and regularized OA.""" # In the process_objective function, as long as the objective function is nonlinear, it will be reformulated and the variable/constraint/objective lists will be updated. # For OA/GOA/LP-NLP algorithm, if the objective function is linear, it will not be reformulated as epigraph constraint. # If the objective function is linear, it will be reformulated as epigraph constraint only if the Feasibility Pump or ROA/RLP-NLP algorithm is activated. (move_objective = True) diff --git a/pyomo/contrib/mindtpy/plugins.py b/pyomo/contrib/mindtpy/plugins.py index 15ead6d4a34..ca3b1fdbe81 100644 --- a/pyomo/contrib/mindtpy/plugins.py +++ b/pyomo/contrib/mindtpy/plugins.py @@ -7,8 +7,11 @@ # software. This software is distributed under the 3-clause BSD License. # ____________________________________________________________________________________ +"""Plugin loader for registering MindtPy solver variants.""" + def load(): + """Import MindtPy solver modules so their plugins are registered.""" import pyomo.contrib.mindtpy.MindtPy import pyomo.contrib.mindtpy.outer_approximation import pyomo.contrib.mindtpy.extended_cutting_plane diff --git a/pyomo/contrib/mindtpy/single_tree.py b/pyomo/contrib/mindtpy/single_tree.py index d42d947baa5..e8ad800568f 100644 --- a/pyomo/contrib/mindtpy/single_tree.py +++ b/pyomo/contrib/mindtpy/single_tree.py @@ -7,6 +7,8 @@ # software. This software is distributed under the 3-clause BSD License. # ____________________________________________________________________________________ +"""Single-tree callback machinery for MindtPy persistent MIP solvers.""" + from pyomo.common.dependencies import attempt_import from pyomo.solvers.plugins.solvers.gurobi_direct import gurobipy from pyomo.contrib.mindtpy.cut_generation import add_oa_cuts, add_no_good_cuts @@ -30,12 +32,13 @@ class LazyOACallback_cplex( cplex.callbacks.LazyConstraintCallback if cplex_available else object ): - """Inherent class in CPLEX to call Lazy callback.""" + """CPLEX lazy-constraint callback used by MindtPy single-tree OA.""" def copy_lazy_var_list_values( self, opt, from_list, to_list, config, skip_stale=False, skip_fixed=True ): - """This function copies variable values from one list to another. + """Copy variable values from one list to another. + Rounds to Binary/Integer if necessary. Sets to zero for NonNegativeReals if necessary. @@ -78,7 +81,10 @@ def add_lazy_oa_cuts( linearize_active=True, linearize_violated=True, ): - """Linearizes nonlinear constraints; add the OA cuts through CPLEX inherent function self.add() + """Add OA cuts through the CPLEX lazy-constraint callback. + + Linearizes nonlinear constraints and adds OA cuts through CPLEX + inherent function ``self.add()``. For nonconvex problems, turn on 'config.add_slack'. Slack variables will always be used for nonlinear equality constraints. @@ -375,7 +381,8 @@ def add_lazy_no_good_cuts( opt : SolverFactory The cplex_persistent solver. feasible : bool, optional - Whether the integer combination yields a feasible or infeasible NLP, by default False. + Whether the integer combination yields a feasible or infeasible NLP, + by default False. Raises ------ @@ -429,10 +436,12 @@ def add_lazy_no_good_cuts( ) def handle_lazy_main_feasible_solution(self, main_mip, mindtpy_solver, config, opt): - """This function is called during the branch and bound of main mip, more + """Handle a feasible main-MIP solution inside the lazy callback. + + This function is called during the branch and bound of main mip, more exactly when a feasible solution is found and LazyCallback is activated. - Copy the result to working model and update upper or lower bound. - In LP-NLP, upper or lower bound are updated during solving the main problem. + Copies the incumbent values to ``fixed_nlp`` for warm-starting and + updates the global dual bound from the callback-reported main bound. Parameters ---------- @@ -469,7 +478,9 @@ def handle_lazy_main_feasible_solution(self, main_mip, mindtpy_solver, config, o ) def handle_lazy_subproblem_optimal(self, fixed_nlp, mindtpy_solver, config, opt): - """This function copies the optimal solution of the fixed NLP subproblem to the MIP + """Handle an optimal NLP subproblem solve inside the lazy callback. + + This function copies the optimal solution of the fixed NLP subproblem to the MIP main problem(explanation see below), updates bound, adds OA and no-good cuts, stores incumbent solution if it has been improved. @@ -620,7 +631,9 @@ def handle_lazy_subproblem_infeasible(self, fixed_nlp, mindtpy_solver, config, o def handle_lazy_subproblem_other_termination( self, fixed_nlp, termination_condition, mindtpy_solver, config ): - """Handles the result of the latest iteration of solving the NLP subproblem given + """Handle non-optimal, non-infeasible NLP callback termination. + + Handles the result of the latest iteration of solving the NLP subproblem given a solution that is neither optimal nor infeasible. Parameters @@ -652,7 +665,7 @@ def handle_lazy_subproblem_other_termination( ) def __call__(self): - """This is an inherent function in LazyConstraintCallback in CPLEX. + """Entry point for CPLEX LazyConstraintCallback. This function is called whenever an integer solution is found during the branch and bound process. """ @@ -662,8 +675,9 @@ def __call__(self): main_mip = self.main_mip mindtpy_solver = self.mindtpy_solver - # The lazy constraint callback may be invoked during MIP start processing. In that case get_solution_source returns mip_start_solution. - # Reference: https://www.ibm.com/docs/en/icos/22.1.1?topic=SSSA5P_22.1.1/ilog.odms.cplex.help/refpythoncplex/html/cplex.callbacks.SolutionSource-class.htm + # The lazy constraint callback may be invoked during MIP start processing. + # IBM CPLEX SolutionSource documentation: + # https://www.ibm.com/docs/en/icos/22.1.1?topic=SSSA5P_22.1.1/ilog.odms.cplex.help/refpythoncplex/html/cplex.callbacks.SolutionSource-class.htm # Another solution source is user_solution = 118, but it will not be encountered in LazyConstraintCallback. config.logger.info( "Solution source: {} (111 node_solution, 117 heuristic_solution, 119 mipstart_solution)".format( @@ -674,8 +688,10 @@ def __call__(self): # The solution found in MIP start process might be revisited in branch and bound. # Lazy constraints separated when processing a MIP start will be discarded after that MIP start has been processed. # This means that the callback may have to separate the same constraint again for the next MIP start or for a solution that is found later in the solution process. + # IBM CPLEX LazyConstraintCallback documentation: # https://www.ibm.com/docs/en/icos/22.1.1?topic=SSSA5P_22.1.1/ilog.odms.cplex.help/refpythoncplex/html/cplex.callbacks.LazyConstraintCallback-class.htm - # For the MINLP3_simple example, all the solutions are obtained from mip_start (solution source). Therefore, it will not go to a branch and bound process.Cause an error output. + # In the minlp3_simple example, all solutions come from mip_start as the solution source. + # As a result, CPLEX may never enter the branch-and-bound process, which can otherwise lead to this error. if ( self.get_solution_source() != cplex.callbacks.SolutionSource.mipstart_solution @@ -935,11 +951,14 @@ def LazyOACallback_gurobi(cb_m, cb_opt, cb_where, mindtpy_solver, config): def handle_lazy_main_feasible_solution_gurobi(cb_m, cb_opt, mindtpy_solver, config): - """This function is called during the branch and bound of main MIP problem, + """Handle a feasible Gurobi MIP solution in the lazy callback. + + This function is called during the branch and bound of main MIP problem, more exactly when a feasible solution is found and LazyCallback is activated. - Copy the solution to working model and update upper or lower bound. - In LP-NLP, upper or lower bound are updated during solving the main problem. + Copies the incumbent values to ``fixed_nlp`` (and ``mip`` for cut + generation) and updates the global dual bound from + ``GRB.Callback.MIPSOL_OBJBND``. Parameters ---------- diff --git a/pyomo/contrib/mindtpy/tabu_list.py b/pyomo/contrib/mindtpy/tabu_list.py index 3ef76bfd1cf..c849bf6890d 100644 --- a/pyomo/contrib/mindtpy/tabu_list.py +++ b/pyomo/contrib/mindtpy/tabu_list.py @@ -7,6 +7,7 @@ # software. This software is distributed under the 3-clause BSD License. # ____________________________________________________________________________________ # +"""Tabu-list callback support for persistent-solver MindtPy runs.""" from pyomo.common.dependencies import attempt_import, UnavailableClass @@ -19,11 +20,13 @@ class IncumbentCallback_cplex( """Inherent class in Cplex to call Incumbent callback.""" def __call__(self): - """ + """Process each candidate incumbent found by CPLEX. + This is an inherent function in LazyConstraintCallback in CPLEX. This callback will be used after each new potential incumbent is found. - https://www.ibm.com/support/knowledgecenter/SSSA5P_12.10.0/ilog.odms.cplex.help/refpythoncplex/html/cplex.callbacks.IncumbentCallback-class.html - IncumbentCallback will be activated after Lazyconstraint callback, when the potential incumbent solution is satisfies the lazyconstraints. + See IBM ILOG CPLEX Optimization Studio documentation for + ``IncumbentCallback`` callback behavior. + IncumbentCallback is activated after the LazyConstraintCallback, when the potential incumbent solution satisfies the lazy constraints. TODO: need to handle GOA same integer combination check in lazyconstraint callback in single_tree.py """ mindtpy_solver = self.mindtpy_solver diff --git a/pyomo/contrib/mindtpy/tests/__init__.py b/pyomo/contrib/mindtpy/tests/__init__.py index 231b44987f6..13c69a46a1c 100644 --- a/pyomo/contrib/mindtpy/tests/__init__.py +++ b/pyomo/contrib/mindtpy/tests/__init__.py @@ -6,3 +6,24 @@ # Solutions of Sandia, LLC, the U.S. Government retains certain rights in this # software. This software is distributed under the 3-clause BSD License. # ____________________________________________________________________________________ + +"""Test models and regression tests for MindtPy.""" + +from importlib import import_module +import sys + +_legacy_module_aliases = { + 'MINLP_simple': 'minlp_simple', + 'MINLP2_simple': 'minlp2_simple', + 'MINLP3_simple': 'minlp3_simple', + 'MINLP4_simple': 'minlp4_simple', + 'MINLP5_simple': 'minlp5_simple', + 'MINLP_simple_grey_box': 'minlp_simple_grey_box', +} + +for _legacy_name, _module_name in _legacy_module_aliases.items(): + sys.modules[f'{__name__}.{_legacy_name}'] = import_module( + f'.{_module_name}', __name__ + ) + +del _legacy_name, _module_name, _legacy_module_aliases, import_module, sys diff --git a/pyomo/contrib/mindtpy/tests/constraint_qualification_example.py b/pyomo/contrib/mindtpy/tests/constraint_qualification_example.py index 0d9590914a0..a5efcc2040e 100644 --- a/pyomo/contrib/mindtpy/tests/constraint_qualification_example.py +++ b/pyomo/contrib/mindtpy/tests/constraint_qualification_example.py @@ -32,8 +32,18 @@ class ConstraintQualificationExample(ConcreteModel): + """Test model designed to exercise constraint qualification edge cases.""" + def __init__(self, *args, **kwargs): - """Create the problem.""" + """Create the problem. + + Parameters + ---------- + *args + Positional arguments forwarded to ``ConcreteModel``. + **kwargs + Keyword arguments forwarded to ``ConcreteModel``. + """ kwargs.setdefault('name', 'ConstraintQualificationExample') super(ConstraintQualificationExample, self).__init__(*args, **kwargs) m = self diff --git a/pyomo/contrib/mindtpy/tests/eight_process_problem.py b/pyomo/contrib/mindtpy/tests/eight_process_problem.py index 73f6ca9e7f3..4e59cc973da 100644 --- a/pyomo/contrib/mindtpy/tests/eight_process_problem.py +++ b/pyomo/contrib/mindtpy/tests/eight_process_problem.py @@ -13,10 +13,13 @@ Re-implementation of Duran example 3 superstructure synthesis problem in Pyomo Author: Qi Chen . -Ref: - SELECT OPTIMAL PROCESS FROM WITHIN GIVEN SUPERSTRUCTURE. - MARCO DURAN , PH.D. THESIS (EX3) , 1984. - CARNEGIE-MELLON UNIVERSITY , PITTSBURGH , PA. +References +---------- +Duran, M. P. (1984). Select optimal process from within given superstructure. +PhD thesis, Carnegie-Mellon University, Pittsburgh, PA. + +Turkay, M., and Grossmann, I. E. (1996). Pictorial representation of the +superstructure synthesis problem. DOI: http://dx.doi.org/10.1016/0098-1354(95)00219-7 The expected optimal solution value is 68.0. @@ -51,7 +54,18 @@ class EightProcessFlowsheet(ConcreteModel): """Flowsheet for the 8 process problem.""" def __init__(self, convex=True, *args, **kwargs): - """Create the flowsheet.""" + """Create the flowsheet. + + Parameters + ---------- + convex : bool, optional + Whether to use the convex variant of the benchmark model. + The convex variant replaces the exponential process equalities with <= inequalities. + *args + Positional arguments forwarded to ``ConcreteModel``. + **kwargs + Keyword arguments forwarded to ``ConcreteModel``. + """ kwargs.setdefault('name', 'DuranEx3') super(EightProcessFlowsheet, self).__init__(*args, **kwargs) m = self diff --git a/pyomo/contrib/mindtpy/tests/feasibility_pump1.py b/pyomo/contrib/mindtpy/tests/feasibility_pump1.py index 224b7c50988..94bccb65c1c 100644 --- a/pyomo/contrib/mindtpy/tests/feasibility_pump1.py +++ b/pyomo/contrib/mindtpy/tests/feasibility_pump1.py @@ -32,13 +32,21 @@ from pyomo.common.collections import ComponentMap -class FeasPump1(ConcreteModel): +class FeasibilityPump1(ConcreteModel): """Feasibility Pump example 1""" def __init__(self, *args, **kwargs): - """Create the problem.""" + """Create the problem. + + Parameters + ---------- + *args + Positional arguments forwarded to ``ConcreteModel``. + **kwargs + Keyword arguments forwarded to ``ConcreteModel``. + """ kwargs.setdefault('name', 'Feasibility Pump 1') - super(FeasPump1, self).__init__(*args, **kwargs) + super(FeasibilityPump1, self).__init__(*args, **kwargs) m = self m.x = Var(within=Binary) @@ -57,3 +65,7 @@ def __init__(self, *args, **kwargs): m.optimal_solution[m.x] = 0.0 m.optimal_solution[m.y1] = 0.5 m.optimal_solution[m.y2] = 0.0 + + +# Backward-compatible alias. +FeasPump1 = FeasibilityPump1 diff --git a/pyomo/contrib/mindtpy/tests/feasibility_pump2.py b/pyomo/contrib/mindtpy/tests/feasibility_pump2.py index 30b2d1bf011..bdffaa9c3a1 100644 --- a/pyomo/contrib/mindtpy/tests/feasibility_pump2.py +++ b/pyomo/contrib/mindtpy/tests/feasibility_pump2.py @@ -35,13 +35,21 @@ from pyomo.common.collections import ComponentMap -class FeasPump2(ConcreteModel): +class FeasibilityPump2(ConcreteModel): """Feasibility Pump example 2""" def __init__(self, *args, **kwargs): - """Create the problem.""" + """Create the problem. + + Parameters + ---------- + *args + Positional arguments forwarded to ``ConcreteModel``. + **kwargs + Keyword arguments forwarded to ``ConcreteModel``. + """ kwargs.setdefault('name', 'Feasibility Pump 2') - super(FeasPump2, self).__init__(*args, **kwargs) + super(FeasibilityPump2, self).__init__(*args, **kwargs) m = self m.x = Var(within=Binary) @@ -55,3 +63,7 @@ def __init__(self, *args, **kwargs): m.optimal_solution = ComponentMap() m.optimal_solution[m.x] = 0.0 m.optimal_solution[m.y] = 0.0 + + +# Backward-compatible alias. +FeasPump2 = FeasibilityPump2 diff --git a/pyomo/contrib/mindtpy/tests/from_proposal.py b/pyomo/contrib/mindtpy/tests/from_proposal.py index 77ce5a065dc..06b50dec1fb 100644 --- a/pyomo/contrib/mindtpy/tests/from_proposal.py +++ b/pyomo/contrib/mindtpy/tests/from_proposal.py @@ -8,9 +8,13 @@ # ____________________________________________________________________________________ # -*- coding: utf-8 -*- -""" -See David Bernal PhD proposal example. -Link: https://www.researchgate.net/project/Convex-MINLP/update/5c7eb2ee3843b034242e9e4a +"""MINLP test model adapted from material in David Bernal's PhD thesis proposal. + +References +---------- +Bernal, D. E. Convex MINLP project update derived from the thesis-proposal +material. +ResearchGate project page: https://www.researchgate.net/project/Convex-MINLP/update/5c7eb2ee3843b034242e9e4a """ from pyomo.environ import ( @@ -26,11 +30,21 @@ from pyomo.common.collections import ComponentMap -class ProposalModel(ConcreteModel): +class FromProposalModel(ConcreteModel): + """MINLP benchmark adapted from David Bernal's PhD thesis proposal material.""" + def __init__(self, *args, **kwargs): - """Create the problem.""" + """Create the problem. + + Parameters + ---------- + *args + Positional arguments forwarded to ``ConcreteModel``. + **kwargs + Keyword arguments forwarded to ``ConcreteModel``. + """ kwargs.setdefault('name', 'DavidProposalExample') - super(ProposalModel, self).__init__(*args, **kwargs) + super(FromProposalModel, self).__init__(*args, **kwargs) m = self m.x = Var(domain=Reals, bounds=(0, 20), initialize=1) @@ -46,3 +60,7 @@ def __init__(self, *args, **kwargs): m.optimal_solution = ComponentMap() m.optimal_solution[m.x] = 1.1099999999999999 m.optimal_solution[m.y] = 11.0 + + +# Backward-compatible alias. +ProposalModel = FromProposalModel diff --git a/pyomo/contrib/mindtpy/tests/MINLP2_simple.py b/pyomo/contrib/mindtpy/tests/minlp2_simple.py similarity index 89% rename from pyomo/contrib/mindtpy/tests/MINLP2_simple.py rename to pyomo/contrib/mindtpy/tests/minlp2_simple.py index 792ef31a2fd..4636566dbd8 100644 --- a/pyomo/contrib/mindtpy/tests/MINLP2_simple.py +++ b/pyomo/contrib/mindtpy/tests/minlp2_simple.py @@ -47,13 +47,21 @@ from pyomo.common.collections import ComponentMap -class SimpleMINLP(ConcreteModel): +class Minlp2Simple(ConcreteModel): """Example 1 Outer Approximation and Extended Cutting Planes.""" def __init__(self, *args, **kwargs): - """Create the problem.""" - kwargs.setdefault('name', 'SimpleMINLP2') - super(SimpleMINLP, self).__init__(*args, **kwargs) + """Create the problem. + + Parameters + ---------- + *args + Positional arguments forwarded to ``ConcreteModel``. + **kwargs + Keyword arguments forwarded to ``ConcreteModel``. + """ + kwargs.setdefault('name', 'Minlp2Simple') + super(Minlp2Simple, self).__init__(*args, **kwargs) m = self """Set declarations""" @@ -113,3 +121,7 @@ def __init__(self, *args, **kwargs): m.optimal_solution[m.Y[1]] = 0.0 m.optimal_solution[m.Y[2]] = 1.0 m.optimal_solution[m.Y[3]] = 0.0 + + +# Backward-compatible alias. +SimpleMINLP = Minlp2Simple diff --git a/pyomo/contrib/mindtpy/tests/MINLP3_simple.py b/pyomo/contrib/mindtpy/tests/minlp3_simple.py similarity index 83% rename from pyomo/contrib/mindtpy/tests/MINLP3_simple.py rename to pyomo/contrib/mindtpy/tests/minlp3_simple.py index 9035d5caa54..195b8ac4acc 100644 --- a/pyomo/contrib/mindtpy/tests/MINLP3_simple.py +++ b/pyomo/contrib/mindtpy/tests/minlp3_simple.py @@ -42,11 +42,21 @@ from pyomo.common.collections import ComponentMap -class SimpleMINLP(ConcreteModel): +class Minlp3Simple(ConcreteModel): + """Convex MINLP test instance based on Quesada and Grossmann.""" + def __init__(self, *args, **kwargs): - """Create the problem.""" - kwargs.setdefault('name', 'SimpleMINLP3') - super(SimpleMINLP, self).__init__(*args, **kwargs) + """Create the problem. + + Parameters + ---------- + *args + Positional arguments forwarded to ``ConcreteModel``. + **kwargs + Keyword arguments forwarded to ``ConcreteModel``. + """ + kwargs.setdefault('name', 'Minlp3Simple') + super(Minlp3Simple, self).__init__(*args, **kwargs) m = self """Set declarations""" @@ -80,3 +90,7 @@ def __init__(self, *args, **kwargs): m.optimal_solution[m.X[1]] = 0.20710677582302733 m.optimal_solution[m.X[2]] = 0.9411320859243828 m.optimal_solution[m.Y[1]] = 0.0 + + +# Backward-compatible alias. +SimpleMINLP = Minlp3Simple diff --git a/pyomo/contrib/mindtpy/tests/MINLP4_simple.py b/pyomo/contrib/mindtpy/tests/minlp4_simple.py similarity index 67% rename from pyomo/contrib/mindtpy/tests/MINLP4_simple.py rename to pyomo/contrib/mindtpy/tests/minlp4_simple.py index bd2ef695891..7919d112c26 100644 --- a/pyomo/contrib/mindtpy/tests/MINLP4_simple.py +++ b/pyomo/contrib/mindtpy/tests/minlp4_simple.py @@ -8,12 +8,15 @@ # ____________________________________________________________________________________ # -*- coding: utf-8 -*- -"""Example 1 in Paper 'Using regularization and second order information in outer approximation for convex MINLP' +"""Convex MINLP test model based on Example 1 from a regularization OA study. The expected optimal solution value is -56.981. -Ref: - Kronqvist J, Bernal D E, Grossmann I E. Using regularization and second order information in outer approximation for convex MINLP[J]. Mathematical Programming, 2020, 180(1): 285-310. +References +---------- +Kronqvist, J., Bernal, D. E., and Grossmann, I. E. (2020). Using +regularization and second order information in outer approximation for convex +MINLP. Mathematical Programming, 180(1), 285-310. Problem type: convex MINLP size: 1 binary variable @@ -37,11 +40,21 @@ from pyomo.common.collections import ComponentMap -class SimpleMINLP4(ConcreteModel): +class Minlp4Simple(ConcreteModel): + """Convex MINLP benchmark instance used in MindtPy regression tests.""" + def __init__(self, *args, **kwargs): - """Create the problem.""" - kwargs.setdefault('name', 'SimpleMINLP4') - super(SimpleMINLP4, self).__init__(*args, **kwargs) + """Create the problem. + + Parameters + ---------- + *args + Positional arguments forwarded to ``ConcreteModel``. + **kwargs + Keyword arguments forwarded to ``ConcreteModel``. + """ + kwargs.setdefault('name', 'Minlp4Simple') + super(Minlp4Simple, self).__init__(*args, **kwargs) m = self m.x = Var(domain=Reals, bounds=(1, 20), initialize=5.29) @@ -61,3 +74,7 @@ def __init__(self, *args, **kwargs): m.optimal_solution = ComponentMap() m.optimal_solution[m.x] = 7.663528589138092 m.optimal_solution[m.y] = 11.0 + + +# Backward-compatible alias. +SimpleMINLP4 = Minlp4Simple diff --git a/pyomo/contrib/mindtpy/tests/MINLP5_simple.py b/pyomo/contrib/mindtpy/tests/minlp5_simple.py similarity index 69% rename from pyomo/contrib/mindtpy/tests/MINLP5_simple.py rename to pyomo/contrib/mindtpy/tests/minlp5_simple.py index 8b51089061c..10734e90099 100644 --- a/pyomo/contrib/mindtpy/tests/MINLP5_simple.py +++ b/pyomo/contrib/mindtpy/tests/minlp5_simple.py @@ -10,10 +10,13 @@ # -*- coding: utf-8 -*- """Example in paper 'Using regularization and second order information in outer approximation for convex MINLP' -Ref: -Kronqvist J, Bernal D E, Grossmann I E. Using regularization and second order information in outer approximation for convex MINLP[J]. Mathematical Programming, 2020, 180(1): 285-310. +References +---------- +Kronqvist, J., Bernal, D. E., and Grossmann, I. E. (2020). Using +regularization and second order information in outer approximation for convex +MINLP. Mathematical Programming, 180(1), 285-310. -Problem type: nonconvex MINLP +Problem type: convex MINLP size: 1 binary variable 1 continuous variables 3 constraints @@ -33,11 +36,21 @@ from pyomo.common.collections import ComponentMap -class SimpleMINLP5(ConcreteModel): +class Minlp5Simple(ConcreteModel): + """Convex MINLP test instance used by regularization and utility tests.""" + def __init__(self, *args, **kwargs): - """Create the problem.""" - kwargs.setdefault('name', 'SimpleMINLP5') - super(SimpleMINLP5, self).__init__(*args, **kwargs) + """Create the problem. + + Parameters + ---------- + *args + Positional arguments forwarded to ``ConcreteModel``. + **kwargs + Keyword arguments forwarded to ``ConcreteModel``. + """ + kwargs.setdefault('name', 'Minlp5Simple') + super(Minlp5Simple, self).__init__(*args, **kwargs) m = self m.x = Var(within=Reals, bounds=(1, 20), initialize=5.29) @@ -57,3 +70,7 @@ def __init__(self, *args, **kwargs): m.optimal_solution = ComponentMap() m.optimal_solution[m.x] = 4.991797840270567 m.optimal_solution[m.y] = 7.0 + + +# Backward-compatible alias. +SimpleMINLP5 = Minlp5Simple diff --git a/pyomo/contrib/mindtpy/tests/MINLP_simple.py b/pyomo/contrib/mindtpy/tests/minlp_simple.py similarity index 69% rename from pyomo/contrib/mindtpy/tests/MINLP_simple.py rename to pyomo/contrib/mindtpy/tests/minlp_simple.py index 58ba497119d..f3934c4b874 100644 --- a/pyomo/contrib/mindtpy/tests/MINLP_simple.py +++ b/pyomo/contrib/mindtpy/tests/minlp_simple.py @@ -13,9 +13,10 @@ The expected optimal solution is 3.5. -Ref: - IGNACIO GROSSMANN. - CARNEGIE-MELLON UNIVERSITY , PITTSBURGH , PA. +References +---------- +Grossmann, I. E. (Advanced PSE lecture, Assignment 6). Carnegie-Mellon +University, Pittsburgh, PA. Problem type: convex MINLP size: 3 binary variables @@ -36,23 +37,37 @@ Block, ) from pyomo.common.collections import ComponentMap -from pyomo.contrib.mindtpy.tests.MINLP_simple_grey_box import ( +from pyomo.contrib.mindtpy.tests.minlp_simple_grey_box import ( GreyBoxModel, build_model_external, ) -class SimpleMINLP(ConcreteModel): +class MinlpSimple(ConcreteModel): """Convex MINLP problem Assignment 6 APSE.""" def __init__(self, grey_box=False, *args, **kwargs): - """Create the problem.""" - kwargs.setdefault('name', 'SimpleMINLP') + """Create the problem. + + Parameters + ---------- + grey_box : bool, optional + Whether to formulate the model using an external grey-box block. + When ``True``, the model adds linking constraints ``con_X_1``, + ``con_X_2``, ``con_Y_1``, ``con_Y_2``, and ``con_Y_3``. + *args + Positional arguments forwarded to ``ConcreteModel``. + **kwargs + Keyword arguments forwarded to ``ConcreteModel``. + """ + kwargs.setdefault('name', 'MinlpSimple') if grey_box and GreyBoxModel is None: - m = None - return + raise RuntimeError( + 'The grey-box MinlpSimple variant requires ' + 'pyomo.contrib.pynumero.interfaces.external_grey_box.' + ) - super(SimpleMINLP, self).__init__(*args, **kwargs) + super(MinlpSimple, self).__init__(*args, **kwargs) m = self """Set declarations""" @@ -99,6 +114,13 @@ def __init__(self, grey_box=False, *args, **kwargs): else: def _model_i(b): + """Build the external grey-box block for this model. + + Parameters + ---------- + b : Block + Block receiving the external grey-box declaration. + """ build_model_external(b) m.my_block = Block(rule=_model_i) @@ -106,6 +128,13 @@ def _model_i(b): for i in m.I: def eq_inputX(m): + """Link continuous variable ``X[i]`` to grey-box input ``Xi``. + + Parameters + ---------- + m : Block + Model block used to build the linking constraint. + """ return m.X[i] == m.my_block.egb.inputs["X" + str(i)] con_name = "con_X_" + str(i) @@ -114,6 +143,13 @@ def eq_inputX(m): for j in m.J: def eq_inputY(m): + """Link binary variable ``Y[j]`` to grey-box input ``Yj``. + + Parameters + ---------- + m : Block + Model block used to build the linking constraint. + """ return m.Y[j] == m.my_block.egb.inputs["Y" + str(j)] con_name = "con_Y_" + str(j) @@ -135,3 +171,7 @@ def eq_inputY(m): m.optimal_solution[m.Y[1]] = 0.0 m.optimal_solution[m.Y[2]] = 1.0 m.optimal_solution[m.Y[3]] = 0.0 + + +# Backward-compatible alias. +SimpleMINLP = MinlpSimple diff --git a/pyomo/contrib/mindtpy/tests/MINLP_simple_grey_box.py b/pyomo/contrib/mindtpy/tests/minlp_simple_grey_box.py similarity index 82% rename from pyomo/contrib/mindtpy/tests/MINLP_simple_grey_box.py rename to pyomo/contrib/mindtpy/tests/minlp_simple_grey_box.py index 9a38885c6b1..11231500bb6 100644 --- a/pyomo/contrib/mindtpy/tests/MINLP_simple_grey_box.py +++ b/pyomo/contrib/mindtpy/tests/minlp_simple_grey_box.py @@ -7,6 +7,8 @@ # software. This software is distributed under the 3-clause BSD License. # ____________________________________________________________________________________ +"""Grey-box MINLP test model utilities for MindtPy test coverage.""" + from pyomo.common.dependencies import numpy as np import pyomo.common.dependencies.scipy.sparse as scipy_sparse from pyomo.common.dependencies import attempt_import @@ -21,13 +23,16 @@ class GreyBoxModel(egb.ExternalGreyBoxModel): """Greybox model to compute the example objective function.""" def __init__(self, initial, use_exact_derivatives=True, verbose=False): - """ - Parameters + """Initialize the external grey-box model. - use_exact_derivatives: bool + Parameters + ---------- + initial : dict + Initial values for model inputs. + use_exact_derivatives : bool, optional If True, the exact derivatives are used. If False, the finite difference approximation is used. - verbose: bool + verbose : bool, optional If True, print information about the model. """ self._use_exact_derivatives = use_exact_derivatives @@ -58,13 +63,25 @@ def output_names(self): return ['z'] def set_output_constraint_multipliers(self, output_con_multiplier_values): - """Set the values of the output constraint multipliers.""" + """Set the values of the output constraint multipliers. + + Parameters + ---------- + output_con_multiplier_values : array_like + Output-constraint multipliers provided by the NLP interface. + """ # because we only have one output constraint assert len(output_con_multiplier_values) == 1 np.copyto(self._output_con_mult_values, output_con_multiplier_values) def finalize_block_construction(self, pyomo_block): - """Finalize the construction of the ExternalGreyBoxBlock.""" + """Finalize the construction of the ExternalGreyBoxBlock. + + Parameters + ---------- + pyomo_block : Block + External grey-box block being finalized. + """ if self.initial is not None: print("initialized") pyomo_block.inputs["X1"].value = self.initial["X1"] @@ -94,7 +111,13 @@ def finalize_block_construction(self, pyomo_block): pyomo_block.inputs["Y3"].setlb(0) def set_input_values(self, input_values): - """Set the values of the inputs.""" + """Set the values of the inputs. + + Parameters + ---------- + input_values : iterable + Input values in the order returned by ``input_names``. + """ self._input_values = list(input_values) def evaluate_equality_constraints(self): @@ -150,10 +173,18 @@ def evaluate_jacobian_outputs(self): # sparse matrix return scipy_sparse.coo_matrix((data, (row, col)), shape=(1, 5)) - def build_model_external(m): + def build_model_external(model_block): + """Create and attach the external grey-box block on the given block. + + Parameters + ---------- + model_block : Block + Block on a ``MinlpSimple`` test instance that receives the + ``egb`` component when grey-box coverage is enabled. + """ ex_model = GreyBoxModel(initial={"X1": 0, "X2": 0, "Y1": 0, "Y2": 1, "Y3": 1}) - m.egb = egb.ExternalGreyBoxBlock() - m.egb.set_external_model(ex_model) + model_block.egb = egb.ExternalGreyBoxBlock() + model_block.egb.set_external_model(ex_model) else: GreyBoxModel = None diff --git a/pyomo/contrib/mindtpy/tests/nonconvex1.py b/pyomo/contrib/mindtpy/tests/nonconvex1.py index 9c6782bcd4a..4396a091124 100644 --- a/pyomo/contrib/mindtpy/tests/nonconvex1.py +++ b/pyomo/contrib/mindtpy/tests/nonconvex1.py @@ -8,15 +8,20 @@ # ____________________________________________________________________________________ # -*- coding: utf-8 -*- -"""Problem A in paper 'Outer approximation algorithms for separable nonconvex mixed-integer nonlinear programs' +"""Nonconvex MINLP test model based on problem A from a benchmark study. -Ref: -Kesavan P, Allgor R J, Gatzke E P, et al. Outer approximation algorithms for separable nonconvex mixed-integer nonlinear programs[J]. Mathematical Programming, 2004, 100(3): 517-535. +The expected optimal solution value is 7.667. -Problem type: nonconvex MINLP - size: 3 binary variable - 2 continuous variables - 6 constraints +References +---------- +Kesavan, P., Allgor, R. J., Gatzke, E. P., et al. (2004). Outer approximation +algorithms for separable nonconvex mixed-integer nonlinear programs. +Mathematical Programming, 100(3), 517-535. + + Problem type: nonconvex MINLP + size: 3 binary variables + 2 continuous variables + 6 constraints """ @@ -33,8 +38,18 @@ class Nonconvex1(ConcreteModel): + """Nonconvex MINLP benchmark instance used in MindtPy regression tests.""" + def __init__(self, *args, **kwargs): - """Create the problem.""" + """Create the problem. + + Parameters + ---------- + *args + Positional arguments forwarded to ``ConcreteModel``. + **kwargs + Keyword arguments forwarded to ``ConcreteModel``. + """ kwargs.setdefault('name', 'Nonconvex1') super(Nonconvex1, self).__init__(*args, **kwargs) m = self diff --git a/pyomo/contrib/mindtpy/tests/nonconvex2.py b/pyomo/contrib/mindtpy/tests/nonconvex2.py index 376971d7ff5..264937c1300 100644 --- a/pyomo/contrib/mindtpy/tests/nonconvex2.py +++ b/pyomo/contrib/mindtpy/tests/nonconvex2.py @@ -8,15 +8,20 @@ # ____________________________________________________________________________________ # -*- coding: utf-8 -*- -"""Problem B in paper 'Outer approximation algorithms for separable nonconvex mixed-integer nonlinear programs' +"""Nonconvex MINLP test model based on problem B from a benchmark study. -Ref: -Kesavan P, Allgor R J, Gatzke E P, et al. Outer approximation algorithms for separable nonconvex mixed-integer nonlinear programs[J]. Mathematical Programming, 2004, 100(3): 517-535. +The expected optimal solution value is -0.94347. -Problem type: nonconvex MINLP - size: 8 binary variable - 3 continuous variables - 7 constraints +References +---------- +Kesavan, P., Allgor, R. J., Gatzke, E. P., et al. (2004). Outer approximation +algorithms for separable nonconvex mixed-integer nonlinear programs. +Mathematical Programming, 100(3), 517-535. + + Problem type: nonconvex MINLP + size: 8 binary variables + 3 continuous variables + 7 constraints """ @@ -34,8 +39,18 @@ class Nonconvex2(ConcreteModel): + """Nonconvex MINLP benchmark problem B for MindtPy tests.""" + def __init__(self, *args, **kwargs): - """Create the problem.""" + """Create the problem. + + Parameters + ---------- + *args + Positional arguments forwarded to ``ConcreteModel``. + **kwargs + Keyword arguments forwarded to ``ConcreteModel``. + """ kwargs.setdefault('name', 'Nonconvex2') super(Nonconvex2, self).__init__(*args, **kwargs) m = self diff --git a/pyomo/contrib/mindtpy/tests/nonconvex3.py b/pyomo/contrib/mindtpy/tests/nonconvex3.py index ad96f16f0bc..3708046cf44 100644 --- a/pyomo/contrib/mindtpy/tests/nonconvex3.py +++ b/pyomo/contrib/mindtpy/tests/nonconvex3.py @@ -8,16 +8,24 @@ # ____________________________________________________________________________________ # -*- coding: utf-8 -*- -"""Problem C in paper 'Outer approximation algorithms for separable nonconvex mixed-integer nonlinear programs'. -The problem in the paper has two optimal solution. Variable y4 and y6 are symmetric. Therefore, we remove variable y6 for simplification. +"""Nonconvex MINLP test model based on problem C from a benchmark study. -Ref: -Kesavan P, Allgor R J, Gatzke E P, et al. Outer approximation algorithms for separable nonconvex mixed-integer nonlinear programs[J]. Mathematical Programming, 2004, 100(3): 517-535. +The expected optimal solution value is 31. -Problem type: nonconvex MINLP - size: 6 binary variable - 2 continuous variables - 6 constraints +The published benchmark has two symmetric optimal solutions in ``y4`` and +``y6``. This simplified test variant removes ``y6``, so the Pyomo model uses +five binary variables. + +References +---------- +Kesavan, P., Allgor, R. J., Gatzke, E. P., et al. (2004). Outer approximation +algorithms for separable nonconvex mixed-integer nonlinear programs. +Mathematical Programming, 100(3), 517-535. + + Problem type: nonconvex MINLP + size: 5 binary variables + 2 continuous variables + 6 constraints """ @@ -34,8 +42,18 @@ class Nonconvex3(ConcreteModel): + """Nonconvex MINLP benchmark problem C for MindtPy tests.""" + def __init__(self, *args, **kwargs): - """Create the problem.""" + """Create the problem. + + Parameters + ---------- + *args + Positional arguments forwarded to ``ConcreteModel``. + **kwargs + Keyword arguments forwarded to ``ConcreteModel``. + """ kwargs.setdefault('name', 'Nonconvex3') super(Nonconvex3, self).__init__(*args, **kwargs) m = self diff --git a/pyomo/contrib/mindtpy/tests/nonconvex4.py b/pyomo/contrib/mindtpy/tests/nonconvex4.py index 5b0ffd3839e..3946ab265d1 100644 --- a/pyomo/contrib/mindtpy/tests/nonconvex4.py +++ b/pyomo/contrib/mindtpy/tests/nonconvex4.py @@ -8,15 +8,20 @@ # ____________________________________________________________________________________ # -*- coding: utf-8 -*- -"""Problem D in paper 'Outer approximation algorithms for separable nonconvex mixed-integer nonlinear programs' +"""Nonconvex MINLP test model based on problem D from a benchmark study. -Ref: -Kesavan P, Allgor R J, Gatzke E P, et al. Outer approximation algorithms for separable nonconvex mixed-integer nonlinear programs[J]. Mathematical Programming, 2004, 100(3): 517-535. +The expected optimal solution value is -17. -Problem type: nonconvex MINLP - size: 3 binary variable - 2 continuous variables - 4 constraints +References +---------- +Kesavan, P., Allgor, R. J., Gatzke, E. P., et al. (2004). Outer approximation +algorithms for separable nonconvex mixed-integer nonlinear programs. +Mathematical Programming, 100(3), 517-535. + + Problem type: nonconvex MINLP + size: 3 binary variables + 2 continuous variables + 4 constraints """ @@ -34,8 +39,18 @@ class Nonconvex4(ConcreteModel): + """Nonconvex MINLP benchmark problem D for MindtPy tests.""" + def __init__(self, *args, **kwargs): - """Create the problem.""" + """Create the problem. + + Parameters + ---------- + *args + Positional arguments forwarded to ``ConcreteModel``. + **kwargs + Keyword arguments forwarded to ``ConcreteModel``. + """ kwargs.setdefault('name', 'Nonconvex4') super(Nonconvex4, self).__init__(*args, **kwargs) m = self diff --git a/pyomo/contrib/mindtpy/tests/online_doc_example.py b/pyomo/contrib/mindtpy/tests/online_doc_example.py index 486e6eebc27..c119aaed1c9 100644 --- a/pyomo/contrib/mindtpy/tests/online_doc_example.py +++ b/pyomo/contrib/mindtpy/tests/online_doc_example.py @@ -7,7 +7,7 @@ # software. This software is distributed under the 3-clause BSD License. # ____________________________________________________________________________________ -"""Example in the online doc. +"""Compact MINLP example mirrored from the MindtPy online documentation. The expected optimal solution value is 2.438447187191098. @@ -31,8 +31,18 @@ class OnlineDocExample(ConcreteModel): + """Compact MINLP benchmark used in documentation and regression tests.""" + def __init__(self, *args, **kwargs): - """Create the problem.""" + """Create the problem. + + Parameters + ---------- + *args + Positional arguments forwarded to ``ConcreteModel``. + **kwargs + Keyword arguments forwarded to ``ConcreteModel``. + """ kwargs.setdefault('name', 'OnlineDocExample') super(OnlineDocExample, self).__init__(*args, **kwargs) m = self diff --git a/pyomo/contrib/mindtpy/tests/test_mindtpy.py b/pyomo/contrib/mindtpy/tests/test_mindtpy.py index 84110f95648..f758d809b40 100644 --- a/pyomo/contrib/mindtpy/tests/test_mindtpy.py +++ b/pyomo/contrib/mindtpy/tests/test_mindtpy.py @@ -12,12 +12,12 @@ from pyomo.core.expr.calculus.diff_with_sympy import differentiate_available import pyomo.common.unittest as unittest from pyomo.contrib.mindtpy.tests.eight_process_problem import EightProcessFlowsheet -from pyomo.contrib.mindtpy.tests.MINLP_simple import SimpleMINLP as SimpleMINLP -from pyomo.contrib.mindtpy.tests.MINLP2_simple import SimpleMINLP as SimpleMINLP2 -from pyomo.contrib.mindtpy.tests.MINLP3_simple import SimpleMINLP as SimpleMINLP3 -from pyomo.contrib.mindtpy.tests.MINLP4_simple import SimpleMINLP4 -from pyomo.contrib.mindtpy.tests.MINLP5_simple import SimpleMINLP5 -from pyomo.contrib.mindtpy.tests.from_proposal import ProposalModel +from pyomo.contrib.mindtpy.tests.minlp_simple import MinlpSimple +from pyomo.contrib.mindtpy.tests.minlp2_simple import Minlp2Simple +from pyomo.contrib.mindtpy.tests.minlp3_simple import Minlp3Simple +from pyomo.contrib.mindtpy.tests.minlp4_simple import Minlp4Simple +from pyomo.contrib.mindtpy.tests.minlp5_simple import Minlp5Simple +from pyomo.contrib.mindtpy.tests.from_proposal import FromProposalModel from pyomo.contrib.mindtpy.tests.constraint_qualification_example import ( ConstraintQualificationExample, ) @@ -30,22 +30,22 @@ full_model_list = [ EightProcessFlowsheet(convex=True), ConstraintQualificationExample(), - SimpleMINLP(), - SimpleMINLP2(), - SimpleMINLP3(), - SimpleMINLP4(), - SimpleMINLP5(), - ProposalModel(), + MinlpSimple(), + Minlp2Simple(), + Minlp3Simple(), + Minlp4Simple(), + Minlp5Simple(), + FromProposalModel(), OnlineDocExample(), ] model_list = [ EightProcessFlowsheet(convex=True), ConstraintQualificationExample(), - SimpleMINLP2(), + Minlp2Simple(), ] nonconvex_model_list = [EightProcessFlowsheet(convex=False)] -obj_nonlinear_sum_model_list = [SimpleMINLP(), SimpleMINLP5()] +obj_nonlinear_sum_model_list = [MinlpSimple(), Minlp5Simple()] LP_model = LP_unbounded() LP_model._generate_model() @@ -74,9 +74,18 @@ not differentiate_available, 'Symbolic differentiation is not available' ) class TestMindtPy(unittest.TestCase): - """Tests for the MindtPy solver plugin.""" + """Tests for the MindtPy solver.""" def check_optimal_solution(self, model, places=1): + """Assert that variable values match the model's known optimum. + + Parameters + ---------- + model : Block + Model containing ``optimal_solution`` values for comparison. + places : int, optional + Decimal places used by ``assertAlmostEqual``. + """ for var in model.optimal_solution: self.assertAlmostEqual( var.value, model.optimal_solution[var], places=places @@ -108,12 +117,19 @@ def test_OA_callback(self): """Test the outer approximation decomposition algorithm.""" with SolverFactory('mindtpy') as opt: - def callback(model): + def force_zero_binary_pattern_callback(model): + """Set binaries to a fixed pattern to induce cycling behavior. + + Parameters + ---------- + model : Block + Model whose binary variables are modified before subproblem solve. + """ model.Y[1].value = 0 model.Y[2].value = 0 model.Y[3].value = 0 - model = SimpleMINLP2() + model = Minlp2Simple() # The callback function will make the OA method cycling. results = opt.solve( model, @@ -121,7 +137,7 @@ def callback(model): init_strategy='rNLP', mip_solver=required_solvers[1], nlp_solver=required_solvers[0], - call_before_subproblem_solve=callback, + call_before_subproblem_solve=force_zero_binary_pattern_callback, ) self.assertIs( results.solver.termination_condition, TerminationCondition.feasible @@ -283,7 +299,7 @@ def test_OA_no_good_cuts(self): def test_OA_quadratic_strategy(self): """Test the outer approximation decomposition algorithm.""" with SolverFactory('mindtpy') as opt: - model = ProposalModel().clone() + model = FromProposalModel().clone() if SolverFactory('cplex').available(): mip_solver = 'cplex' elif SolverFactory('gurobi').available(): @@ -488,6 +504,7 @@ def test_OA_nonconvex(self): self.check_optimal_solution(model) def test_iteration_limit(self): + """Verify solve completes when a tight iteration limit is imposed.""" with SolverFactory('mindtpy') as opt: model = ConstraintQualificationExample() opt.solve( @@ -500,6 +517,7 @@ def test_iteration_limit(self): # self.assertAlmostEqual(value(model.objective.expr), 3, places=2) def test_time_limit(self): + """Verify solve respects a short global time limit setting.""" with SolverFactory('mindtpy') as opt: model = ConstraintQualificationExample() opt.solve( @@ -513,7 +531,7 @@ def test_time_limit(self): def test_maximize_obj(self): """Test the outer approximation decomposition algorithm.""" with SolverFactory('mindtpy') as opt: - model = ProposalModel().clone() + model = FromProposalModel().clone() model.objective.sense = maximize opt.solve( model, @@ -526,7 +544,7 @@ def test_maximize_obj(self): def test_infeasible_model(self): """Test the outer approximation decomposition algorithm.""" with SolverFactory('mindtpy') as opt: - model = SimpleMINLP().clone() + model = MinlpSimple().clone() model.X[1].fix(0) model.Y[1].fix(0) results = opt.solve( diff --git a/pyomo/contrib/mindtpy/tests/test_mindtpy_ECP.py b/pyomo/contrib/mindtpy/tests/test_mindtpy_ECP.py index de83ac0d773..a3e6fdc558f 100644 --- a/pyomo/contrib/mindtpy/tests/test_mindtpy_ECP.py +++ b/pyomo/contrib/mindtpy/tests/test_mindtpy_ECP.py @@ -12,10 +12,10 @@ import pyomo.common.unittest as unittest from pyomo.contrib.mindtpy.tests.eight_process_problem import EightProcessFlowsheet -from pyomo.contrib.mindtpy.tests.MINLP_simple import SimpleMINLP as SimpleMINLP -from pyomo.contrib.mindtpy.tests.MINLP2_simple import SimpleMINLP as SimpleMINLP2 -from pyomo.contrib.mindtpy.tests.MINLP3_simple import SimpleMINLP as SimpleMINLP3 -from pyomo.contrib.mindtpy.tests.from_proposal import ProposalModel +from pyomo.contrib.mindtpy.tests.minlp_simple import MinlpSimple +from pyomo.contrib.mindtpy.tests.minlp2_simple import Minlp2Simple +from pyomo.contrib.mindtpy.tests.minlp3_simple import Minlp3Simple +from pyomo.contrib.mindtpy.tests.from_proposal import FromProposalModel from pyomo.contrib.mindtpy.tests.constraint_qualification_example import ( ConstraintQualificationExample, ) @@ -37,10 +37,10 @@ model_list = [ EightProcessFlowsheet(convex=True), ConstraintQualificationExample(), - SimpleMINLP(), - SimpleMINLP2(), - SimpleMINLP3(), - ProposalModel(), + MinlpSimple(), + Minlp2Simple(), + Minlp3Simple(), + FromProposalModel(), ] @@ -49,9 +49,18 @@ 'Required subsolvers %s are not available' % (required_solvers,), ) class TestMindtPy(unittest.TestCase): - """Tests for the MindtPy solver plugin.""" + """Tests for the MindtPy solver.""" def check_optimal_solution(self, model, places=1): + """Assert that variable values match the model's known optimum. + + Parameters + ---------- + model : Block + Model containing ``optimal_solution`` values for comparison. + places : int, optional + Decimal places used by ``assertAlmostEqual``. + """ for var in model.optimal_solution: self.assertAlmostEqual( var.value, model.optimal_solution[var], places=places diff --git a/pyomo/contrib/mindtpy/tests/test_mindtpy_feas_pump.py b/pyomo/contrib/mindtpy/tests/test_mindtpy_feas_pump.py index 35f0070fe4c..d48c925a5cf 100644 --- a/pyomo/contrib/mindtpy/tests/test_mindtpy_feas_pump.py +++ b/pyomo/contrib/mindtpy/tests/test_mindtpy_feas_pump.py @@ -12,10 +12,10 @@ import pyomo.common.unittest as unittest from pyomo.contrib.mindtpy.tests.eight_process_problem import EightProcessFlowsheet -from pyomo.contrib.mindtpy.tests.MINLP_simple import SimpleMINLP as SimpleMINLP -from pyomo.contrib.mindtpy.tests.MINLP2_simple import SimpleMINLP as SimpleMINLP2 -from pyomo.contrib.mindtpy.tests.MINLP3_simple import SimpleMINLP as SimpleMINLP3 -from pyomo.contrib.mindtpy.tests.from_proposal import ProposalModel +from pyomo.contrib.mindtpy.tests.minlp_simple import MinlpSimple +from pyomo.contrib.mindtpy.tests.minlp2_simple import Minlp2Simple +from pyomo.contrib.mindtpy.tests.minlp3_simple import Minlp3Simple +from pyomo.contrib.mindtpy.tests.from_proposal import FromProposalModel from pyomo.contrib.mindtpy.tests.constraint_qualification_example import ( ConstraintQualificationExample, ) @@ -24,8 +24,8 @@ from pyomo.opt import TerminationCondition from pyomo.contrib.gdpopt.util import is_feasible from pyomo.util.infeasible import log_infeasible_constraints -from pyomo.contrib.mindtpy.tests.feasibility_pump1 import FeasPump1 -from pyomo.contrib.mindtpy.tests.feasibility_pump2 import FeasPump2 +from pyomo.contrib.mindtpy.tests.feasibility_pump1 import FeasibilityPump1 +from pyomo.contrib.mindtpy.tests.feasibility_pump2 import FeasibilityPump2 if SolverFactory('appsi_highs').available(exception_flag=False) and SolverFactory( 'appsi_highs' @@ -42,12 +42,12 @@ model_list = [ EightProcessFlowsheet(convex=True), ConstraintQualificationExample(), - FeasPump1(), - FeasPump2(), - SimpleMINLP(), - SimpleMINLP2(), - SimpleMINLP3(), - ProposalModel(), + FeasibilityPump1(), + FeasibilityPump2(), + MinlpSimple(), + Minlp2Simple(), + Minlp3Simple(), + FromProposalModel(), OnlineDocExample(), ] @@ -60,12 +60,33 @@ class TestMindtPy(unittest.TestCase): """Tests for the MindtPy solver.""" def check_optimal_solution(self, model, places=1): + """Assert that variable values match the model's known optimum. + + Parameters + ---------- + model : Block + Model containing ``optimal_solution`` values for comparison. + places : int, optional + Decimal places used by ``assertAlmostEqual``. + """ for var in model.optimal_solution: self.assertAlmostEqual( var.value, model.optimal_solution[var], places=places ) def get_config(self, solver): + """Return the active MindtPy configuration block for ``solver``. + + Parameters + ---------- + solver : SolverFactory + Instantiated MindtPy solver object. + + Returns + ------- + ConfigBlock + Active configuration block associated with ``solver``. + """ config = solver.CONFIG return config diff --git a/pyomo/contrib/mindtpy/tests/test_mindtpy_global.py b/pyomo/contrib/mindtpy/tests/test_mindtpy_global.py index 5f198c6bac8..a0f6e6f3f73 100644 --- a/pyomo/contrib/mindtpy/tests/test_mindtpy_global.py +++ b/pyomo/contrib/mindtpy/tests/test_mindtpy_global.py @@ -45,9 +45,18 @@ not pyomo.contrib.mcpp.pyomo_mcpp.mcpp_available(), 'MC++ is not available' ) class TestMindtPy(unittest.TestCase): - """Tests for the MindtPy solver plugin.""" + """Tests for the MindtPy solver.""" def check_optimal_solution(self, model, places=1): + """Assert that variable values match the model's known optimum. + + Parameters + ---------- + model : Block + Model containing ``optimal_solution`` values for comparison. + places : int, optional + Decimal places used by ``assertAlmostEqual``. + """ for var in model.optimal_solution: self.assertAlmostEqual( var.value, model.optimal_solution[var], places=places diff --git a/pyomo/contrib/mindtpy/tests/test_mindtpy_global_lp_nlp.py b/pyomo/contrib/mindtpy/tests/test_mindtpy_global_lp_nlp.py index 8c194837959..53db67a0c63 100644 --- a/pyomo/contrib/mindtpy/tests/test_mindtpy_global_lp_nlp.py +++ b/pyomo/contrib/mindtpy/tests/test_mindtpy_global_lp_nlp.py @@ -44,9 +44,18 @@ ) @unittest.skipIf(not pyomo_mcpp.mcpp_available(), 'MC++ is not available') class TestMindtPy(unittest.TestCase): - """Tests for the MindtPy solver plugin.""" + """Tests for the MindtPy solver.""" def check_optimal_solution(self, model, places=1): + """Assert that variable values match the model's known optimum. + + Parameters + ---------- + model : Block + Model containing ``optimal_solution`` values for comparison. + places : int, optional + Decimal places used by ``assertAlmostEqual``. + """ for var in model.optimal_solution: self.assertAlmostEqual( var.value, model.optimal_solution[var], places=places diff --git a/pyomo/contrib/mindtpy/tests/test_mindtpy_grey_box.py b/pyomo/contrib/mindtpy/tests/test_mindtpy_grey_box.py index 4c1ed2f3621..d2688a92491 100644 --- a/pyomo/contrib/mindtpy/tests/test_mindtpy_grey_box.py +++ b/pyomo/contrib/mindtpy/tests/test_mindtpy_grey_box.py @@ -14,9 +14,11 @@ from pyomo.environ import SolverFactory, value, maximize from pyomo.opt import TerminationCondition from pyomo.common.dependencies import numpy_available, scipy_available -from pyomo.contrib.mindtpy.tests.MINLP_simple import SimpleMINLP as SimpleMINLP +from pyomo.contrib.mindtpy.tests.minlp_simple import MinlpSimple +from pyomo.contrib.mindtpy.tests.minlp_simple_grey_box import GreyBoxModel -model_list = [SimpleMINLP(grey_box=True)] +model_list = [MinlpSimple] +grey_box_available = GreyBoxModel is not None and numpy_available and scipy_available if SolverFactory('appsi_highs').available(exception_flag=False) and SolverFactory( 'appsi_highs' @@ -31,7 +33,7 @@ subsolvers_available = False -@unittest.skipIf(model_list[0] is None, 'Unable to generate the Grey Box model.') +@unittest.skipIf(not grey_box_available, 'Unable to generate the Grey Box model.') @unittest.skipIf( not subsolvers_available, 'Required subsolvers %s are not available' % (required_solvers,), @@ -40,9 +42,18 @@ not differentiate_available, 'Symbolic differentiation is not available' ) class TestMindtPy(unittest.TestCase): - """Tests for the MindtPy solver plugin.""" + """Tests for the MindtPy solver.""" def check_optimal_solution(self, model, places=1): + """Assert that variable values match the model's known optimum. + + Parameters + ---------- + model : Block + Model containing ``optimal_solution`` values for comparison. + places : int, optional + Decimal places used by ``assertAlmostEqual``. + """ for var in model.optimal_solution: self.assertAlmostEqual( var.value, model.optimal_solution[var], places=places @@ -51,8 +62,8 @@ def check_optimal_solution(self, model, places=1): def test_OA_rNLP(self): """Test the outer approximation decomposition algorithm.""" with SolverFactory('mindtpy') as opt: - for model in model_list: - model = model.clone() + for model_factory in model_list: + model = model_factory(grey_box=True) results = opt.solve( model, strategy='OA', diff --git a/pyomo/contrib/mindtpy/tests/test_mindtpy_lp_nlp.py b/pyomo/contrib/mindtpy/tests/test_mindtpy_lp_nlp.py index 29047e5046a..4172681e80f 100644 --- a/pyomo/contrib/mindtpy/tests/test_mindtpy_lp_nlp.py +++ b/pyomo/contrib/mindtpy/tests/test_mindtpy_lp_nlp.py @@ -12,8 +12,8 @@ import sys import pyomo.common.unittest as unittest from pyomo.contrib.mindtpy.tests.eight_process_problem import EightProcessFlowsheet -from pyomo.contrib.mindtpy.tests.MINLP_simple import SimpleMINLP as SimpleMINLP -from pyomo.contrib.mindtpy.tests.MINLP3_simple import SimpleMINLP as SimpleMINLP3 +from pyomo.contrib.mindtpy.tests.minlp_simple import MinlpSimple +from pyomo.contrib.mindtpy.tests.minlp3_simple import Minlp3Simple from pyomo.contrib.mindtpy.tests.constraint_qualification_example import ( ConstraintQualificationExample, ) @@ -38,15 +38,29 @@ model_list = [ EightProcessFlowsheet(convex=True), ConstraintQualificationExample(), - SimpleMINLP(), - SimpleMINLP3(), + MinlpSimple(), + Minlp3Simple(), ] def known_solver_failure(mip_solver, model): + """Return True when a platform/solver/model combination is known to fail. + + Parameters + ---------- + mip_solver : str + Name of the MIP solver under test. + model : Block + Model currently being tested. + + Returns + ------- + bool + ``True`` if the combination matches a known failing case. + """ if ( mip_solver == 'gurobi_persistent' - and model.name in {'DuranEx3', 'SimpleMINLP'} + and model.name in {'DuranEx3', 'SimpleMINLP', 'MinlpSimple'} and sys.platform.startswith('win') and SolverFactory(mip_solver).version()[:3] == (9, 5, 0) ): @@ -63,10 +77,19 @@ def known_solver_failure(mip_solver, model): 'Required subsolvers %s are not available' % ([required_nlp_solvers] + required_mip_solvers), ) -class TestMindtPy(unittest.TestCase): - """Tests for the MindtPy solver plugin.""" +class TestMindtPyLpNlp(unittest.TestCase): + """LP/NLP decomposition tests for the MindtPy solver.""" def check_optimal_solution(self, model, places=1): + """Assert that variable values match the model's known optimum. + + Parameters + ---------- + model : Block + Model containing ``optimal_solution`` values for comparison. + places : int, optional + Decimal places used by ``assertAlmostEqual``. + """ for var in model.optimal_solution: self.assertAlmostEqual( var.value, model.optimal_solution[var], places=places diff --git a/pyomo/contrib/mindtpy/tests/test_mindtpy_regularization.py b/pyomo/contrib/mindtpy/tests/test_mindtpy_regularization.py index 6314808d902..f2de2b6a334 100644 --- a/pyomo/contrib/mindtpy/tests/test_mindtpy_regularization.py +++ b/pyomo/contrib/mindtpy/tests/test_mindtpy_regularization.py @@ -34,9 +34,18 @@ 'Required subsolvers %s are not available' % (required_solvers,), ) class TestMindtPy(unittest.TestCase): - """Tests for the MindtPy solver plugin.""" + """Tests for the MindtPy solver.""" def check_optimal_solution(self, model, places=1): + """Assert that variable values match the model's known optimum. + + Parameters + ---------- + model : Block + Model containing ``optimal_solution`` values for comparison. + places : int, optional + Decimal places used by ``assertAlmostEqual``. + """ for var in model.optimal_solution: self.assertAlmostEqual( var.value, model.optimal_solution[var], places=places diff --git a/pyomo/contrib/mindtpy/tests/test_mindtpy_solution_pool.py b/pyomo/contrib/mindtpy/tests/test_mindtpy_solution_pool.py index f3f935880e0..8bdf79b1e35 100644 --- a/pyomo/contrib/mindtpy/tests/test_mindtpy_solution_pool.py +++ b/pyomo/contrib/mindtpy/tests/test_mindtpy_solution_pool.py @@ -12,7 +12,7 @@ from pyomo.core.expr.calculus.diff_with_sympy import differentiate_available import pyomo.common.unittest as unittest from pyomo.contrib.mindtpy.tests.eight_process_problem import EightProcessFlowsheet -from pyomo.contrib.mindtpy.tests.MINLP2_simple import SimpleMINLP as SimpleMINLP2 +from pyomo.contrib.mindtpy.tests.minlp2_simple import Minlp2Simple from pyomo.contrib.mindtpy.tests.constraint_qualification_example import ( ConstraintQualificationExample, ) @@ -22,7 +22,7 @@ model_list = [ EightProcessFlowsheet(convex=True), ConstraintQualificationExample(), - SimpleMINLP2(), + Minlp2Simple(), ] @@ -47,9 +47,18 @@ not differentiate_available, 'Symbolic differentiation is not available' ) class TestMindtPy(unittest.TestCase): - """Tests for the MindtPy solver plugin.""" + """Tests for the MindtPy solver.""" def check_optimal_solution(self, model, places=1): + """Assert that variable values match the model's known optimum. + + Parameters + ---------- + model : Block + Model containing ``optimal_solution`` values for comparison. + places : int, optional + Decimal places used by ``assertAlmostEqual``. + """ for var in model.optimal_solution: self.assertAlmostEqual( var.value, model.optimal_solution[var], places=places diff --git a/pyomo/contrib/mindtpy/tests/unit_test.py b/pyomo/contrib/mindtpy/tests/unit_test.py index 4543c332b01..60e7c797a38 100644 --- a/pyomo/contrib/mindtpy/tests/unit_test.py +++ b/pyomo/contrib/mindtpy/tests/unit_test.py @@ -7,18 +7,23 @@ # software. This software is distributed under the 3-clause BSD License. # ____________________________________________________________________________________ +"""Unit tests for MindtPy utility helpers.""" + import pyomo.common.unittest as unittest from pyomo.contrib.mindtpy.util import set_var_valid_value -from pyomo.environ import Var, Integers, ConcreteModel, Integers +from pyomo.environ import Var, Integers, ConcreteModel from pyomo.contrib.mindtpy.algorithm_base_class import _MindtPyAlgorithm from pyomo.contrib.mindtpy.config_options import _get_MindtPy_OA_config -from pyomo.contrib.mindtpy.tests.MINLP5_simple import SimpleMINLP5 +from pyomo.contrib.mindtpy.tests.minlp5_simple import Minlp5Simple from pyomo.contrib.mindtpy.util import add_var_bound class UnitTestMindtPy(unittest.TestCase): + """Unit tests for selected MindtPy helper functions.""" + def test_set_var_valid_value(self): + """Verify value coercion and bound handling for integer variables.""" m = ConcreteModel() m.x1 = Var(within=Integers, bounds=(-1, 4), initialize=0) @@ -68,7 +73,8 @@ def test_set_var_valid_value(self): self.assertEqual(m.x1.value, 0) def test_add_var_bound(self): - m = SimpleMINLP5().clone() + """Verify default bounds are added when variable bounds are missing.""" + m = Minlp5Simple().clone() m.x.lb = None m.x.ub = None m.y.lb = None @@ -94,6 +100,38 @@ def test_add_var_bound(self): solver_object.working_model.y.upper, solver_object.config.integer_var_bound ) + def test_legacy_test_model_imports(self): + """Verify legacy MindtPy test-model import paths remain available.""" + from pyomo.contrib.mindtpy.tests.MINLP_simple import SimpleMINLP + from pyomo.contrib.mindtpy.tests.MINLP2_simple import ( + SimpleMINLP as SimpleMINLP2, + ) + from pyomo.contrib.mindtpy.tests.MINLP3_simple import ( + SimpleMINLP as SimpleMINLP3, + ) + from pyomo.contrib.mindtpy.tests.MINLP4_simple import SimpleMINLP4 + from pyomo.contrib.mindtpy.tests.MINLP5_simple import SimpleMINLP5 + from pyomo.contrib.mindtpy.tests.feasibility_pump1 import FeasPump1 + from pyomo.contrib.mindtpy.tests.feasibility_pump2 import FeasPump2 + from pyomo.contrib.mindtpy.tests.from_proposal import ProposalModel + from pyomo.contrib.mindtpy.tests.minlp_simple import MinlpSimple + from pyomo.contrib.mindtpy.tests.minlp2_simple import Minlp2Simple + from pyomo.contrib.mindtpy.tests.minlp3_simple import Minlp3Simple + from pyomo.contrib.mindtpy.tests.minlp4_simple import Minlp4Simple + from pyomo.contrib.mindtpy.tests.minlp5_simple import Minlp5Simple + from pyomo.contrib.mindtpy.tests.feasibility_pump1 import FeasibilityPump1 + from pyomo.contrib.mindtpy.tests.feasibility_pump2 import FeasibilityPump2 + from pyomo.contrib.mindtpy.tests.from_proposal import FromProposalModel + + self.assertIs(SimpleMINLP, MinlpSimple) + self.assertIs(SimpleMINLP2, Minlp2Simple) + self.assertIs(SimpleMINLP3, Minlp3Simple) + self.assertIs(SimpleMINLP4, Minlp4Simple) + self.assertIs(SimpleMINLP5, Minlp5Simple) + self.assertIs(FeasPump1, FeasibilityPump1) + self.assertIs(FeasPump2, FeasibilityPump2) + self.assertIs(ProposalModel, FromProposalModel) + if __name__ == '__main__': unittest.main() diff --git a/pyomo/contrib/mindtpy/util.py b/pyomo/contrib/mindtpy/util.py index 368f261eb50..9886636a63d 100644 --- a/pyomo/contrib/mindtpy/util.py +++ b/pyomo/contrib/mindtpy/util.py @@ -66,8 +66,10 @@ def calc_jacobians(constraint_list, differentiate_mode): def initialize_feas_subproblem(m, feasibility_norm): - """Adds feasibility slack variables according to config.feasibility_norm (given an infeasible problem). - Defines the objective function of the feasibility subproblem. + """Add feasibility slacks and objective for the feasibility subproblem. + + Adds feasibility slack variables according to ``feasibility_norm`` + for an infeasible NLP and defines the feasibility objective. Parameters ---------- @@ -142,10 +144,12 @@ def add_var_bound(model, config): def generate_norm2sq_objective_function(model, setpoint_model, discrete_only=False): - r"""This function generates objective (FP-NLP subproblem) for minimum - euclidean distance to setpoint_model. + r"""Generate an FP-NLP objective for minimum squared Euclidean (L2) distance. + + This function generates an objective for minimum squared L2 distance to + ``setpoint_model``. - L2 distance of :math:`(x,y) = \sqrt{\sum_i (x_i - y_i)^2}`. + Squared Euclidean (L2) distance of :math:`(x,y) = \sum_i (x_i - y_i)^2`. Parameters ---------- @@ -198,7 +202,9 @@ def generate_norm2sq_objective_function(model, setpoint_model, discrete_only=Fal def generate_norm1_objective_function(model, setpoint_model, discrete_only=False): - r"""This function generates objective (PF-OA main problem) for minimum + r"""Generate a PF-OA objective for minimum L1 distance. + + This function generates objective (PF-OA main problem) for minimum Norm1 distance to setpoint_model. Norm1 distance of :math:`(x,y) = \sum_i |x_i - y_i|`. @@ -440,7 +446,9 @@ def generate_lag_objective_function( def generate_norm1_norm_constraint(model, setpoint_model, config, discrete_only=True): - r"""This function generates constraint (PF-OA main problem) for minimum + r"""Generate a monotonicity norm constraint for PF-OA iterations. + + This function generates constraint (PF-OA main problem) for minimum Norm1 distance to setpoint_model. Norm constraint is used to guarantees the monotonicity of the norm @@ -577,6 +585,8 @@ def set_solver_constraint_violation_tolerance( The name of solver. config : ConfigBlock The specific configurations for MindtPy. + warm_start : bool, optional + Whether to emit warm-start options for IPOPT when using the GAMS NLP solver interface. """ if solver_name == 'baron': opt.options['AbsConFeasTol'] = config.zero_tolerance @@ -703,12 +713,17 @@ class GurobiPersistent4MindtPy(GurobiPersistent): """A new persistent interface to Gurobi.""" def _intermediate_callback(self): + """Create and return the intermediate callback wrapper for Gurobi.""" + def f(gurobi_model, where): """Callback function for Gurobi. - Args: - gurobi_model (Gurobi model): the Gurobi model derived from pyomo model. - where (int): an enum member of gurobipy.GRB.Callback. + Parameters + ---------- + gurobi_model : gurobipy.Model + Gurobi model derived from the Pyomo model. + where : int + Callback location code from ``gurobipy.GRB.Callback``. """ self._callback_func( self._pyomo_model, self, where, self.mindtpy_solver, self.config @@ -754,7 +769,17 @@ def epigraph_reformulation(exp, slack_var_list, constraint_list, use_mcpp, sense def setup_results_object(results, model, config): - """Record problem statistics for original model.""" + """Record problem statistics for original model. + + Parameters + ---------- + results : SolverResults + Results container to populate. + model : Block + Original model being solved. + config : ConfigBlock + MindtPy configuration options. + """ # Create the solver results object res = results prob = res.problem @@ -813,7 +838,7 @@ def setup_results_object(results, model, config): def fp_converged(working_model, mip_model, proj_zero_tolerance, discrete_only=True): - """Calculates the euclidean norm between the discrete variables in the MIP and NLP models. + """Check whether FP projection distance is below the convergence tolerance. Parameters ---------- @@ -828,8 +853,9 @@ def fp_converged(working_model, mip_model, proj_zero_tolerance, discrete_only=Tr Returns ------- - distance : float - The euclidean norm between the discrete variables in the MIP and NLP models. + bool + ``True`` if the maximum squared variable difference (optionally over + discrete variables only) is within ``proj_zero_tolerance``. """ distance = max( (nlp_var.value - milp_var.value) ** 2 @@ -845,7 +871,9 @@ def fp_converged(working_model, mip_model, proj_zero_tolerance, discrete_only=Tr def add_orthogonality_cuts(working_model, mip_model, config): """Add orthogonality cuts. - This function adds orthogonality cuts to avoid cycling when the independence constraint qualification is not satisfied. + This helper lazily creates the FP orthogonality cut container on the + working model and MIP model, then adds cuts to avoid cycling when the + independence constraint qualification is not satisfied. Parameters ---------- @@ -856,6 +884,14 @@ def add_orthogonality_cuts(working_model, mip_model, config): config : ConfigBlock The specific configurations for MindtPy. """ + if not hasattr(mip_model.MindtPy_utils.cuts, 'fp_orthogonality_cuts'): + mip_model.MindtPy_utils.cuts.fp_orthogonality_cuts = ConstraintList( + doc='FP orthogonality cuts' + ) + if not hasattr(working_model.MindtPy_utils.cuts, 'fp_orthogonality_cuts'): + working_model.MindtPy_utils.cuts.fp_orthogonality_cuts = ConstraintList( + doc='FP orthogonality cuts' + ) mip_integer_vars = mip_model.MindtPy_utils.discrete_variable_list nlp_integer_vars = working_model.MindtPy_utils.discrete_variable_list orthogonality_cut = ( @@ -885,7 +921,7 @@ def generate_norm_constraint(fp_nlp_model, mip_model, config): fp_nlp_model : Pyomo model The feasibility pump NLP subproblem. mip_model : Pyomo model - The mip_model model. + The FP main MIP model providing the reference point. config : ConfigBlock The specific configurations for MindtPy. """ @@ -931,9 +967,12 @@ def copy_var_list_values( ignore_integrality=False, ): """Copy variable values from one list to another. + Rounds to Binary/Integer if necessary Sets to zero for NonNegativeReals if necessary + Parameters + ---------- from_list : list The variables that provide the values to copy from. to_list : list @@ -965,7 +1004,9 @@ def copy_var_list_values( def set_var_valid_value( var, var_val, integer_tolerance, zero_tolerance, ignore_integrality ): - """This function tries to set a valid value for variable with the given input. + """Set a valid variable value while respecting domain and tolerances. + + This function tries to set a valid value for variable with the given input. Rounds to Binary/Integer if necessary. Sets to zero for NonNegativeReals if necessary.