diff --git a/pyomo/contrib/pyros/tests/test_uncertainty_sets.py b/pyomo/contrib/pyros/tests/test_uncertainty_sets.py index 28e974598e3..0ddd51e56e9 100644 --- a/pyomo/contrib/pyros/tests/test_uncertainty_sets.py +++ b/pyomo/contrib/pyros/tests/test_uncertainty_sets.py @@ -3257,10 +3257,41 @@ def test_compute_exact_parameter_bounds(self): baron = SolverFactory("baron") custom_set = CustomUncertaintySet(dim=2) self.assertEqual(custom_set.parameter_bounds, [(-1, 1)] * 2) + + # check clearing cache + # Expecting 0 hits, misses, size + custom_set._solve_bounds_optimization.cache_clear() + info = custom_set._solve_bounds_optimization.cache_info() + self.assertEqual(info.hits, 0) + self.assertEqual(info.misses, 0) + self.assertEqual(info.maxsize, None) + self.assertEqual(info.currsize, 0) + + # check cache info + # Expecting 4 misses and size 4 self.assertEqual( custom_set._compute_exact_parameter_bounds(baron), [(-1, 1)] * 2 ) + info = custom_set._solve_bounds_optimization.cache_info() + self.assertEqual(info.hits, 0) + self.assertEqual(info.misses, 4) + self.assertEqual(info.maxsize, None) + self.assertEqual(info.currsize, 4) + + # run again and check caching + # Expecting additional 4 hits from accessing cached values + self.assertEqual( + custom_set._compute_exact_parameter_bounds(baron), [(-1, 1)] * 2 + ) + + info = custom_set._solve_bounds_optimization.cache_info() + self.assertEqual(info.hits, 4) + self.assertEqual(info.misses, 4) + self.assertEqual(info.maxsize, None) + self.assertEqual(info.currsize, 4) + custom_set._solve_bounds_optimization.cache_clear() + @unittest.skipUnless(baron_available, "BARON is not available") def test_solve_feasibility(self): """ diff --git a/pyomo/contrib/pyros/uncertainty_sets.py b/pyomo/contrib/pyros/uncertainty_sets.py index a925ab19396..8445441678a 100644 --- a/pyomo/contrib/pyros/uncertainty_sets.py +++ b/pyomo/contrib/pyros/uncertainty_sets.py @@ -656,6 +656,7 @@ def validate(self, config): """ Validate the uncertainty set with a nonemptiness and boundedness check. + Clears any cached exact parameter bounds. Parameters ---------- @@ -667,6 +668,10 @@ def validate(self, config): ValueError If nonemptiness check or boundedness check fails. """ + # clear any cached exact parameter bounds + self._solve_bounds_optimization.cache_clear() + + # perform validation checks if not self.is_nonempty(config=config): raise ValueError(f"Nonemptiness check failed for uncertainty set {self}.") @@ -788,45 +793,78 @@ def _compute_exact_parameter_bounds(self, solver, index=None): if index is None: index = [(True, True)] * self.dim - # create bounding model and get all objectives - bounding_model = self._create_bounding_model() - objs_to_optimize = bounding_model.param_var_objectives.items() - param_bounds = [] - for idx, obj in objs_to_optimize: - # activate objective for corresponding dimension - obj.activate() + for idx in range(self.dim): bounds = [] - - # solve for lower bound, then upper bound - # solve should be successful for i, sense in enumerate((minimize, maximize)): - # check if the LB or UB should be solved if not index[idx][i]: bounds.append(None) continue - obj.sense = sense - res = solver.solve(bounding_model, load_solutions=False) - if check_optimal_termination(res): - bounding_model.solutions.load_from(res) - else: - raise ValueError( - "Could not compute " - f"{'lower' if sense == minimize else 'upper'} " - f"bound in dimension {idx + 1} of {self.dim}. " - f"Solver status summary:\n {res.solver}." - ) - bounds.append(value(obj)) + bounds.append(self._solve_bounds_optimization(solver, idx, sense)) # add parameter bounds for current dimension param_bounds.append(tuple(bounds)) - # ensure sense is minimize when done, deactivate - obj.sense = minimize - obj.deactivate() - return param_bounds + @functools.cache + def _solve_bounds_optimization(self, solver, index, sense): + """ + Compute value of bounds for a single parameter + of `self` at a specified index by solving a bounding model. + Results are cached as efficiency for large uncertainty sets. + + Parameters + ---------- + solver : ~pyomo.opt.base.solvers.OptSolver + Optimizer to invoke on the bounding problems. + index : int + The index of the parameter to solve for bounds. + sense : Pyomo objective sense + A Pyomo objective sense to optimize for the bounding model. + `maximize` solves for an upper bound and + `minimize` solves for a lower bound. + + Returns + ------- + bound : float + A value of the lower or upper bound for + the corresponding dimension at the specified index. + + Raises + ------ + ValueError + If solver failed to compute a bound for a + coordinate. + """ + # create bounding model and get all objectives + bounding_model = self._create_bounding_model() + + # select objective corresponding to specified index + obj = bounding_model.param_var_objectives[index] + obj.activate() + + # optimize with specified sense + obj.sense = sense + res = solver.solve(bounding_model, load_solutions=False) + if check_optimal_termination(res): + bounding_model.solutions.load_from(res) + else: + raise ValueError( + "Could not compute " + f"{'lower' if sense == minimize else 'upper'} " + f"bound in dimension {index + 1} of {self.dim}. " + f"Solver status summary:\n {res.solver}." + ) + + # ensure sense is minimize when done, deactivate + obj.sense = minimize + obj.deactivate() + + bound = value(obj) + + return bound + def _fbbt_parameter_bounds(self, config): """ Obtain parameter bounds of the uncertainty set using FBBT. @@ -858,7 +896,8 @@ def _fbbt_parameter_bounds(self, config): ) param_bounds = [ - (var.lower, var.upper) for var in bounding_model.param_vars.values() + (value(var.lower), value(var.upper)) + for var in bounding_model.param_vars.values() ] return param_bounds