From 685a2648cef269bda05493c284425c1cf1f3e66a Mon Sep 17 00:00:00 2001 From: "David E. Bernal Neira" Date: Mon, 6 Apr 2026 18:35:28 -0400 Subject: [PATCH 1/6] Refine MindtPy short-circuit QP/QCP routing --- pyomo/contrib/mindtpy/algorithm_base_class.py | 153 +++++++++++--- .../mindtpy/tests/test_mindtpy_no_discrete.py | 195 +++++++++++++++++- 2 files changed, 319 insertions(+), 29 deletions(-) diff --git a/pyomo/contrib/mindtpy/algorithm_base_class.py b/pyomo/contrib/mindtpy/algorithm_base_class.py index cb1c34e3054..84585a231ca 100644 --- a/pyomo/contrib/mindtpy/algorithm_base_class.py +++ b/pyomo/contrib/mindtpy/algorithm_base_class.py @@ -296,20 +296,39 @@ def model_is_valid(self): if len(MindtPy.discrete_variable_list) == 0: config.logger.info('Problem has no discrete decisions.') - obj = next(m.component_data_objects(ctype=Objective, active=True)) - if ( - any( - c.body.polynomial_degree() - not in self.mip_constraint_polynomial_degree - for c in MindtPy.constraint_list - ) - or obj.expr.polynomial_degree() - not in self.mip_objective_polynomial_degree - ): - config.logger.info( - 'Your model is a NLP (nonlinear program). ' - 'Using NLP solver %s to solve.' % config.nlp_solver - ) + working_obj = next(m.component_data_objects(ctype=Objective, active=True)) + original_obj = next( + self.original_model.component_data_objects(ctype=Objective, active=True) + ) + obj_degree = getattr( + MindtPy, + 'objective_polynomial_degree', + working_obj.expr.polynomial_degree(), + ) + problem_type, solver_to_use, unsupported_structure = ( + self._classify_short_circuit_problem(MindtPy, obj_degree) + ) + if solver_to_use == 'nlp': + if problem_type == 'NLP': + config.logger.info( + 'Your model is a NLP (nonlinear program). ' + 'Using NLP solver %s to solve.' % config.nlp_solver + ) + else: + problem_type_names = { + 'QP': 'QP (quadratic program)', + 'QCP': 'QCP (quadratically constrained program)', + } + config.logger.info( + 'Your model is a %s, but MIP solver %s does not support %s. ' + 'Using NLP solver %s to solve.' + % ( + problem_type_names[problem_type], + config.mip_solver, + unsupported_structure, + config.nlp_solver, + ) + ) update_solver_timelimit( self.nlp_opt, config.nlp_solver, self.timing, config ) @@ -322,12 +341,19 @@ def model_is_valid(self): if len(results.solution) > 0: self.original_model.solutions.load_from(results) - self._mirror_direct_solve_results(results=results, obj=obj, prob=prob) + self._mirror_direct_solve_results( + results=results, obj=original_obj, prob=prob + ) return False else: + problem_type_names = { + 'LP': 'LP (linear program)', + 'QP': 'QP (quadratic program)', + 'QCP': 'QCP (quadratically constrained program)', + } config.logger.info( - 'Your model is an LP (linear program). ' - 'Using LP solver %s to solve.' % config.mip_solver + 'Your model is a %s. Using MIP solver %s to solve.' + % (problem_type_names[problem_type], config.mip_solver) ) if isinstance(self.mip_opt, PersistentSolver): self.mip_opt.set_instance(self.original_model) @@ -343,7 +369,9 @@ def model_is_valid(self): if len(results.solution) > 0: self.original_model.solutions.load_from(results) - self._mirror_direct_solve_results(results=results, obj=obj, prob=prob) + self._mirror_direct_solve_results( + results=results, obj=original_obj, prob=prob + ) return False # Set up dual value reporting @@ -359,6 +387,65 @@ def model_is_valid(self): # need to do some kind of transformation (Glover?) or throw an error message return True + def _classify_short_circuit_problem(self, MindtPy, obj_degree): + """Classify a no-discrete model for short-circuit direct solves. + + This classification is intentionally independent of + ``quadratic_strategy``. In the no-discrete short-circuit path we are + selecting a direct subsolver for the original model, not building the + MindtPy main problem. + + Returns + ------- + tuple + ``(problem_type, solver_to_use, unsupported_structure)`` where + ``problem_type`` is one of ``LP``, ``QP``, ``QCP``, or ``NLP``, + ``solver_to_use`` is ``mip`` or ``nlp``, and + ``unsupported_structure`` is ``None`` unless a quadratic problem + must be routed to the NLP solver because the chosen MIP solver does + not support the required structure. + """ + has_quadratic_constraints = len(MindtPy.quadratic_constraint_list) > 0 + has_nonquadratic_constraints = len(MindtPy.nonquadratic_constraint_list) > 0 + + if has_nonquadratic_constraints or obj_degree not in (0, 1, 2): + return 'NLP', 'nlp', None + + if has_quadratic_constraints: + if not self._mip_solver_supports_capability('quadratic_constraint'): + return 'QCP', 'nlp', 'quadratic constraints' + if obj_degree == 2 and not self._mip_solver_supports_capability( + 'quadratic_objective' + ): + return 'QCP', 'nlp', 'quadratic objectives' + return 'QCP', 'mip', None + + if obj_degree == 2: + if not self._mip_solver_supports_capability('quadratic_objective'): + return 'QP', 'nlp', 'quadratic objectives' + return 'QP', 'mip', None + + return 'LP', 'mip', None + + def _mip_solver_supports_capability(self, capability): + """Return whether the selected MIP solver supports a model feature.""" + appsi_capabilities = { + 'appsi_cplex': {'quadratic_objective': True, 'quadratic_constraint': True}, + 'appsi_gurobi': {'quadratic_objective': True, 'quadratic_constraint': True}, + 'appsi_highs': { + 'quadratic_objective': False, + 'quadratic_constraint': False, + }, + } + solver_name = self.config.mip_solver + if solver_name in appsi_capabilities: + return appsi_capabilities[solver_name][capability] + + has_capability = getattr(self.mip_opt, 'has_capability', None) + if has_capability is None: + return False + return has_capability(capability) + def _mirror_direct_solve_results(self, results, obj, prob): """Mirror a direct (LP/NLP) solve result into MindtPy's results object. @@ -431,21 +518,31 @@ def build_ordered_component_lists(self, model): ) else: util_block.grey_box_list = [] - util_block.linear_constraint_list = list( - c - for c in util_block.constraint_list - if c.body.polynomial_degree() in self.mip_constraint_polynomial_degree - ) - util_block.nonlinear_constraint_list = list( - c - for c in util_block.constraint_list - if c.body.polynomial_degree() not in self.mip_constraint_polynomial_degree - ) + util_block.linear_constraint_list = [] + util_block.nonlinear_constraint_list = [] + util_block.quadratic_constraint_list = [] + util_block.nonquadratic_constraint_list = [] + for c in util_block.constraint_list: + degree = c.body.polynomial_degree() + if degree in self.mip_constraint_polynomial_degree: + util_block.linear_constraint_list.append(c) + else: + util_block.nonlinear_constraint_list.append(c) + + if degree == 2: + util_block.quadratic_constraint_list.append(c) + elif degree not in (0, 1): + util_block.nonquadratic_constraint_list.append(c) util_block.objective_list = list( model.component_data_objects( ctype=Objective, active=True, descend_into=(Block) ) ) + util_block.objective_polynomial_degree = None + if len(util_block.objective_list) == 1: + util_block.objective_polynomial_degree = util_block.objective_list[ + 0 + ].expr.polynomial_degree() # Identify the non-fixed variables in (potentially) active constraints and # objective functions diff --git a/pyomo/contrib/mindtpy/tests/test_mindtpy_no_discrete.py b/pyomo/contrib/mindtpy/tests/test_mindtpy_no_discrete.py index a9219ceb8b5..1b6787da8c6 100644 --- a/pyomo/contrib/mindtpy/tests/test_mindtpy_no_discrete.py +++ b/pyomo/contrib/mindtpy/tests/test_mindtpy_no_discrete.py @@ -8,7 +8,7 @@ # ____________________________________________________________________________________ -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch from pyomo.opt import TerminationCondition as tc, SolverStatus import pyomo.common.unittest as unittest @@ -405,3 +405,196 @@ def test_solver_status_and_message_mirrored(self): self.assertEqual(algo.results.solver.status, SolverStatus.ok) self.assertEqual(algo.results.solver.termination_condition, tc.optimal) self.assertEqual(algo.results.solver.message, "All good") + + +class _FakeLegacyMIPSolver: + def __init__( + self, + *, + quadratic_objective=False, + quadratic_constraint=False, + termination_condition=tc.optimal, + ): + self.options = {} + self._capabilities = { + 'quadratic_objective': quadratic_objective, + 'quadratic_constraint': quadratic_constraint, + } + self.solve = MagicMock( + return_value=_make_direct_solve_results(termination_condition) + ) + + def has_capability(self, capability): + return self._capabilities[capability] + + +class _FakeNLPSolver: + def __init__(self, termination_condition=tc.optimal): + self.options = {} + self.solve = MagicMock( + return_value=_make_direct_solve_results(termination_condition) + ) + + +def _make_direct_solve_results(termination_condition): + return _SimpleNamespace( + solution=[], + solver=_SimpleNamespace( + termination_condition=termination_condition, + status=SolverStatus.ok, + message=None, + ), + problem=_SimpleNamespace(lower_bound=None, upper_bound=None), + ) + + +class TestMindtPyShortCircuitRouting(unittest.TestCase): + def _make_algorithm(self, model, mip_solver_name, mip_opt, nlp_opt=None): + from pyomo.contrib.mindtpy.algorithm_base_class import _MindtPyAlgorithm + + algo = _MindtPyAlgorithm() + algo.config = _SimpleNamespace( + logger=MagicMock(), + mip_solver=mip_solver_name, + nlp_solver='ipopt', + mip_solver_tee=False, + nlp_solver_tee=False, + mip_solver_args={}, + nlp_solver_args={}, + feasibility_norm='L1', + add_slack=False, + max_slack=0, + ) + algo.mip_constraint_polynomial_degree = {0, 1} + algo.mip_objective_polynomial_degree = {0, 1} + algo.original_model = model + algo.working_model = model.clone() + algo.create_utility_block(algo.working_model, 'MindtPy_utils') + algo.mip_opt = mip_opt + algo.nlp_opt = nlp_opt if nlp_opt is not None else _FakeNLPSolver() + algo.mip_load_solutions = False + algo.nlp_load_solutions = False + algo._mirror_direct_solve_results = MagicMock() + algo.timing = _SimpleNamespace(main_timer_start_time=0.0) + return algo + + def _make_qp_model(self): + m = ConcreteModel() + m.x = Var(bounds=(0, None)) + m.c = Constraint(expr=m.x >= 1) + m.obj = Objective(expr=(m.x - 2) ** 2, sense=minimize) + return m + + def _make_qcp_model(self): + m = ConcreteModel() + m.x = Var(bounds=(0, 10)) + m.c = Constraint(expr=m.x**2 <= 4) + m.obj = Objective(expr=m.x, sense=maximize) + return m + + def _make_nlp_model(self): + m = ConcreteModel() + m.x = Var(bounds=(0, 10)) + m.c = Constraint(expr=m.x >= 1) + m.obj = Objective(expr=m.x**3, sense=minimize) + return m + + def test_short_circuit_qp_uses_legacy_mip_solver_when_supported(self): + algo = self._make_algorithm( + self._make_qp_model(), + mip_solver_name='gurobi', + mip_opt=_FakeLegacyMIPSolver(quadratic_objective=True), + ) + + with patch( + 'pyomo.contrib.mindtpy.algorithm_base_class.update_solver_timelimit' + ): + self.assertFalse(algo.model_is_valid()) + + algo.mip_opt.solve.assert_called_once() + algo.nlp_opt.solve.assert_not_called() + self.assertIs( + algo._mirror_direct_solve_results.call_args.kwargs['obj'], + algo.original_model.obj, + ) + + def test_short_circuit_qp_uses_nlp_when_appsi_highs_lacks_quadratic_support(self): + algo = self._make_algorithm( + self._make_qp_model(), + mip_solver_name='appsi_highs', + mip_opt=_FakeNLPSolver(), + ) + + with patch( + 'pyomo.contrib.mindtpy.algorithm_base_class.update_solver_timelimit' + ): + self.assertFalse(algo.model_is_valid()) + + algo.mip_opt.solve.assert_not_called() + algo.nlp_opt.solve.assert_called_once() + + def test_short_circuit_qcp_uses_appsi_cplex_when_supported(self): + algo = self._make_algorithm( + self._make_qcp_model(), + mip_solver_name='appsi_cplex', + mip_opt=_FakeNLPSolver(), + ) + + with patch( + 'pyomo.contrib.mindtpy.algorithm_base_class.update_solver_timelimit' + ): + self.assertFalse(algo.model_is_valid()) + + algo.mip_opt.solve.assert_called_once() + algo.nlp_opt.solve.assert_not_called() + + def test_short_circuit_qcp_uses_nlp_when_legacy_mip_solver_lacks_qcp_support(self): + algo = self._make_algorithm( + self._make_qcp_model(), + mip_solver_name='cbc', + mip_opt=_FakeLegacyMIPSolver( + quadratic_objective=False, quadratic_constraint=False + ), + ) + + with patch( + 'pyomo.contrib.mindtpy.algorithm_base_class.update_solver_timelimit' + ): + self.assertFalse(algo.model_is_valid()) + + algo.mip_opt.solve.assert_not_called() + algo.nlp_opt.solve.assert_called_once() + + def test_short_circuit_nlp_uses_nlp_even_with_quadratic_capable_mip_solver(self): + algo = self._make_algorithm( + self._make_nlp_model(), + mip_solver_name='gurobi', + mip_opt=_FakeLegacyMIPSolver( + quadratic_objective=True, quadratic_constraint=True + ), + ) + + with patch( + 'pyomo.contrib.mindtpy.algorithm_base_class.update_solver_timelimit' + ): + self.assertFalse(algo.model_is_valid()) + + algo.mip_opt.solve.assert_not_called() + algo.nlp_opt.solve.assert_called_once() + + def test_short_circuit_mip_failure_does_not_fallback_to_nlp(self): + algo = self._make_algorithm( + self._make_qp_model(), + mip_solver_name='gurobi', + mip_opt=_FakeLegacyMIPSolver( + quadratic_objective=True, termination_condition=tc.error + ), + ) + + with patch( + 'pyomo.contrib.mindtpy.algorithm_base_class.update_solver_timelimit' + ): + self.assertFalse(algo.model_is_valid()) + + algo.mip_opt.solve.assert_called_once() + algo.nlp_opt.solve.assert_not_called() From a014410c89c2cfd201d3d1c9587f1b55d550b935 Mon Sep 17 00:00:00 2001 From: "David E. Bernal Neira" Date: Mon, 6 Apr 2026 23:05:49 -0400 Subject: [PATCH 2/6] Address review feedback on short-circuit routing --- pyomo/contrib/mindtpy/algorithm_base_class.py | 36 +++--- .../mindtpy/tests/test_mindtpy_no_discrete.py | 105 ++++++++++++++++-- 2 files changed, 110 insertions(+), 31 deletions(-) diff --git a/pyomo/contrib/mindtpy/algorithm_base_class.py b/pyomo/contrib/mindtpy/algorithm_base_class.py index 84585a231ca..aff54c6e60b 100644 --- a/pyomo/contrib/mindtpy/algorithm_base_class.py +++ b/pyomo/contrib/mindtpy/algorithm_base_class.py @@ -300,11 +300,15 @@ def model_is_valid(self): original_obj = next( self.original_model.component_data_objects(ctype=Objective, active=True) ) - obj_degree = getattr( - MindtPy, - 'objective_polynomial_degree', - working_obj.expr.polynomial_degree(), - ) + problem_type_names = { + 'LP': 'LP (linear program)', + 'QP': 'QP (quadratic program)', + 'QCP': 'QCP (quadratically constrained program)', + 'NLP': 'NLP (nonlinear program)', + } + obj_degree = MindtPy.objective_polynomial_degree + if obj_degree is None: + obj_degree = working_obj.expr.polynomial_degree() problem_type, solver_to_use, unsupported_structure = ( self._classify_short_circuit_problem(MindtPy, obj_degree) ) @@ -315,10 +319,6 @@ def model_is_valid(self): 'Using NLP solver %s to solve.' % config.nlp_solver ) else: - problem_type_names = { - 'QP': 'QP (quadratic program)', - 'QCP': 'QCP (quadratically constrained program)', - } config.logger.info( 'Your model is a %s, but MIP solver %s does not support %s. ' 'Using NLP solver %s to solve.' @@ -346,11 +346,6 @@ def model_is_valid(self): ) return False else: - problem_type_names = { - 'LP': 'LP (linear program)', - 'QP': 'QP (quadratic program)', - 'QCP': 'QCP (quadratically constrained program)', - } config.logger.info( 'Your model is a %s. Using MIP solver %s to solve.' % (problem_type_names[problem_type], config.mip_solver) @@ -405,8 +400,8 @@ def _classify_short_circuit_problem(self, MindtPy, obj_degree): must be routed to the NLP solver because the chosen MIP solver does not support the required structure. """ - has_quadratic_constraints = len(MindtPy.quadratic_constraint_list) > 0 - has_nonquadratic_constraints = len(MindtPy.nonquadratic_constraint_list) > 0 + has_quadratic_constraints = MindtPy.has_quadratic_constraints + has_nonquadratic_constraints = MindtPy.has_nonquadratic_constraints if has_nonquadratic_constraints or obj_degree not in (0, 1, 2): return 'NLP', 'nlp', None @@ -433,6 +428,7 @@ def _mip_solver_supports_capability(self, capability): 'appsi_cplex': {'quadratic_objective': True, 'quadratic_constraint': True}, 'appsi_gurobi': {'quadratic_objective': True, 'quadratic_constraint': True}, 'appsi_highs': { + # TODO: revisit if/when the APPSI HiGHS wrapper adds QP support. 'quadratic_objective': False, 'quadratic_constraint': False, }, @@ -520,8 +516,8 @@ def build_ordered_component_lists(self, model): util_block.grey_box_list = [] util_block.linear_constraint_list = [] util_block.nonlinear_constraint_list = [] - util_block.quadratic_constraint_list = [] - util_block.nonquadratic_constraint_list = [] + util_block.has_quadratic_constraints = False + util_block.has_nonquadratic_constraints = False for c in util_block.constraint_list: degree = c.body.polynomial_degree() if degree in self.mip_constraint_polynomial_degree: @@ -530,9 +526,9 @@ def build_ordered_component_lists(self, model): util_block.nonlinear_constraint_list.append(c) if degree == 2: - util_block.quadratic_constraint_list.append(c) + util_block.has_quadratic_constraints = True elif degree not in (0, 1): - util_block.nonquadratic_constraint_list.append(c) + util_block.has_nonquadratic_constraints = True util_block.objective_list = list( model.component_data_objects( ctype=Objective, active=True, descend_into=(Block) diff --git a/pyomo/contrib/mindtpy/tests/test_mindtpy_no_discrete.py b/pyomo/contrib/mindtpy/tests/test_mindtpy_no_discrete.py index 1b6787da8c6..4bb9eaafbd0 100644 --- a/pyomo/contrib/mindtpy/tests/test_mindtpy_no_discrete.py +++ b/pyomo/contrib/mindtpy/tests/test_mindtpy_no_discrete.py @@ -428,7 +428,7 @@ def has_capability(self, capability): return self._capabilities[capability] -class _FakeNLPSolver: +class _FakeSolver: def __init__(self, termination_condition=tc.optimal): self.options = {} self.solve = MagicMock( @@ -449,7 +449,15 @@ def _make_direct_solve_results(termination_condition): class TestMindtPyShortCircuitRouting(unittest.TestCase): - def _make_algorithm(self, model, mip_solver_name, mip_opt, nlp_opt=None): + def _make_algorithm( + self, + model, + mip_solver_name, + mip_opt, + nlp_opt=None, + mip_constraint_polynomial_degree=None, + mip_objective_polynomial_degree=None, + ): from pyomo.contrib.mindtpy.algorithm_base_class import _MindtPyAlgorithm algo = _MindtPyAlgorithm() @@ -465,19 +473,34 @@ def _make_algorithm(self, model, mip_solver_name, mip_opt, nlp_opt=None): add_slack=False, max_slack=0, ) - algo.mip_constraint_polynomial_degree = {0, 1} - algo.mip_objective_polynomial_degree = {0, 1} + algo.mip_constraint_polynomial_degree = ( + {0, 1} + if mip_constraint_polynomial_degree is None + else mip_constraint_polynomial_degree + ) + algo.mip_objective_polynomial_degree = ( + {0, 1} + if mip_objective_polynomial_degree is None + else mip_objective_polynomial_degree + ) algo.original_model = model algo.working_model = model.clone() algo.create_utility_block(algo.working_model, 'MindtPy_utils') algo.mip_opt = mip_opt - algo.nlp_opt = nlp_opt if nlp_opt is not None else _FakeNLPSolver() + algo.nlp_opt = nlp_opt if nlp_opt is not None else _FakeSolver() algo.mip_load_solutions = False algo.nlp_load_solutions = False algo._mirror_direct_solve_results = MagicMock() algo.timing = _SimpleNamespace(main_timer_start_time=0.0) return algo + def _make_lp_model(self): + m = ConcreteModel() + m.x = Var(bounds=(0, None)) + m.c = Constraint(expr=m.x >= 1) + m.obj = Objective(expr=m.x, sense=minimize) + return m + def _make_qp_model(self): m = ConcreteModel() m.x = Var(bounds=(0, None)) @@ -492,6 +515,13 @@ def _make_qcp_model(self): m.obj = Objective(expr=m.x, sense=maximize) return m + def _make_qcp_with_quadratic_objective_model(self): + m = ConcreteModel() + m.x = Var(bounds=(0, 10)) + m.c = Constraint(expr=m.x**2 <= 9) + m.obj = Objective(expr=(m.x - 1) ** 2, sense=minimize) + return m + def _make_nlp_model(self): m = ConcreteModel() m.x = Var(bounds=(0, 10)) @@ -499,6 +529,21 @@ def _make_nlp_model(self): m.obj = Objective(expr=m.x**3, sense=minimize) return m + def test_short_circuit_lp_routes_to_mip(self): + algo = self._make_algorithm( + self._make_lp_model(), + mip_solver_name='glpk', + mip_opt=_FakeLegacyMIPSolver(), + ) + + with patch( + 'pyomo.contrib.mindtpy.algorithm_base_class.update_solver_timelimit' + ): + self.assertFalse(algo.model_is_valid()) + + algo.mip_opt.solve.assert_called_once() + algo.nlp_opt.solve.assert_not_called() + def test_short_circuit_qp_uses_legacy_mip_solver_when_supported(self): algo = self._make_algorithm( self._make_qp_model(), @@ -520,9 +565,7 @@ def test_short_circuit_qp_uses_legacy_mip_solver_when_supported(self): def test_short_circuit_qp_uses_nlp_when_appsi_highs_lacks_quadratic_support(self): algo = self._make_algorithm( - self._make_qp_model(), - mip_solver_name='appsi_highs', - mip_opt=_FakeNLPSolver(), + self._make_qp_model(), mip_solver_name='appsi_highs', mip_opt=_FakeSolver() ) with patch( @@ -535,9 +578,7 @@ def test_short_circuit_qp_uses_nlp_when_appsi_highs_lacks_quadratic_support(self def test_short_circuit_qcp_uses_appsi_cplex_when_supported(self): algo = self._make_algorithm( - self._make_qcp_model(), - mip_solver_name='appsi_cplex', - mip_opt=_FakeNLPSolver(), + self._make_qcp_model(), mip_solver_name='appsi_cplex', mip_opt=_FakeSolver() ) with patch( @@ -565,6 +606,23 @@ def test_short_circuit_qcp_uses_nlp_when_legacy_mip_solver_lacks_qcp_support(sel algo.mip_opt.solve.assert_not_called() algo.nlp_opt.solve.assert_called_once() + def test_short_circuit_qcp_uses_nlp_when_solver_lacks_quadratic_objective(self): + algo = self._make_algorithm( + self._make_qcp_with_quadratic_objective_model(), + mip_solver_name='cbc', + mip_opt=_FakeLegacyMIPSolver( + quadratic_objective=False, quadratic_constraint=True + ), + ) + + with patch( + 'pyomo.contrib.mindtpy.algorithm_base_class.update_solver_timelimit' + ): + self.assertFalse(algo.model_is_valid()) + + algo.mip_opt.solve.assert_not_called() + algo.nlp_opt.solve.assert_called_once() + def test_short_circuit_nlp_uses_nlp_even_with_quadratic_capable_mip_solver(self): algo = self._make_algorithm( self._make_nlp_model(), @@ -598,3 +656,28 @@ def test_short_circuit_mip_failure_does_not_fallback_to_nlp(self): algo.mip_opt.solve.assert_called_once() algo.nlp_opt.solve.assert_not_called() + + def test_short_circuit_qcp_detection_ignores_quadratic_strategy(self): + algo = self._make_algorithm( + self._make_qcp_model(), + mip_solver_name='cbc', + mip_opt=_FakeLegacyMIPSolver( + quadratic_objective=False, quadratic_constraint=False + ), + mip_constraint_polynomial_degree={0, 1, 2}, + mip_objective_polynomial_degree={0, 1, 2}, + ) + + util = algo.working_model.MindtPy_utils + self.assertEqual(len(util.linear_constraint_list), 1) + self.assertEqual(len(util.nonlinear_constraint_list), 0) + self.assertTrue(util.has_quadratic_constraints) + self.assertFalse(util.has_nonquadratic_constraints) + + with patch( + 'pyomo.contrib.mindtpy.algorithm_base_class.update_solver_timelimit' + ): + self.assertFalse(algo.model_is_valid()) + + algo.mip_opt.solve.assert_not_called() + algo.nlp_opt.solve.assert_called_once() From da366fbd7065ccd4445ac5c5b0325dd701be7fec Mon Sep 17 00:00:00 2001 From: "David E. Bernal Neira" Date: Mon, 6 Apr 2026 23:32:43 -0400 Subject: [PATCH 3/6] Polish MindtPy short-circuit logging docs --- pyomo/contrib/mindtpy/algorithm_base_class.py | 53 ++++++++++++------- 1 file changed, 33 insertions(+), 20 deletions(-) diff --git a/pyomo/contrib/mindtpy/algorithm_base_class.py b/pyomo/contrib/mindtpy/algorithm_base_class.py index aff54c6e60b..9573ec31838 100644 --- a/pyomo/contrib/mindtpy/algorithm_base_class.py +++ b/pyomo/contrib/mindtpy/algorithm_base_class.py @@ -252,7 +252,8 @@ def model_is_valid(self): This method performs a structural check on the working model. It determines if the problem is a true Mixed-Integer program. If no discrete variables are present, it serves as a short-circuit. - In short-circuit cases, the problem is solved immediately as an LP or NLP. + In short-circuit cases, the problem is solved immediately with the + configured MIP or NLP subsolver. Returns ------- @@ -270,19 +271,20 @@ def model_is_valid(self): This indicates the model is a valid MINLP for decomposition. 2. Continuous Model Handling (The "False" cases) - If the discrete variable list is empty, the model is "invalid" for MINLP. - The method then differentiates between LP and NLP structures. + If the discrete variable list is empty, the model is "invalid" for + MINLP. The method then classifies the continuous model as LP, QP, QCP, + or NLP and routes it directly to the configured MIP or NLP subsolver. 3. NLP Branch - The code checks the ``polynomial_degree`` of constraints and objectives. - If any degree is non-linear (not in ``mip_constraint_polynomial_degree``), - it is treated as a standard Nonlinear Program (NLP). - The ``config.nlp_solver`` is called to solve the original model directly. + If the model is structurally nonlinear beyond QP/QCP, or if the chosen + MIP solver cannot handle the required quadratic structure, the + ``config.nlp_solver`` is called to solve the original model directly. - 4. LP Branch - If all components are linear, it is treated as a Linear Program (LP). - The ``config.mip_solver`` is utilized for the solution process. - Solutions are loaded directly back into the ``original_model``. + 4. MIP Branch + If the continuous model is LP, QP, or QCP and the chosen MIP solver + supports the required structure, ``config.mip_solver`` is used for the + direct solve. Solutions are loaded directly back into the + ``original_model``. In both continuous cases, the method returns False to bypass the main loop. This ensures MindtPy does not attempt decomposition on trivial continuous models. @@ -291,7 +293,7 @@ def model_is_valid(self): MindtPy = m.MindtPy_utils config = self.config - # Handle LP/NLP being passed to the solver + # Handle purely continuous models by short-circuiting to a direct solve prob = self.results.problem if len(MindtPy.discrete_variable_list) == 0: config.logger.info('Problem has no discrete decisions.') @@ -306,6 +308,7 @@ def model_is_valid(self): 'QCP': 'QCP (quadratically constrained program)', 'NLP': 'NLP (nonlinear program)', } + problem_type_articles = {'LP': 'an', 'QP': 'a', 'QCP': 'a', 'NLP': 'an'} obj_degree = MindtPy.objective_polynomial_degree if obj_degree is None: obj_degree = working_obj.expr.polynomial_degree() @@ -315,14 +318,20 @@ def model_is_valid(self): if solver_to_use == 'nlp': if problem_type == 'NLP': config.logger.info( - 'Your model is a NLP (nonlinear program). ' - 'Using NLP solver %s to solve.' % config.nlp_solver + 'Your model is %s %s. ' + 'Using NLP solver %s to solve.' + % ( + problem_type_articles[problem_type], + problem_type_names[problem_type], + config.nlp_solver, + ) ) else: config.logger.info( - 'Your model is a %s, but MIP solver %s does not support %s. ' + 'Your model is %s %s, but MIP solver %s does not support %s. ' 'Using NLP solver %s to solve.' % ( + problem_type_articles[problem_type], problem_type_names[problem_type], config.mip_solver, unsupported_structure, @@ -347,8 +356,12 @@ def model_is_valid(self): return False else: config.logger.info( - 'Your model is a %s. Using MIP solver %s to solve.' - % (problem_type_names[problem_type], config.mip_solver) + 'Your model is %s %s. Using MIP solver %s to solve.' + % ( + problem_type_articles[problem_type], + problem_type_names[problem_type], + config.mip_solver, + ) ) if isinstance(self.mip_opt, PersistentSolver): self.mip_opt.set_instance(self.original_model) @@ -443,11 +456,11 @@ def _mip_solver_supports_capability(self, capability): return has_capability(capability) def _mirror_direct_solve_results(self, results, obj, prob): - """Mirror a direct (LP/NLP) solve result into MindtPy's results object. + """Mirror a direct short-circuit solve result into MindtPy results. This is used by `model_is_valid()` when the instance is purely continuous - (no discrete variables) and MindtPy short-circuits to a direct LP/NLP - solve. + (no discrete variables) and MindtPy short-circuits to a direct MIP or + NLP solve for an LP, QP, QCP, or NLP model. Parameters ---------- From 3610f25270fc14cec1beedb37b3bc67fe1b81965 Mon Sep 17 00:00:00 2001 From: "David E. Bernal Neira" Date: Tue, 7 Apr 2026 00:38:57 -0400 Subject: [PATCH 4/6] Address review feedback on short-circuit routing - Fix stale Returns docstring in model_is_valid: LP, QP, QCP, or NLP - Defer working_obj fetch into the obj_degree is None fallback branch - Add Parameters section to _classify_short_circuit_problem docstring - Add comment in _mip_solver_supports_capability explaining that unknown APPSI solvers fall through conservatively to return False - Add test_short_circuit_mixed_degree_model_routes_to_nlp to cover the case where both has_quadratic_constraints and has_nonquadratic_constraints are True (model with quadratic and cubic constraints must route to NLP) Co-Authored-By: Claude Sonnet 4.6 --- pyomo/contrib/mindtpy/algorithm_base_class.py | 22 ++++++++++- .../mindtpy/tests/test_mindtpy_no_discrete.py | 37 +++++++++++++++++++ 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/pyomo/contrib/mindtpy/algorithm_base_class.py b/pyomo/contrib/mindtpy/algorithm_base_class.py index 9573ec31838..6aac9a0f19d 100644 --- a/pyomo/contrib/mindtpy/algorithm_base_class.py +++ b/pyomo/contrib/mindtpy/algorithm_base_class.py @@ -259,7 +259,7 @@ def model_is_valid(self): ------- bool True if the model has discrete variables and requires MindtPy iteration. - False if the model is purely continuous (LP or NLP). + False if the model is purely continuous (LP, QP, QCP, or NLP). Notes ----- @@ -298,7 +298,6 @@ def model_is_valid(self): if len(MindtPy.discrete_variable_list) == 0: config.logger.info('Problem has no discrete decisions.') - working_obj = next(m.component_data_objects(ctype=Objective, active=True)) original_obj = next( self.original_model.component_data_objects(ctype=Objective, active=True) ) @@ -311,6 +310,9 @@ def model_is_valid(self): problem_type_articles = {'LP': 'an', 'QP': 'a', 'QCP': 'a', 'NLP': 'an'} obj_degree = MindtPy.objective_polynomial_degree if obj_degree is None: + working_obj = next( + m.component_data_objects(ctype=Objective, active=True) + ) obj_degree = working_obj.expr.polynomial_degree() problem_type, solver_to_use, unsupported_structure = ( self._classify_short_circuit_problem(MindtPy, obj_degree) @@ -403,6 +405,17 @@ def _classify_short_circuit_problem(self, MindtPy, obj_degree): selecting a direct subsolver for the original model, not building the MindtPy main problem. + Parameters + ---------- + MindtPy : Block + The utility block attached to the working model + (``model.MindtPy_utils``). Must have ``has_quadratic_constraints`` + and ``has_nonquadratic_constraints`` boolean attributes set by + ``build_ordered_component_lists``. + obj_degree : int or None + Polynomial degree of the active objective expression, or ``None`` + for nonlinear (non-polynomial) objectives. + Returns ------- tuple @@ -450,6 +463,11 @@ def _mip_solver_supports_capability(self, capability): if solver_name in appsi_capabilities: return appsi_capabilities[solver_name][capability] + # APPSI solvers do not expose has_capability (that is a legacy-interface + # method). Any APPSI solver not in the dict above therefore reaches this + # branch and is treated conservatively: all quadratic features are assumed + # unsupported, so QP/QCP models are routed to the NLP solver. To enable + # MIP-path routing for a new APPSI solver, add it to appsi_capabilities. has_capability = getattr(self.mip_opt, 'has_capability', None) if has_capability is None: return False diff --git a/pyomo/contrib/mindtpy/tests/test_mindtpy_no_discrete.py b/pyomo/contrib/mindtpy/tests/test_mindtpy_no_discrete.py index 4bb9eaafbd0..fed46e33122 100644 --- a/pyomo/contrib/mindtpy/tests/test_mindtpy_no_discrete.py +++ b/pyomo/contrib/mindtpy/tests/test_mindtpy_no_discrete.py @@ -681,3 +681,40 @@ def test_short_circuit_qcp_detection_ignores_quadratic_strategy(self): algo.mip_opt.solve.assert_not_called() algo.nlp_opt.solve.assert_called_once() + + def _make_mixed_degree_model(self): + """Model with one quadratic and one cubic constraint (mixed degree).""" + m = ConcreteModel() + m.x = Var(bounds=(0, 10)) + m.c_quad = Constraint(expr=m.x**2 <= 9) + m.c_cubic = Constraint(expr=m.x**3 <= 27) + m.obj = Objective(expr=m.x, sense=minimize) + return m + + def test_short_circuit_mixed_degree_model_routes_to_nlp(self): + """A model with both quadratic and cubic constraints must route to NLP. + + has_quadratic_constraints and has_nonquadratic_constraints are both + True. The NLP short-circuit path should be taken regardless of whether + the configured MIP solver claims quadratic support, because the cubic + constraint makes this an NLP. + """ + algo = self._make_algorithm( + self._make_mixed_degree_model(), + mip_solver_name='gurobi', + mip_opt=_FakeLegacyMIPSolver( + quadratic_objective=True, quadratic_constraint=True + ), + ) + + util = algo.working_model.MindtPy_utils + self.assertTrue(util.has_quadratic_constraints) + self.assertTrue(util.has_nonquadratic_constraints) + + with patch( + 'pyomo.contrib.mindtpy.algorithm_base_class.update_solver_timelimit' + ): + self.assertFalse(algo.model_is_valid()) + + algo.mip_opt.solve.assert_not_called() + algo.nlp_opt.solve.assert_called_once() From ee55f39210d9ba0e6340c31a418af731091ee8a6 Mon Sep 17 00:00:00 2001 From: "David E. Bernal Neira" Date: Wed, 22 Apr 2026 20:49:20 -0400 Subject: [PATCH 5/6] Normalize MindtPy objective handling before short-circuit routing --- pyomo/contrib/mindtpy/algorithm_base_class.py | 195 +++++++++++------- .../mindtpy/tests/test_mindtpy_no_discrete.py | 48 +++++ 2 files changed, 171 insertions(+), 72 deletions(-) diff --git a/pyomo/contrib/mindtpy/algorithm_base_class.py b/pyomo/contrib/mindtpy/algorithm_base_class.py index 6aac9a0f19d..f4a43e96234 100644 --- a/pyomo/contrib/mindtpy/algorithm_base_class.py +++ b/pyomo/contrib/mindtpy/algorithm_base_class.py @@ -18,6 +18,7 @@ from pyomo.opt import TerminationCondition as tc from pyomo.contrib.mindtpy import __version__ from pyomo.common.dependencies import attempt_import +from pyomo.common.modeling import unique_component_name from pyomo.util.vars_from_expressions import get_vars_from_components from pyomo.solvers.plugins.solvers.persistent_solver import PersistentSolver from pyomo.common.collections import ComponentMap, Bunch, ComponentSet @@ -91,8 +92,11 @@ def __init__(self, **kwds): """ self.working_model = None + self.original_model = None self.mip = None self.fixed_nlp = None + self._temporary_original_objective_name = None + self._original_model_num_active_objectives = None # We store bounds, timing info, iteration count, incumbent, and the # Expression of the original (possibly nonlinear) objective function. @@ -245,6 +249,56 @@ def create_utility_block(self, model, name): self.build_ordered_component_lists(model) self.add_cuts_components(model) + def _get_main_objective( + self, model, create_dummy_objective=False, logger=None + ): + """Return the single active objective, adding a dummy one if needed. + + Parameters + ---------- + model : BlockData + Model to inspect. + create_dummy_objective : bool, optional + Whether to create a temporary constant objective when the model has + no active objective, by default False. + logger : logging.Logger, optional + Logger used to emit the missing-objective warning when a dummy + objective is created. + + Returns + ------- + tuple + ``(main_obj, objective_count, dummy_name)`` where + ``objective_count`` is the number of active objectives before any + dummy objective is added, and ``dummy_name`` is the generated + component name or ``None``. + """ + active_objectives = list( + model.component_data_objects(ctype=Objective, active=True, descend_into=True) + ) + objective_count = len(active_objectives) + if objective_count == 0: + if not create_dummy_objective: + return None, objective_count, None + if logger is not None: + logger.warning('Model has no active objectives. Adding dummy objective.') + dummy_name = unique_component_name(model, 'MindtPy_dummy_objective') + setattr(model, dummy_name, Objective(expr=1)) + return getattr(model, dummy_name), objective_count, dummy_name + if objective_count > 1: + raise ValueError('Model has multiple active objectives.') + return active_objectives[0], objective_count, None + + def _cleanup_temporary_original_objective(self): + if self._temporary_original_objective_name is None or self.original_model is None: + return + if ( + self.original_model.component(self._temporary_original_objective_name) + is not None + ): + self.original_model.del_component(self._temporary_original_objective_name) + self._temporary_original_objective_name = None + def model_is_valid(self): """ Check if the model requires the MindtPy MINLP decomposition algorithm. @@ -309,11 +363,6 @@ def model_is_valid(self): } problem_type_articles = {'LP': 'an', 'QP': 'a', 'QCP': 'a', 'NLP': 'an'} obj_degree = MindtPy.objective_polynomial_degree - if obj_degree is None: - working_obj = next( - m.component_data_objects(ctype=Objective, active=True) - ) - obj_degree = working_obj.expr.polynomial_degree() problem_type, solver_to_use, unsupported_structure = ( self._classify_short_circuit_problem(MindtPy, obj_degree) ) @@ -829,21 +878,12 @@ def process_objective(self, update_var_con_list=True): config = self.config m = self.working_model util_block = getattr(m, self.util_block_name) - # Handle missing or multiple objectives - active_objectives = list( - m.component_data_objects(ctype=Objective, active=True, descend_into=True) + main_obj, _, _ = self._get_main_objective( + m, create_dummy_objective=True, logger=config.logger + ) + self.results.problem.number_of_objectives = ( + self._original_model_num_active_objectives ) - self.results.problem.number_of_objectives = len(active_objectives) - if len(active_objectives) == 0: - config.logger.warning( - 'Model has no active objectives. Adding dummy objective.' - ) - util_block.dummy_objective = Objective(expr=1) - main_obj = util_block.dummy_objective - elif len(active_objectives) > 1: - raise ValueError('Model has multiple active objectives.') - else: - main_obj = active_objectives[0] self.results.problem.sense = main_obj.sense self.objective_sense = main_obj.sense @@ -953,8 +993,14 @@ def set_up_solve_data(self, model): The original model to be solved in MindtPy. """ config = self.config + self.original_model = model + self._temporary_original_objective_name = None + obj, objective_count, dummy_name = self._get_main_objective( + model, create_dummy_objective=True, logger=config.logger + ) + self._temporary_original_objective_name = dummy_name + self._original_model_num_active_objectives = objective_count # if the objective function is a constant, dual bound constraint is not added. - obj = next(model.component_data_objects(ctype=Objective, active=True)) if obj.expr.polynomial_degree() == 0: config.logger.info( 'The model has a constant objecitive function. use_dual_bound is set to False.' @@ -966,7 +1012,6 @@ def set_up_solve_data(self, model): # TODO: logging_level is not logging.INFO here config.logger.info('Use the fbbt to tighten the bounds of variables') - self.original_model = model self.working_model = model.clone() # set up bounds @@ -3002,72 +3047,78 @@ def solve(self, model, **kwds): with lower_logger_level_to(config.logger, new_logging_level): self.check_config() - self.set_up_solve_data(model) - - if config.integer_to_binary: - TransformationFactory('contrib.integer_to_binary').apply_to( - self.working_model - ) + try: + self.set_up_solve_data(model) - self.create_utility_block(self.working_model, 'MindtPy_utils') - with ( - time_code(self.timing, 'total', is_main_timer=True), - lower_logger_level_to(config.logger, new_logging_level), - ): - self._log_solver_intro_message() - self.initialize_subsolvers() + if config.integer_to_binary: + TransformationFactory('contrib.integer_to_binary').apply_to( + self.working_model + ) - # Initialize MindtPy results early so that direct LP/NLP short-circuit - # paths can still return a valid SolverResults object. - setup_results_object(self.results, self.original_model, config) + self.create_utility_block(self.working_model, 'MindtPy_utils') + with ( + time_code(self.timing, 'total', is_main_timer=True), + lower_logger_level_to(config.logger, new_logging_level), + ): + self._log_solver_intro_message() + self.initialize_subsolvers() + + # Initialize MindtPy results early so that direct LP/NLP short-circuit + # paths can still return a valid SolverResults object. + setup_results_object(self.results, self.original_model, config) + self.results.problem.number_of_objectives = ( + self._original_model_num_active_objectives + ) - # Validate the model to ensure that MindtPy is able to solve it. - if not self.model_is_valid(): - return self.results + # Validate the model to ensure that MindtPy is able to solve it. + if not self.model_is_valid(): + return self.results - MindtPy = self.working_model.MindtPy_utils + MindtPy = self.working_model.MindtPy_utils - # Reformulate the objective function. - self.objective_reformulation() + # Reformulate the objective function. + self.objective_reformulation() - # Save model initial values. - self.initial_var_values = list(v.value for v in MindtPy.variable_list) + # Save model initial values. + self.initial_var_values = list(v.value for v in MindtPy.variable_list) - # TODO: if the MindtPy solver is defined once and called several times to solve models. The following two lines are necessary. It seems that the solver class will not be init every time call. - # For example, if we remove the following two lines. test_RLPNLP_L1 will fail. - self.best_solution_found = None - self.best_solution_found_time = None - self.initialize_mip_problem() + # TODO: if the MindtPy solver is defined once and called several times to solve models. The following two lines are necessary. It seems that the solver class will not be init every time call. + # For example, if we remove the following two lines. test_RLPNLP_L1 will fail. + self.best_solution_found = None + self.best_solution_found_time = None + self.initialize_mip_problem() - # Initialization - with time_code(self.timing, 'initialization'): - self.MindtPy_initialization() + # Initialization + with time_code(self.timing, 'initialization'): + self.MindtPy_initialization() - # Algorithm main loop - with time_code(self.timing, 'main loop'): - self.MindtPy_iteration_loop() + # Algorithm main loop + with time_code(self.timing, 'main loop'): + self.MindtPy_iteration_loop() - # Load solution - if self.best_solution_found is not None: - self.load_solution() + # Load solution + if self.best_solution_found is not None: + self.load_solution() - # Get integral info - self.get_integral_info() + # Get integral info + self.get_integral_info() - config.logger.info( - ' {:<25}: {:>7.4f} '.format( - 'Primal-dual gap integral', self.primal_dual_gap_integral + config.logger.info( + ' {:<25}: {:>7.4f} '.format( + 'Primal-dual gap integral', self.primal_dual_gap_integral + ) ) - ) - # Update result - self.update_result() - if config.single_tree: - self.results.solver.num_nodes = self.nlp_iter - ( - 1 if config.init_strategy == 'rNLP' else 0 - ) + # Update result + self.update_result() + if config.single_tree: + self.results.solver.num_nodes = self.nlp_iter - ( + 1 if config.init_strategy == 'rNLP' else 0 + ) - return self.results + return self.results + finally: + self._cleanup_temporary_original_objective() def objective_reformulation(self): # 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. diff --git a/pyomo/contrib/mindtpy/tests/test_mindtpy_no_discrete.py b/pyomo/contrib/mindtpy/tests/test_mindtpy_no_discrete.py index fed46e33122..1ba46b08d75 100644 --- a/pyomo/contrib/mindtpy/tests/test_mindtpy_no_discrete.py +++ b/pyomo/contrib/mindtpy/tests/test_mindtpy_no_discrete.py @@ -47,6 +47,32 @@ 'Required subsolvers %s are not available' % (short_circuit_required_solvers,), ) class TestMindtPyShortCircuitNoDiscrete(unittest.TestCase): + def test_short_circuit_model_with_no_objective_uses_temporary_dummy_objective(self): + """No-objective models should short-circuit and leave the user model unchanged.""" + m = ConcreteModel() + m.x = Var(domain=NonNegativeReals) + m.y = Var(domain=Binary) + m.y.fix(0) + + m.c1 = Constraint(expr=m.x >= 1) + m.c2 = Constraint(expr=m.x <= 1) + + with SolverFactory('mindtpy') as opt: + results = opt.solve( + m, + strategy='OA', + mip_solver=short_circuit_required_solvers[1], + nlp_solver=short_circuit_required_solvers[0], + ) + + self.assertIsNotNone(results) + self.assertEqual(results.solver.termination_condition, tc.optimal) + self.assertEqual(results.problem.number_of_objectives, 0) + self.assertAlmostEqual(m.x.value, 1.0, places=4) + self.assertEqual( + len(list(m.component_data_objects(ctype=Objective, active=True))), 0 + ) + def test_no_discrete_decisions_short_circuit_loads_values(self): """Regression test for MindtPy short-circuit with no discrete decisions. @@ -207,6 +233,28 @@ def test_short_circuit_maximize_lp(self): self.assertEqual(results.solver.termination_condition, tc.optimal) self.assertAlmostEqual(m.x.value, 5.0, places=4) + def test_short_circuit_multiobjective_model_raises(self): + """Multiple active objectives should be rejected before short-circuit solve.""" + m = ConcreteModel() + m.x = Var(domain=NonNegativeReals) + m.y = Var(domain=Binary) + m.y.fix(0) + + m.c = Constraint(expr=m.x >= 1) + m.obj1 = Objective(expr=m.x, sense=minimize) + m.obj2 = Objective(expr=2 * m.x, sense=minimize) + + with SolverFactory('mindtpy') as opt: + with self.assertRaisesRegex( + ValueError, 'Model has multiple active objectives.' + ): + opt.solve( + m, + strategy='OA', + mip_solver=short_circuit_required_solvers[1], + nlp_solver=short_circuit_required_solvers[0], + ) + class _SimpleNamespace: """A plain object for tracking attribute assignments in tests.""" From 29145ba1e7e3be328cd356031191b364a14bbdd4 Mon Sep 17 00:00:00 2001 From: "David E. Bernal Neira" Date: Wed, 22 Apr 2026 21:25:48 -0400 Subject: [PATCH 6/6] Format MindtPy objective normalization helpers --- pyomo/contrib/mindtpy/algorithm_base_class.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/pyomo/contrib/mindtpy/algorithm_base_class.py b/pyomo/contrib/mindtpy/algorithm_base_class.py index f4a43e96234..4ba1d470a7a 100644 --- a/pyomo/contrib/mindtpy/algorithm_base_class.py +++ b/pyomo/contrib/mindtpy/algorithm_base_class.py @@ -249,9 +249,7 @@ def create_utility_block(self, model, name): self.build_ordered_component_lists(model) self.add_cuts_components(model) - def _get_main_objective( - self, model, create_dummy_objective=False, logger=None - ): + def _get_main_objective(self, model, create_dummy_objective=False, logger=None): """Return the single active objective, adding a dummy one if needed. Parameters @@ -274,14 +272,18 @@ def _get_main_objective( component name or ``None``. """ active_objectives = list( - model.component_data_objects(ctype=Objective, active=True, descend_into=True) + model.component_data_objects( + ctype=Objective, active=True, descend_into=True + ) ) objective_count = len(active_objectives) if objective_count == 0: if not create_dummy_objective: return None, objective_count, None if logger is not None: - logger.warning('Model has no active objectives. Adding dummy objective.') + logger.warning( + 'Model has no active objectives. Adding dummy objective.' + ) dummy_name = unique_component_name(model, 'MindtPy_dummy_objective') setattr(model, dummy_name, Objective(expr=1)) return getattr(model, dummy_name), objective_count, dummy_name @@ -290,7 +292,10 @@ def _get_main_objective( return active_objectives[0], objective_count, None def _cleanup_temporary_original_objective(self): - if self._temporary_original_objective_name is None or self.original_model is None: + if ( + self._temporary_original_objective_name is None + or self.original_model is None + ): return if ( self.original_model.component(self._temporary_original_objective_name)