From 8d1f872c7d0418b0e4d4aadf61c7058102029974 Mon Sep 17 00:00:00 2001 From: alvaroborras <13698600+alvaroborras@users.noreply.github.com> Date: Thu, 27 Aug 2026 22:27:44 +0200 Subject: [PATCH 1/4] Make GDP enumeration lazy --- pyomo/contrib/gdpopt/enumerate.py | 68 +++++++++++--------- pyomo/contrib/gdpopt/tests/test_enumerate.py | 52 +++++++++++++++ 2 files changed, 90 insertions(+), 30 deletions(-) diff --git a/pyomo/contrib/gdpopt/enumerate.py b/pyomo/contrib/gdpopt/enumerate.py index aae1b7bc0f9..2b9dfb41436 100644 --- a/pyomo/contrib/gdpopt/enumerate.py +++ b/pyomo/contrib/gdpopt/enumerate.py @@ -7,7 +7,9 @@ # software. This software is distributed under the 3-clause BSD License. # ____________________________________________________________________________________ +import math from itertools import product +from operator import index from pyomo.common.collections import ComponentSet from pyomo.common.config import document_kwargs_from_configdict @@ -110,8 +112,6 @@ def _log_current_state(self, logger, subproblem_type, primal_improved=False): ) def _solve_gdp(self, original_model, config): - logger = config.logger - util_block = self.original_util_block # From preprocessing to make sure this *is* a GDP, we already have # lists of: @@ -124,19 +124,30 @@ def _solve_gdp(self, original_model, config): subproblem, subproblem_util_block = get_subproblem(original_model, util_block) - discrete_solns = list( - self._discrete_solution_iterator( - subproblem_util_block.disjunction_list, - subproblem_util_block.non_indicator_boolean_variable_list, - subproblem_util_block.discrete_variable_list, - config, + disjunctions = subproblem_util_block.disjunction_list + non_indicator_boolean_vars = ( + subproblem_util_block.non_indicator_boolean_variable_list + ) + discrete_vars = subproblem_util_block.discrete_variable_list + + self.num_discrete_solns = math.prod( + len(disjunction.disjuncts) for disjunction in disjunctions + ) + if config.force_subproblem_nlp: + self.num_discrete_solns *= 2 ** len(non_indicator_boolean_vars) * math.prod( + max(0, index(v.ub) - index(v.lb) + 1) for v in discrete_vars ) + + if self.reached_time_limit(config) or self.reached_iteration_limit(config): + return + discrete_solns = self._discrete_solution_iterator( + disjunctions, non_indicator_boolean_vars, discrete_vars, config ) - self.num_discrete_solns = len(discrete_solns) - for soln in discrete_solns: - # We will interrupt based on time limit or iteration limit: + for _ in range(self.num_discrete_solns): if self.reached_time_limit(config) or self.reached_iteration_limit(config): - break + return + soln = next(discrete_solns) + self.iteration += 1 with time_code(self.timing, 'nlp'): @@ -159,26 +170,23 @@ def _solve_gdp(self, original_model, config): # the whole problem is unbounded, we can stop self._update_primal_bound_to_unbounded(config) self._log_current_state(config.logger, 'subproblem', True) - break + return else: # Just log where we are self._log_current_state(config.logger, 'subproblem') - if self.iteration == self.num_discrete_solns: - # We can terminate optimally or declare infeasibility: We have - # enumerated all solutions, so our incumbent is optimal (or - # locally optimal, depending on how we solved the subproblems) - # if it exists, and if not then there is no solution. - if self.incumbent_boolean_soln is None: - self._update_dual_bound_to_infeasible() - self._load_infeasible_termination_status(config) - else: # the incumbent is optimal - self._update_bounds(dual=self.primal_bound(), force_update=True) - self._log_current_state(config.logger, '') - config.logger.info( - 'GDPopt exiting--all discrete solutions have been ' - 'enumerated.' - ) - self.pyomo_results.solver.termination_condition = tc.optimal - break + # We can terminate optimally or declare infeasibility: We have + # enumerated all solutions, so our incumbent is optimal (or + # locally optimal, depending on how we solved the subproblems) + # if it exists, and if not then there is no solution. + if self.incumbent_boolean_soln is None: + self._update_dual_bound_to_infeasible() + self._load_infeasible_termination_status(config) + else: # the incumbent is optimal + self._update_bounds(dual=self.primal_bound(), force_update=True) + self._log_current_state(config.logger, '') + config.logger.info( + 'GDPopt exiting--all discrete solutions have been enumerated.' + ) + self.pyomo_results.solver.termination_condition = tc.optimal diff --git a/pyomo/contrib/gdpopt/tests/test_enumerate.py b/pyomo/contrib/gdpopt/tests/test_enumerate.py index f0860fe577f..65958a259d0 100644 --- a/pyomo/contrib/gdpopt/tests/test_enumerate.py +++ b/pyomo/contrib/gdpopt/tests/test_enumerate.py @@ -25,6 +25,58 @@ import pyomo.gdp.tests.models as models +class _ExpiredEnumerationSolver(GDP_Enumeration_Solver): + """Enumeration solver that enters its GDP solve after the time limit.""" + + def _solve_gdp(self, original_model, config): + """Expire the active timer before running the real GDP solve.""" + self.timing.main_timer_start_time -= config.time_limit + return super()._solve_gdp(original_model, config) + + def _discrete_solution_iterator(self, *args): + """Fail instead of requesting a discrete solution.""" + raise AssertionError('discrete solutions were enumerated') + + +class TestGDPoptEnumerateUnit(unittest.TestCase): + def test_large_space_not_enumerated_after_time_limit(self): + m = ConcreteModel() + m.x = Var(bounds=(-1, 1)) + m.i = Var(domain=Integers, bounds=(0, 10**20)) + m.obj = Objective(expr=m.x + m.i) + + def disjunction_rule(m, _): + return [[m.x <= 0], [m.x >= 0]] + + m.disjunctions = Disjunction(range(32), rule=disjunction_rule) + solver = _ExpiredEnumerationSolver() + results = solver.solve(m, force_subproblem_nlp=True, time_limit=1) + + self.assertEqual(solver.num_discrete_solns, 2**32 * (10**20 + 1)) + self.assertEqual( + results.solver.termination_condition, TerminationCondition.maxTimeLimit + ) + + def test_completion_and_solver_reuse(self): + solver = GDP_Enumeration_Solver() + for num_disjuncts, expected_iterations, options in ( + (2, 2, {'iterlim': 2}), + (3, 5, {}), + ): + m = ConcreteModel() + m.disjunction = Disjunction( + expr=[[Constraint.Infeasible] for _ in range(num_disjuncts)] + ) + m.obj = Objective(expr=0) + + results = solver.solve(m, **options) + + self.assertEqual(results.solver.iterations, expected_iterations) + self.assertEqual( + results.solver.termination_condition, TerminationCondition.infeasible + ) + + @unittest.skipUnless(SolverFactory('gurobi').available(), 'Gurobi not available') @unittest.skipUnless(SolverFactory('gurobi').license_is_valid(), 'Gurobi not licensed') class TestGDPoptEnumerate(unittest.TestCase): From eb44fbff68f1cb4e1875746829864c56a17b6005 Mon Sep 17 00:00:00 2001 From: alvaroborras <13698600+alvaroborras@users.noreply.github.com> Date: Wed, 2 Sep 2026 09:20:55 +0200 Subject: [PATCH 2/4] Simplify lazy GDP enumeration --- pyomo/contrib/gdpopt/enumerate.py | 49 +++----------------- pyomo/contrib/gdpopt/tests/test_enumerate.py | 1 - 2 files changed, 7 insertions(+), 43 deletions(-) diff --git a/pyomo/contrib/gdpopt/enumerate.py b/pyomo/contrib/gdpopt/enumerate.py index 2b9dfb41436..f2d26dc8897 100644 --- a/pyomo/contrib/gdpopt/enumerate.py +++ b/pyomo/contrib/gdpopt/enumerate.py @@ -7,9 +7,7 @@ # software. This software is distributed under the 3-clause BSD License. # ____________________________________________________________________________________ -import math from itertools import product -from operator import index from pyomo.common.collections import ComponentSet from pyomo.common.config import document_kwargs_from_configdict @@ -29,11 +27,7 @@ get_subproblem, ) from pyomo.contrib.gdpopt.solve_subproblem import solve_subproblem -from pyomo.contrib.gdpopt.util import ( - fix_discrete_solution_in_subproblem, - time_code, - get_main_elapsed_time, -) +from pyomo.contrib.gdpopt.util import fix_discrete_solution_in_subproblem, time_code from pyomo.core import value from pyomo.opt import TerminationCondition as tc @@ -95,22 +89,6 @@ def _discrete_solution_iterator( integer_realization, ) - # Override logging so that we print progress in terms of the number of - # iterations needed to fully enumerate the discrete space. - def _log_current_state(self, logger, subproblem_type, primal_improved=False): - star = "*" if primal_improved else "" - logger.info( - self.log_formatter.format( - "{}/{}".format(self.iteration, self.num_discrete_solns), - subproblem_type, - self.LB, - self.UB, - self.relative_gap(), - get_main_elapsed_time(self.timing), - star, - ) - ) - def _solve_gdp(self, original_model, config): util_block = self.original_util_block # From preprocessing to make sure this *is* a GDP, we already have @@ -124,29 +102,16 @@ def _solve_gdp(self, original_model, config): subproblem, subproblem_util_block = get_subproblem(original_model, util_block) - disjunctions = subproblem_util_block.disjunction_list - non_indicator_boolean_vars = ( - subproblem_util_block.non_indicator_boolean_variable_list - ) - discrete_vars = subproblem_util_block.discrete_variable_list - - self.num_discrete_solns = math.prod( - len(disjunction.disjuncts) for disjunction in disjunctions - ) - if config.force_subproblem_nlp: - self.num_discrete_solns *= 2 ** len(non_indicator_boolean_vars) * math.prod( - max(0, index(v.ub) - index(v.lb) + 1) for v in discrete_vars - ) - if self.reached_time_limit(config) or self.reached_iteration_limit(config): return - discrete_solns = self._discrete_solution_iterator( - disjunctions, non_indicator_boolean_vars, discrete_vars, config - ) - for _ in range(self.num_discrete_solns): + for soln in self._discrete_solution_iterator( + subproblem_util_block.disjunction_list, + subproblem_util_block.non_indicator_boolean_variable_list, + subproblem_util_block.discrete_variable_list, + config, + ): if self.reached_time_limit(config) or self.reached_iteration_limit(config): return - soln = next(discrete_solns) self.iteration += 1 diff --git a/pyomo/contrib/gdpopt/tests/test_enumerate.py b/pyomo/contrib/gdpopt/tests/test_enumerate.py index 65958a259d0..0f1b3d58f5d 100644 --- a/pyomo/contrib/gdpopt/tests/test_enumerate.py +++ b/pyomo/contrib/gdpopt/tests/test_enumerate.py @@ -52,7 +52,6 @@ def disjunction_rule(m, _): solver = _ExpiredEnumerationSolver() results = solver.solve(m, force_subproblem_nlp=True, time_limit=1) - self.assertEqual(solver.num_discrete_solns, 2**32 * (10**20 + 1)) self.assertEqual( results.solver.termination_condition, TerminationCondition.maxTimeLimit ) From 95b24327f18179bed3e45640711b8c806b8cae3a Mon Sep 17 00:00:00 2001 From: alvaroborras <13698600+alvaroborras@users.noreply.github.com> Date: Wed, 2 Sep 2026 20:07:19 +0200 Subject: [PATCH 3/4] Restore GDP enumeration progress logging --- pyomo/contrib/gdpopt/enumerate.py | 46 +++++++++++++++++--- pyomo/contrib/gdpopt/tests/test_enumerate.py | 43 ++++++++++-------- 2 files changed, 64 insertions(+), 25 deletions(-) diff --git a/pyomo/contrib/gdpopt/enumerate.py b/pyomo/contrib/gdpopt/enumerate.py index f2d26dc8897..0276f5d086d 100644 --- a/pyomo/contrib/gdpopt/enumerate.py +++ b/pyomo/contrib/gdpopt/enumerate.py @@ -7,6 +7,7 @@ # software. This software is distributed under the 3-clause BSD License. # ____________________________________________________________________________________ +import math from itertools import product from pyomo.common.collections import ComponentSet @@ -27,7 +28,11 @@ get_subproblem, ) from pyomo.contrib.gdpopt.solve_subproblem import solve_subproblem -from pyomo.contrib.gdpopt.util import fix_discrete_solution_in_subproblem, time_code +from pyomo.contrib.gdpopt.util import ( + fix_discrete_solution_in_subproblem, + time_code, + get_main_elapsed_time, +) from pyomo.core import value from pyomo.opt import TerminationCondition as tc @@ -69,7 +74,9 @@ def solve(self, model, **kwds): def _discrete_solution_iterator( self, disjunctions, non_indicator_boolean_vars, discrete_var_list, config ): - discrete_var_values = [range(v.lb, v.ub + 1) for v in discrete_var_list] + discrete_var_values = [ + range(math.ceil(v.lb), math.floor(v.ub) + 1) for v in discrete_var_list + ] # we will calculate all the possible indicator_var realizations, and # then multiply those out by all the boolean var realizations and all # the integer var realizations. @@ -89,6 +96,22 @@ def _discrete_solution_iterator( integer_realization, ) + # Override logging so that we print progress in terms of the number of + # iterations needed to fully enumerate the discrete space. + def _log_current_state(self, logger, subproblem_type, primal_improved=False): + star = "*" if primal_improved else "" + logger.info( + self.log_formatter.format( + "{}/{}".format(self.iteration, self.num_discrete_solns), + subproblem_type, + self.LB, + self.UB, + self.relative_gap(), + get_main_elapsed_time(self.timing), + star, + ) + ) + def _solve_gdp(self, original_model, config): util_block = self.original_util_block # From preprocessing to make sure this *is* a GDP, we already have @@ -102,13 +125,24 @@ def _solve_gdp(self, original_model, config): subproblem, subproblem_util_block = get_subproblem(original_model, util_block) + disjunctions = subproblem_util_block.disjunction_list + non_indicator_boolean_vars = ( + subproblem_util_block.non_indicator_boolean_variable_list + ) + discrete_vars = subproblem_util_block.discrete_variable_list + + self.num_discrete_solns = math.prod( + len(disjunction.disjuncts) for disjunction in disjunctions + ) + if config.force_subproblem_nlp: + self.num_discrete_solns *= 2 ** len(non_indicator_boolean_vars) * math.prod( + max(0, math.floor(v.ub) - math.ceil(v.lb) + 1) for v in discrete_vars + ) + if self.reached_time_limit(config) or self.reached_iteration_limit(config): return for soln in self._discrete_solution_iterator( - subproblem_util_block.disjunction_list, - subproblem_util_block.non_indicator_boolean_variable_list, - subproblem_util_block.discrete_variable_list, - config, + disjunctions, non_indicator_boolean_vars, discrete_vars, config ): if self.reached_time_limit(config) or self.reached_iteration_limit(config): return diff --git a/pyomo/contrib/gdpopt/tests/test_enumerate.py b/pyomo/contrib/gdpopt/tests/test_enumerate.py index 0f1b3d58f5d..c7a2f37e60a 100644 --- a/pyomo/contrib/gdpopt/tests/test_enumerate.py +++ b/pyomo/contrib/gdpopt/tests/test_enumerate.py @@ -42,7 +42,7 @@ class TestGDPoptEnumerateUnit(unittest.TestCase): def test_large_space_not_enumerated_after_time_limit(self): m = ConcreteModel() m.x = Var(bounds=(-1, 1)) - m.i = Var(domain=Integers, bounds=(0, 10**20)) + m.i = Var(domain=Integers, bounds=(0.5, 10**20)) m.obj = Objective(expr=m.x + m.i) def disjunction_rule(m, _): @@ -52,28 +52,33 @@ def disjunction_rule(m, _): solver = _ExpiredEnumerationSolver() results = solver.solve(m, force_subproblem_nlp=True, time_limit=1) + self.assertEqual(solver.num_discrete_solns, 2**32 * 10**20) self.assertEqual( results.solver.termination_condition, TerminationCondition.maxTimeLimit ) - def test_completion_and_solver_reuse(self): - solver = GDP_Enumeration_Solver() - for num_disjuncts, expected_iterations, options in ( - (2, 2, {'iterlim': 2}), - (3, 5, {}), - ): - m = ConcreteModel() - m.disjunction = Disjunction( - expr=[[Constraint.Infeasible] for _ in range(num_disjuncts)] - ) - m.obj = Objective(expr=0) - - results = solver.solve(m, **options) - - self.assertEqual(results.solver.iterations, expected_iterations) - self.assertEqual( - results.solver.termination_condition, TerminationCondition.infeasible - ) + def test_noninteger_integer_bounds(self): + m = ConcreteModel() + m.i = Var(domain=Integers, bounds=(0.5, 3.5)) + solver = GDP_Enumeration_Solver(force_subproblem_nlp=True) + + solutions = solver._discrete_solution_iterator([], [], [m.i], solver.config) + + self.assertEqual([solution[2] for solution in solutions], [(1,), (2,), (3,)]) + + def test_completion(self): + m = ConcreteModel() + m.disjunction = Disjunction( + expr=[[Constraint.Infeasible], [Constraint.Infeasible]] + ) + m.obj = Objective(expr=0) + + results = GDP_Enumeration_Solver().solve(m, iterlim=2) + + self.assertEqual(results.solver.iterations, 2) + self.assertEqual( + results.solver.termination_condition, TerminationCondition.infeasible + ) @unittest.skipUnless(SolverFactory('gurobi').available(), 'Gurobi not available') From 1d76287db773ee1c93ac851ae21c4a6daeb95f24 Mon Sep 17 00:00:00 2001 From: alvaroborras <13698600+alvaroborras@users.noreply.github.com> Date: Sun, 6 Sep 2026 08:51:39 +0200 Subject: [PATCH 4/4] Reject unbounded integer variables in GDP enumeration --- pyomo/contrib/gdpopt/enumerate.py | 6 ++++++ pyomo/contrib/gdpopt/tests/test_enumerate.py | 9 +++++++++ 2 files changed, 15 insertions(+) diff --git a/pyomo/contrib/gdpopt/enumerate.py b/pyomo/contrib/gdpopt/enumerate.py index 0276f5d086d..d372ceb0067 100644 --- a/pyomo/contrib/gdpopt/enumerate.py +++ b/pyomo/contrib/gdpopt/enumerate.py @@ -131,6 +131,12 @@ def _solve_gdp(self, original_model, config): ) discrete_vars = subproblem_util_block.discrete_variable_list + for v in discrete_vars: + if v.lb is None or v.ub is None: + raise ValueError( + f"GDPopt enumeration requires finite bounds on integer variable {v.name}." + ) + self.num_discrete_solns = math.prod( len(disjunction.disjuncts) for disjunction in disjunctions ) diff --git a/pyomo/contrib/gdpopt/tests/test_enumerate.py b/pyomo/contrib/gdpopt/tests/test_enumerate.py index c7a2f37e60a..66044640d7f 100644 --- a/pyomo/contrib/gdpopt/tests/test_enumerate.py +++ b/pyomo/contrib/gdpopt/tests/test_enumerate.py @@ -66,6 +66,15 @@ def test_noninteger_integer_bounds(self): self.assertEqual([solution[2] for solution in solutions], [(1,), (2,), (3,)]) + def test_unbounded_integer_variable(self): + m = ConcreteModel() + m.i = Var(domain=Integers) + m.disjunction = Disjunction(expr=[[m.i >= 0], [m.i <= 0]]) + m.obj = Objective(expr=m.i) + + with self.assertRaisesRegex(ValueError, 'finite bounds'): + GDP_Enumeration_Solver().solve(m, force_subproblem_nlp=True) + def test_completion(self): m = ConcreteModel() m.disjunction = Disjunction(