From 8ffe20ce2a47b353a590c0782d5e04a874ce076c Mon Sep 17 00:00:00 2001 From: Jason Yao Date: Mon, 7 Apr 2025 15:31:35 -0400 Subject: [PATCH 01/36] update UncertaintySet is_bounded, is_nonempty --- pyomo/contrib/pyros/uncertainty_sets.py | 158 +++++++++++++++++++++--- 1 file changed, 140 insertions(+), 18 deletions(-) diff --git a/pyomo/contrib/pyros/uncertainty_sets.py b/pyomo/contrib/pyros/uncertainty_sets.py index a4b6ba6aa1a..cfb84e32137 100644 --- a/pyomo/contrib/pyros/uncertainty_sets.py +++ b/pyomo/contrib/pyros/uncertainty_sets.py @@ -36,6 +36,7 @@ minimize, Var, VarData, + NonNegativeReals ) from pyomo.core.expr import mutable_expression, native_numeric_types, value from pyomo.core.util import quicksum, dot_product @@ -502,6 +503,9 @@ def parameter_bounds(self): """ Bounds for the value of each uncertain parameter constrained by the set (i.e. bounds for each set dimension). + + This method should return an empty list if it can't be calculated + or a list of length = self.dim if it can. """ raise NotImplementedError @@ -552,23 +556,32 @@ def is_bounded(self, config): Notes ----- - This check is carried out by solving a sequence of maximization - and minimization problems (in which the objective for each - problem is the value of a single uncertain parameter). If any of - the optimization models cannot be solved successfully to + This check is carried out by checking if all parameter bounds + are finite. + + If no parameter bounds are available, the check is done by + solving a sequence of maximization and minimization problems + (in which the objective for each problem is the value of a + single uncertain parameter). + If any of the optimization models cannot be solved successfully to optimality, then False is returned. - This method is invoked during the validation step of a PyROS - solver call. + This method is invoked by validate. """ - # initialize uncertain parameter variables - param_bounds_arr = np.array( - self._compute_parameter_bounds(solver=config.global_solver) - ) + # use parameter bounds if they are available + param_bounds_arr = self.parameter_bounds + if param_bounds_arr: + all_bounds_finite = np.all(np.isfinite(param_bounds_arr)) + else: + # initialize uncertain parameter variables + param_bounds_arr = np.array( + self._compute_parameter_bounds(solver=config.global_solver) + ) + all_bounds_finite = np.all(np.isfinite(param_bounds_arr)) - all_bounds_finite = np.all(np.isfinite(param_bounds_arr)) + # log result if not all_bounds_finite: - config.progress_logger.info( + config.progress_logger.error( "Computed coordinate value bounds are not all finite. " f"Got bounds: {param_bounds_arr}" ) @@ -577,16 +590,73 @@ def is_bounded(self, config): def is_nonempty(self, config): """ - Return True if the uncertainty set is nonempty, else False. + Determine whether the uncertainty set is nonempty. + + Parameters + ---------- + config : ConfigDict + PyROS solver configuration. + + Returns + ------- + : bool + True if the nominal point is within the set, + and False otherwise. """ - return self.is_bounded(config) + # check if nominal point is in set for quick test + set_nonempty = False + if config.nominal_uncertain_param_vals: + if self.point_in_set(config.nominal_uncertain_param_vals): + set_nonempty = True + else: + # construct feasibility problem and solve otherwise + set_nonempty = self._solve_feasibility(config.global_solver) + + # parameter bounds for logging + param_bounds_arr = self.parameter_bounds + if not param_bounds_arr: + param_bounds_arr = np.array( + self._compute_parameter_bounds(solver=config.global_solver) + ) + + # log result + if not set_nonempty: + config.progress_logger.error( + "Nominal point is not within the uncertainty set. " + f"Set parameter bounds: {param_bounds_arr}" + f"Got nominal point: {config.nominal_uncertain_param_vals}" + ) + + return set_nonempty - def is_valid(self, config): + def validate(self, config): """ - Return True if the uncertainty set is bounded and non-empty, - else False. + Validate the uncertainty set with a nonemptiness and boundedness check. + + Parameters + ---------- + config : ConfigDict + PyROS solver configuration. + + Raises + ------ + ValueError + If nonemptiness check or boundedness check fail. """ - return self.is_nonempty(config=config) and self.is_bounded(config=config) + check_nonempty = self.is_nonempty(config=config) + check_bounded = self.is_bounded(config=config) + + if not check_nonempty: + raise ValueError( + "Failed nonemptiness check. Nominal point is not in the set. " + f"Nominal point:\n {config.nominal_uncertain_param_vals}." + ) + + if not check_bounded: + raise ValueError( + "Failed boundedness check. Parameter bounds are not finite. " + f"Parameter bounds:\n {self.parameter_bounds}." + ) @abc.abstractmethod def set_as_constraint(self, uncertain_params=None, block=None): @@ -698,6 +768,58 @@ def _compute_parameter_bounds(self, solver): return param_bounds + def _solve_feasibility(self, solver): + """ + Construct and solve feasibility problem using uncertainty set + constraints and parameter bounds using `set_as_constraint` and + `_add_bounds_on_uncertain_parameters` of self. + + Parameters + ---------- + solver : Pyomo solver + Optimizer capable of solving bounding problems to + global optimality. + + Returns + ------- + : bool + True if the feasibility problem solves successfully, + and raises an exception otherwise + + Raises + ------ + ValueError + If feasibility problem fails to solve. + """ + model = ConcreteModel() + model.u = Var(within=NonNegativeReals) + + # construct param vars + model.param_vars = Var(range(self.dim)) + + # add bounds on param vars + self._add_bounds_on_uncertain_parameters( + model.param_vars, global_solver=solver + ) + + # add constraints + self.set_as_constraint(uncertain_params=model.param_vars, block=model) + + # add objective with dummy variable model.u + @model.Objective(sense=minimize) + def feasibility_objective(self): + return model.u + + # solve feasibility problem + res = solver.solve(model, load_solutions=False) + if not check_optimal_termination(res): + raise ValueError( + "Could not successfully solve feasibility problem. " + f"Solver status summary:\n {res.solver}." + ) + + return True + def _add_bounds_on_uncertain_parameters( self, uncertain_param_vars, global_solver=None ): From 4cd7f8df4cd6c770dabf3e89eb3b48b8578f48cc Mon Sep 17 00:00:00 2001 From: Jason Yao Date: Mon, 7 Apr 2025 15:34:52 -0400 Subject: [PATCH 02/36] change is_valid to validate and preprocess order --- pyomo/contrib/pyros/util.py | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/pyomo/contrib/pyros/util.py b/pyomo/contrib/pyros/util.py index a3206b2ccbb..9e93c5773b5 100644 --- a/pyomo/contrib/pyros/util.py +++ b/pyomo/contrib/pyros/util.py @@ -926,8 +926,7 @@ def validate_uncertainty_specification(model, config): `config.second_stage_variables` - dimension of uncertainty set does not equal number of uncertain parameters - - uncertainty set `is_valid()` method does not return - true. + - uncertainty set `validate()` method fails. - nominal parameter realization is not in the uncertainty set. """ check_components_descended_from_model( @@ -964,13 +963,6 @@ def validate_uncertainty_specification(model, config): f"({len(config.uncertain_params)} != {config.uncertainty_set.dim})." ) - # validate uncertainty set - if not config.uncertainty_set.is_valid(config=config): - raise ValueError( - f"Uncertainty set {config.uncertainty_set} is invalid, " - "as it is either empty or unbounded." - ) - # fill-in nominal point as necessary, if not provided. # otherwise, check length matches uncertainty dimension if not config.nominal_uncertain_param_vals: @@ -993,6 +985,9 @@ def validate_uncertainty_specification(model, config): f"{len(config.nominal_uncertain_param_vals)})." ) + # validate uncertainty set + config.uncertainty_set.validate(config=config) + # uncertainty set should contain nominal point nominal_point_in_set = config.uncertainty_set.point_in_set( point=config.nominal_uncertain_param_vals From 8609c3242ebe6b76b186330f89e726dfbe3ff89d Mon Sep 17 00:00:00 2001 From: Jason Yao Date: Mon, 7 Apr 2025 15:37:33 -0400 Subject: [PATCH 03/36] Add setter for CustomUncertaintySet --- pyomo/contrib/pyros/tests/test_uncertainty_sets.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/pyomo/contrib/pyros/tests/test_uncertainty_sets.py b/pyomo/contrib/pyros/tests/test_uncertainty_sets.py index e0e5bb7d137..7e5cf1cd6d4 100644 --- a/pyomo/contrib/pyros/tests/test_uncertainty_sets.py +++ b/pyomo/contrib/pyros/tests/test_uncertainty_sets.py @@ -2429,6 +2429,7 @@ class CustomUncertaintySet(UncertaintySet): def __init__(self, dim): self._dim = dim + self._parameter_bounds = [(-1, 1)] * self.dim @property def geometry(self): @@ -2464,7 +2465,12 @@ def point_in_set(self, point): @property def parameter_bounds(self): - return [(-1, 1)] * self.dim + return self._parameter_bounds + + @parameter_bounds.setter + def parameter_bounds(self, val): + self._parameter_bounds = val + class TestCustomUncertaintySet(unittest.TestCase): From 2e076942bb18185193df293163754b79e032467c Mon Sep 17 00:00:00 2001 From: Jason Yao Date: Mon, 7 Apr 2025 15:38:15 -0400 Subject: [PATCH 04/36] Add tests for is_bounded, is_nonempty --- .../pyros/tests/test_uncertainty_sets.py | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/pyomo/contrib/pyros/tests/test_uncertainty_sets.py b/pyomo/contrib/pyros/tests/test_uncertainty_sets.py index 7e5cf1cd6d4..620281030f4 100644 --- a/pyomo/contrib/pyros/tests/test_uncertainty_sets.py +++ b/pyomo/contrib/pyros/tests/test_uncertainty_sets.py @@ -44,6 +44,9 @@ _setup_standard_uncertainty_set_constraint_block, ) +from pyomo.contrib.pyros.config import pyros_config +import time + import logging logger = logging.getLogger(__name__) @@ -2503,6 +2506,68 @@ def test_compute_parameter_bounds(self): self.assertEqual(custom_set.parameter_bounds, [(-1, 1)] * 2) self.assertEqual(custom_set._compute_parameter_bounds(baron), [(-1, 1)] * 2) + # test default is_bounded + @unittest.skipUnless(baron_available, "BARON is not available") + def test_is_bounded(self): + """ + Test boundedness check computations give expected results. + """ + custom_set = CustomUncertaintySet(dim=2) + CONFIG = pyros_config() + CONFIG.global_solver = global_solver + + # using provided parameter_bounds + start = time.time() + self.assertTrue(custom_set.is_bounded(config=CONFIG), "Set is not bounded") + end = time.time() + time_with_bounds_provided = end - start + + # when parameter_bounds is not available + custom_set.parameter_bounds = None + start = time.time() + self.assertTrue(custom_set.is_bounded(config=CONFIG), "Set is not bounded") + end = time.time() + time_without_bounds_provided = end - start + + # check with parameter_bounds should always take less time than solving 2N + # optimization problems + self.assertLess(time_with_bounds_provided, time_without_bounds_provided, + "Boundedness check with provided parameter_bounds took longer than expected.") + + # when bad bounds are provided + for val_str in ["inf", "nan"]: + bad_bounds = [[1, float(val_str)], [2, 3]] + custom_set.parameter_bounds = bad_bounds + self.assertFalse(custom_set.is_bounded(config=CONFIG), "Set is bounded") + + # test default is_nonempty + @unittest.skipUnless(baron_available, "BARON is not available") + def test_is_nonempty(self): + """ + Test nonemptiness check computations give expected results. + """ + custom_set = CustomUncertaintySet(dim=2) + CONFIG = pyros_config() + CONFIG.global_solver = global_solver + + # constructing a feasibility problem + self.assertTrue(custom_set.is_nonempty(config=CONFIG), "Set is empty") + + # using provided nominal point + CONFIG.nominal_uncertain_param_vals = [0, 0] + self.assertTrue(custom_set.is_nonempty(config=CONFIG), "Set is empty") + + # check when nominal point is not in set + CONFIG.nominal_uncertain_param_vals = [-2, -2] + self.assertFalse(custom_set.is_nonempty(config=CONFIG), "Nominal point is in set") + + # check when feasibility problem fails + CONFIG.nominal_uncertain_param_vals = None + custom_set.parameter_bounds = [[1, 2], [3, 4]] + exc_str = r"Could not successfully solve feasibility problem. .*" + with self.assertRaisesRegex(ValueError, exc_str): + custom_set.is_nonempty(config=CONFIG) + if __name__ == "__main__": unittest.main() From 6bd2d66ce78c0535c1022fd6b959524fabc2c129 Mon Sep 17 00:00:00 2001 From: Jason Yao Date: Mon, 7 Apr 2025 17:16:48 -0400 Subject: [PATCH 05/36] Move BoxSet setter checks to validate --- pyomo/contrib/pyros/uncertainty_sets.py | 27 +++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/pyomo/contrib/pyros/uncertainty_sets.py b/pyomo/contrib/pyros/uncertainty_sets.py index cfb84e32137..7800c439a0d 100644 --- a/pyomo/contrib/pyros/uncertainty_sets.py +++ b/pyomo/contrib/pyros/uncertainty_sets.py @@ -1139,10 +1139,6 @@ def bounds(self, val): bounds_arr = np.array(val) - for lb, ub in bounds_arr: - if lb > ub: - raise ValueError(f"Lower bound {lb} exceeds upper bound {ub}") - # box set dimension is immutable if hasattr(self, "_bounds") and bounds_arr.shape[0] != self.dim: raise ValueError( @@ -1203,6 +1199,29 @@ def set_as_constraint(self, uncertain_params=None, block=None): auxiliary_vars=aux_var_list, ) + def validate(self, config): + """ + Check BoxSet validity. + + Raises + ------ + ValueError + If finiteness and LB<=UB checks fail. + """ + bounds_arr = np.array(self.parameter_bounds) + + # finiteness check + if not np.all(np.isfinite(bounds_arr)): + raise ValueError( + "Not all bounds are finite. " + f"Got bounds:\n {bounds_arr}" + ) + + # check LB <= UB + for lb, ub in bounds_arr: + if lb > ub: + raise ValueError(f"Lower bound {lb} exceeds upper bound {ub}") + class CardinalitySet(UncertaintySet): """ From cfb93993b600fa3add2c012171fa0d65d0ee2322 Mon Sep 17 00:00:00 2001 From: Jason Yao Date: Mon, 7 Apr 2025 17:17:19 -0400 Subject: [PATCH 06/36] Add bounded_and_nonempty_check --- .../contrib/pyros/tests/test_uncertainty_sets.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/pyomo/contrib/pyros/tests/test_uncertainty_sets.py b/pyomo/contrib/pyros/tests/test_uncertainty_sets.py index 620281030f4..ac4a51e492e 100644 --- a/pyomo/contrib/pyros/tests/test_uncertainty_sets.py +++ b/pyomo/contrib/pyros/tests/test_uncertainty_sets.py @@ -73,6 +73,22 @@ baron_version = (0, 0, 0) +def bounded_and_nonempty_check(unc_set): + """ + All uncertainty sets should pass these checks, + regardless of their custom `validate` method. + """ + CONFIG = pyros_config() + CONFIG.global_solver = global_solver + + # check is_bounded + check_bounded = unc_set.is_bounded(config=CONFIG) + # check is_nonempty + check_nonempty = unc_set.is_nonempty(config=CONFIG) + + return check_bounded and check_nonempty + + class TestBoxSet(unittest.TestCase): """ Tests for the BoxSet. From 010047d7ddb7f2330dc43b5714e4f2ccc8524914 Mon Sep 17 00:00:00 2001 From: Jason Yao Date: Mon, 7 Apr 2025 17:18:49 -0400 Subject: [PATCH 07/36] Add test_validate for BoxSet --- .../pyros/tests/test_uncertainty_sets.py | 37 ++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/pyomo/contrib/pyros/tests/test_uncertainty_sets.py b/pyomo/contrib/pyros/tests/test_uncertainty_sets.py index ac4a51e492e..02a00c9d9ca 100644 --- a/pyomo/contrib/pyros/tests/test_uncertainty_sets.py +++ b/pyomo/contrib/pyros/tests/test_uncertainty_sets.py @@ -377,6 +377,42 @@ def test_add_bounds_on_uncertain_parameters(self): self.assertEqual(m.uncertain_param_vars[0].bounds, (1, 2)) self.assertEqual(m.uncertain_param_vars[1].bounds, (3, 4)) + def test_validate(self): + """ + Test validate checks perform as expected. + """ + CONFIG = pyros_config() + CONFIG.global_solver = global_solver + + # construct valid box set + box_set = BoxSet(bounds=[[1., 2.], [3., 4.]]) + + # validate raises no issues on valid set + box_set.validate(config=CONFIG) + + # check when bounds are not finite + box_set.bounds[0][0] = np.nan + exc_str = r"Not all bounds are finite. Got bounds:.*" + with self.assertRaisesRegex(ValueError, exc_str): + box_set.validate(config=CONFIG) + + # check when LB >= UB + box_set.bounds[0][0] = 5 + exc_str = r"Lower bound 5.0 exceeds upper bound 2.0" + with self.assertRaisesRegex(ValueError, exc_str): + box_set.validate(config=CONFIG) + + @unittest.skipUnless(baron_available, "BARON is not available") + def test_bounded_and_nonempty(self): + """ + Test `is_bounded` and `is_nonempty` for a valid box set. + """ + box_set = BoxSet(bounds=[[1., 2.], [3., 4.]]) + self.assertTrue( + bounded_and_nonempty_check(box_set), + "Set is not bounded or not nonempty" + ) + class TestBudgetSet(unittest.TestCase): """ @@ -2491,7 +2527,6 @@ def parameter_bounds(self, val): self._parameter_bounds = val - class TestCustomUncertaintySet(unittest.TestCase): """ Test for a custom uncertainty set subclass. From 13cd9de117c984913702cea17b5b2abde80551bc Mon Sep 17 00:00:00 2001 From: Jason Yao Date: Mon, 7 Apr 2025 17:19:25 -0400 Subject: [PATCH 08/36] Remove tests for BoxSet setters --- .../pyros/tests/test_uncertainty_sets.py | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/pyomo/contrib/pyros/tests/test_uncertainty_sets.py b/pyomo/contrib/pyros/tests/test_uncertainty_sets.py index 02a00c9d9ca..58722ce41f5 100644 --- a/pyomo/contrib/pyros/tests/test_uncertainty_sets.py +++ b/pyomo/contrib/pyros/tests/test_uncertainty_sets.py @@ -125,25 +125,6 @@ def test_error_on_box_set_dim_change(self): with self.assertRaisesRegex(ValueError, exc_str): bset.bounds = [[1, 2], [3, 4], [5, 6]] - def test_error_on_lb_exceeds_ub(self): - """ - Test exception raised when an LB exceeds a UB. - """ - bad_bounds = [[1, 2], [4, 3]] - - exc_str = r"Lower bound 4 exceeds upper bound 3" - - # assert error on construction - with self.assertRaisesRegex(ValueError, exc_str): - BoxSet(bad_bounds) - - # construct a valid box set - bset = BoxSet([[1, 2], [3, 4]]) - - # assert error on update - with self.assertRaisesRegex(ValueError, exc_str): - bset.bounds = bad_bounds - def test_error_on_ragged_bounds_array(self): """ Test ValueError raised on attempting to set BoxSet bounds From 1f9a8f8941fc32e4607efb952ecf652cd33f3d00 Mon Sep 17 00:00:00 2001 From: Jason Yao Date: Thu, 10 Apr 2025 10:32:00 -0400 Subject: [PATCH 09/36] Remove unnecessary parameter_bounds logging --- pyomo/contrib/pyros/uncertainty_sets.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/pyomo/contrib/pyros/uncertainty_sets.py b/pyomo/contrib/pyros/uncertainty_sets.py index 7800c439a0d..b6f0e3c4fc0 100644 --- a/pyomo/contrib/pyros/uncertainty_sets.py +++ b/pyomo/contrib/pyros/uncertainty_sets.py @@ -612,18 +612,10 @@ def is_nonempty(self, config): # construct feasibility problem and solve otherwise set_nonempty = self._solve_feasibility(config.global_solver) - # parameter bounds for logging - param_bounds_arr = self.parameter_bounds - if not param_bounds_arr: - param_bounds_arr = np.array( - self._compute_parameter_bounds(solver=config.global_solver) - ) - # log result if not set_nonempty: config.progress_logger.error( "Nominal point is not within the uncertainty set. " - f"Set parameter bounds: {param_bounds_arr}" f"Got nominal point: {config.nominal_uncertain_param_vals}" ) From a0a92dc4fe2168e25cb6092fcf8aca03107c6b3b Mon Sep 17 00:00:00 2001 From: Jason Yao Date: Thu, 10 Apr 2025 11:26:26 -0400 Subject: [PATCH 10/36] Update BoxSet validate test,bounded/nonempty check --- .../pyros/tests/test_uncertainty_sets.py | 24 ++++++++++--------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/pyomo/contrib/pyros/tests/test_uncertainty_sets.py b/pyomo/contrib/pyros/tests/test_uncertainty_sets.py index 58722ce41f5..ba7909024af 100644 --- a/pyomo/contrib/pyros/tests/test_uncertainty_sets.py +++ b/pyomo/contrib/pyros/tests/test_uncertainty_sets.py @@ -23,6 +23,7 @@ scipy as sp, scipy_available, ) +from pyomo.common.collections import Bunch from pyomo.environ import SolverFactory from pyomo.core.base import ConcreteModel, Param, Var from pyomo.core.expr import RangedExpression @@ -73,7 +74,7 @@ baron_version = (0, 0, 0) -def bounded_and_nonempty_check(unc_set): +def bounded_and_nonempty_check(test, unc_set): """ All uncertainty sets should pass these checks, regardless of their custom `validate` method. @@ -82,11 +83,16 @@ def bounded_and_nonempty_check(unc_set): CONFIG.global_solver = global_solver # check is_bounded - check_bounded = unc_set.is_bounded(config=CONFIG) - # check is_nonempty - check_nonempty = unc_set.is_nonempty(config=CONFIG) + test.assertTrue( + unc_set.is_bounded(config=CONFIG), + "Set is not bounded." + ) - return check_bounded and check_nonempty + # check is_nonempty + test.assertTrue( + unc_set.is_nonempty(config=CONFIG), + "Set is not bounded." + ) class TestBoxSet(unittest.TestCase): @@ -362,8 +368,7 @@ def test_validate(self): """ Test validate checks perform as expected. """ - CONFIG = pyros_config() - CONFIG.global_solver = global_solver + CONFIG = Bunch() # construct valid box set box_set = BoxSet(bounds=[[1., 2.], [3., 4.]]) @@ -389,10 +394,7 @@ def test_bounded_and_nonempty(self): Test `is_bounded` and `is_nonempty` for a valid box set. """ box_set = BoxSet(bounds=[[1., 2.], [3., 4.]]) - self.assertTrue( - bounded_and_nonempty_check(box_set), - "Set is not bounded or not nonempty" - ) + bounded_and_nonempty_check(self, box_set), class TestBudgetSet(unittest.TestCase): From c032dd80d8346aa6f2c176c0412817a261e85456 Mon Sep 17 00:00:00 2001 From: Jason Yao Date: Thu, 10 Apr 2025 17:17:11 -0400 Subject: [PATCH 11/36] Add test for _solve_feasibility --- .../pyros/tests/test_uncertainty_sets.py | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/pyomo/contrib/pyros/tests/test_uncertainty_sets.py b/pyomo/contrib/pyros/tests/test_uncertainty_sets.py index ba7909024af..67b29abca6c 100644 --- a/pyomo/contrib/pyros/tests/test_uncertainty_sets.py +++ b/pyomo/contrib/pyros/tests/test_uncertainty_sets.py @@ -91,7 +91,7 @@ def bounded_and_nonempty_check(test, unc_set): # check is_nonempty test.assertTrue( unc_set.is_nonempty(config=CONFIG), - "Set is not bounded." + "Set is empty." ) @@ -2540,6 +2540,23 @@ def test_compute_parameter_bounds(self): self.assertEqual(custom_set.parameter_bounds, [(-1, 1)] * 2) self.assertEqual(custom_set._compute_parameter_bounds(baron), [(-1, 1)] * 2) + @unittest.skipUnless(baron_available, "BARON is not available") + def test_solve_feasibility(self): + """ + Test uncertainty set feasibility problem gives expected results. + """ + # feasibility problem passes + baron = SolverFactory("baron") + custom_set = CustomUncertaintySet(dim=2) + self.assertTrue(custom_set._solve_feasibility(baron)) + + # feasibility problem fails + custom_set.parameter_bounds = [[1, 2], [3, 4]] + exc_str = r"Could not successfully solve feasibility problem. .*" + with self.assertRaisesRegex(ValueError, exc_str): + custom_set._solve_feasibility(baron) + + # test default is_bounded @unittest.skipUnless(baron_available, "BARON is not available") def test_is_bounded(self): From 8a8b74631449fee6606fca05482d3919b0d1d3fc Mon Sep 17 00:00:00 2001 From: Jason Yao Date: Thu, 10 Apr 2025 17:21:36 -0400 Subject: [PATCH 12/36] Move CardinalitySet setter checks to validate --- pyomo/contrib/pyros/uncertainty_sets.py | 51 ++++++++++++++++++------- 1 file changed, 37 insertions(+), 14 deletions(-) diff --git a/pyomo/contrib/pyros/uncertainty_sets.py b/pyomo/contrib/pyros/uncertainty_sets.py index b6f0e3c4fc0..02e012e166d 100644 --- a/pyomo/contrib/pyros/uncertainty_sets.py +++ b/pyomo/contrib/pyros/uncertainty_sets.py @@ -1310,13 +1310,6 @@ def positive_deviation(self, val): valid_type_desc="a valid numeric type", ) - for dev_val in val: - if dev_val < 0: - raise ValueError( - f"Entry {dev_val} of attribute 'positive_deviation' " - f"is negative value" - ) - val_arr = np.array(val) # dimension of the set is immutable @@ -1349,13 +1342,6 @@ def gamma(self): @gamma.setter def gamma(self, val): validate_arg_type("gamma", val, valid_num_types, "a valid numeric type", False) - if val < 0 or val > self.dim: - raise ValueError( - "Cardinality set attribute " - f"'gamma' must be a real number between 0 and dimension " - f"{self.dim} " - f"(provided value {val})" - ) self._gamma = val @@ -1470,6 +1456,43 @@ def point_in_set(self, point): and np.all(aux_space_pt <= 1) ) + def validate(self, config): + """ + Check CardinalitySet validity. + + Raises + ------ + ValueError + If finiteness, positive deviation, or gamma checks fail. + """ + orig_val = self.origin + pos_dev = self.positive_deviation + gamma = self.gamma + + # finiteness check + if not (np.all(np.isfinite(orig_val)) and np.all(np.isfinite(pos_dev))): + raise ValueError( + "Origin value and/or positive deviation are not finite. " + f"Got origin: {orig_val}, positive deviation: {pos_dev}" + ) + + # check deviation is positive + for dev_val in pos_dev: + if dev_val < 0: + raise ValueError( + f"Entry {dev_val} of attribute 'positive_deviation' " + f"is negative value" + ) + + # check gamma between 0 and n + if gamma < 0 or gamma > self.dim: + raise ValueError( + "Cardinality set attribute " + f"'gamma' must be a real number between 0 and dimension " + f"{self.dim} " + f"(provided value {gamma})" + ) + class PolyhedralSet(UncertaintySet): """ From 867b189a2bf5b4c08fe50e74fc545037e0cf32d7 Mon Sep 17 00:00:00 2001 From: Jason Yao Date: Thu, 10 Apr 2025 17:23:01 -0400 Subject: [PATCH 13/36] Add validation tests for CardinalitySet --- .../pyros/tests/test_uncertainty_sets.py | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/pyomo/contrib/pyros/tests/test_uncertainty_sets.py b/pyomo/contrib/pyros/tests/test_uncertainty_sets.py index 67b29abca6c..9eb4d661589 100644 --- a/pyomo/contrib/pyros/tests/test_uncertainty_sets.py +++ b/pyomo/contrib/pyros/tests/test_uncertainty_sets.py @@ -1604,6 +1604,80 @@ def test_add_bounds_on_uncertain_parameters(self): self.assertEqual(m.uncertain_param_vars[1].bounds, (1, 4)) self.assertEqual(m.uncertain_param_vars[2].bounds, (2, 2)) + def test_validate(self): + """ + Test validate checks perform as expected. + """ + CONFIG = Bunch() + + # construct a valid cardinality set + cardinality_set = CardinalitySet( + origin=[0., 0.], positive_deviation=[1., 1.], gamma=2 + ) + + # validate raises no issues on valid set + cardinality_set.validate(config=CONFIG) + + # check when bounds are not finite + cardinality_set.origin[0] = np.nan + cardinality_set.positive_deviation[0] = np.nan + exc_str = ( + r"Origin value and\/or positive deviation are not finite. " + + r"Got origin: \[nan 0.\], positive deviation: \[nan 1.\]" + ) + with self.assertRaisesRegex(ValueError, exc_str): + cardinality_set.validate(config=CONFIG) + + cardinality_set.origin[0] = np.nan + cardinality_set.positive_deviation[0] = 1 + exc_str = ( + r"Origin value and\/or positive deviation are not finite. " + + r"Got origin: \[nan 0.\], positive deviation: \[1. 1.\]" + ) + with self.assertRaisesRegex(ValueError, exc_str): + cardinality_set.validate(config=CONFIG) + + cardinality_set.origin[0] = 0 + cardinality_set.positive_deviation[0] = np.nan + exc_str = ( + r"Origin value and\/or positive deviation are not finite. " + + r"Got origin: \[0. 0.\], positive deviation: \[nan 1.\]" + ) + with self.assertRaisesRegex(ValueError, exc_str): + cardinality_set.validate(config=CONFIG) + + # check when deviation is negative + cardinality_set.positive_deviation[0] = -2 + exc_str = r"Entry -2.0 of attribute 'positive_deviation' is negative value" + with self.assertRaisesRegex(ValueError, exc_str): + cardinality_set.validate(config=CONFIG) + + # check when gamma is invalid + cardinality_set.positive_deviation[0] = 1 + cardinality_set.gamma = 3 + exc_str = ( + r".*attribute 'gamma' must be a real number " + r"between 0 and dimension 2 \(provided value 3\)" + ) + with self.assertRaisesRegex(ValueError, exc_str): + cardinality_set.validate(config=CONFIG) + + cardinality_set.gamma = -1 + exc_str = ( + r".*attribute 'gamma' must be a real number " + r"between 0 and dimension 2 \(provided value -1\)" + ) + with self.assertRaisesRegex(ValueError, exc_str): + cardinality_set.validate(config=CONFIG) + + @unittest.skipUnless(baron_available, "BARON is not available") + def test_bounded_and_nonempty(self): + """ + Test `is_bounded` and `is_nonempty` for a valid cardinality set. + """ + cardinality_set = CardinalitySet(origin=[0, 0], positive_deviation=[1, 1], gamma=2) + bounded_and_nonempty_check(self, cardinality_set), + class TestDiscreteScenarioSet(unittest.TestCase): """ From 5279b5626896dda8dbb19fda7b74c0f1fdf5ac1e Mon Sep 17 00:00:00 2001 From: Jason Yao Date: Thu, 10 Apr 2025 17:23:34 -0400 Subject: [PATCH 14/36] Remove test for CardinalitySet setters --- .../pyros/tests/test_uncertainty_sets.py | 52 ------------------- 1 file changed, 52 deletions(-) diff --git a/pyomo/contrib/pyros/tests/test_uncertainty_sets.py b/pyomo/contrib/pyros/tests/test_uncertainty_sets.py index 9eb4d661589..31f8cf47a20 100644 --- a/pyomo/contrib/pyros/tests/test_uncertainty_sets.py +++ b/pyomo/contrib/pyros/tests/test_uncertainty_sets.py @@ -1435,58 +1435,6 @@ def test_normal_cardinality_construction_and_update(self): np.testing.assert_allclose(cset.positive_deviation, [3, 0]) np.testing.assert_allclose(cset.gamma, 0.5) - def test_error_on_neg_positive_deviation(self): - """ - Cardinality set positive deviation attribute should - contain nonnegative numerical entries. - - Check ValueError raised if any negative entries provided. - """ - origin = [0, 0] - positive_deviation = [1, -2] # invalid - gamma = 2 - - exc_str = r"Entry -2 of attribute 'positive_deviation' is negative value" - - # assert error on construction - with self.assertRaisesRegex(ValueError, exc_str): - cset = CardinalitySet(origin, positive_deviation, gamma) - - # construct a valid cardinality set - cset = CardinalitySet(origin, [1, 1], gamma) - - # assert error on update - with self.assertRaisesRegex(ValueError, exc_str): - cset.positive_deviation = positive_deviation - - def test_error_on_invalid_gamma(self): - """ - Cardinality set gamma attribute should be a float-like - between 0 and the set dimension. - - Check ValueError raised if gamma attribute is set - to an invalid value. - """ - origin = [0, 0] - positive_deviation = [1, 1] - gamma = 3 # should be invalid - - exc_str = ( - r".*attribute 'gamma' must be a real number " - r"between 0 and dimension 2 \(provided value 3\)" - ) - - # assert error on construction - with self.assertRaisesRegex(ValueError, exc_str): - CardinalitySet(origin, positive_deviation, gamma) - - # construct a valid cardinality set - cset = CardinalitySet(origin, positive_deviation, gamma=2) - - # assert error on update - with self.assertRaisesRegex(ValueError, exc_str): - cset.gamma = gamma - def test_error_on_cardinality_set_dim_change(self): """ Dimension is considered immutable. From dbd135ec365645e7781d11eb18dbb2c34bba9b9e Mon Sep 17 00:00:00 2001 From: Jason Yao Date: Thu, 10 Apr 2025 18:32:55 -0400 Subject: [PATCH 15/36] Update BoxSet ValueError message --- .../pyros/tests/test_uncertainty_sets.py | 22 +++---------------- pyomo/contrib/pyros/uncertainty_sets.py | 2 +- 2 files changed, 4 insertions(+), 20 deletions(-) diff --git a/pyomo/contrib/pyros/tests/test_uncertainty_sets.py b/pyomo/contrib/pyros/tests/test_uncertainty_sets.py index 31f8cf47a20..b6d867ab39a 100644 --- a/pyomo/contrib/pyros/tests/test_uncertainty_sets.py +++ b/pyomo/contrib/pyros/tests/test_uncertainty_sets.py @@ -378,7 +378,7 @@ def test_validate(self): # check when bounds are not finite box_set.bounds[0][0] = np.nan - exc_str = r"Not all bounds are finite. Got bounds:.*" + exc_str = r"Not all bounds are finite. \nGot bounds:.*" with self.assertRaisesRegex(ValueError, exc_str): box_set.validate(config=CONFIG) @@ -1568,29 +1568,13 @@ def test_validate(self): # check when bounds are not finite cardinality_set.origin[0] = np.nan - cardinality_set.positive_deviation[0] = np.nan - exc_str = ( - r"Origin value and\/or positive deviation are not finite. " - + r"Got origin: \[nan 0.\], positive deviation: \[nan 1.\]" - ) - with self.assertRaisesRegex(ValueError, exc_str): - cardinality_set.validate(config=CONFIG) - - cardinality_set.origin[0] = np.nan - cardinality_set.positive_deviation[0] = 1 - exc_str = ( - r"Origin value and\/or positive deviation are not finite. " - + r"Got origin: \[nan 0.\], positive deviation: \[1. 1.\]" - ) + exc_str = r"Origin value and/or positive deviation are not finite. .*" with self.assertRaisesRegex(ValueError, exc_str): cardinality_set.validate(config=CONFIG) cardinality_set.origin[0] = 0 cardinality_set.positive_deviation[0] = np.nan - exc_str = ( - r"Origin value and\/or positive deviation are not finite. " - + r"Got origin: \[0. 0.\], positive deviation: \[nan 1.\]" - ) + exc_str = r"Origin value and/or positive deviation are not finite. .*" with self.assertRaisesRegex(ValueError, exc_str): cardinality_set.validate(config=CONFIG) diff --git a/pyomo/contrib/pyros/uncertainty_sets.py b/pyomo/contrib/pyros/uncertainty_sets.py index 02e012e166d..f81934755c3 100644 --- a/pyomo/contrib/pyros/uncertainty_sets.py +++ b/pyomo/contrib/pyros/uncertainty_sets.py @@ -1206,7 +1206,7 @@ def validate(self, config): if not np.all(np.isfinite(bounds_arr)): raise ValueError( "Not all bounds are finite. " - f"Got bounds:\n {bounds_arr}" + f"\nGot bounds:\n {bounds_arr}" ) # check LB <= UB From 2c5761ccfb8562fb7283c3fbf5312e4f06aeb664 Mon Sep 17 00:00:00 2001 From: Jason Yao Date: Thu, 10 Apr 2025 18:34:26 -0400 Subject: [PATCH 16/36] Move PolyhedralSet setter checks to validate --- pyomo/contrib/pyros/uncertainty_sets.py | 51 ++++++++++++++++++------- 1 file changed, 38 insertions(+), 13 deletions(-) diff --git a/pyomo/contrib/pyros/uncertainty_sets.py b/pyomo/contrib/pyros/uncertainty_sets.py index f81934755c3..3edcec587dd 100644 --- a/pyomo/contrib/pyros/uncertainty_sets.py +++ b/pyomo/contrib/pyros/uncertainty_sets.py @@ -1538,6 +1538,8 @@ def __init__(self, lhs_coefficients_mat, rhs_vec): # This check is only performed at construction. self._validate() + # TODO this has a _validate method... + # seems redundant with new validate method and should be consolidated def _validate(self): """ Check polyhedral set attributes are such that set is nonempty @@ -1621,19 +1623,6 @@ def coefficients_mat(self, val): f"to match shape of attribute 'rhs_vec' " f"(provided {lhs_coeffs_arr.shape[0]} rows)" ) - - # check no column is all zeros. otherwise, set is unbounded - cols_with_all_zeros = np.nonzero( - [np.all(col == 0) for col in lhs_coeffs_arr.T] - )[0] - if cols_with_all_zeros.size > 0: - col_str = ", ".join(str(val) for val in cols_with_all_zeros) - raise ValueError( - "Attempting to set attribute 'coefficients_mat' to value " - f"with all entries zero in columns at indexes: {col_str}. " - "Ensure column has at least one nonzero entry" - ) - self._coefficients_mat = lhs_coeffs_arr @property @@ -1715,6 +1704,42 @@ def set_as_constraint(self, uncertain_params=None, block=None): ) + def validate(self, config): + """ + Check PolyhedralSet validity. + + Raises + ------ + ValueError + If finiteness, full column rank of LHS matrix, is_bounded, + or is_nonempty checks fail. + """ + lhs_coeffs_arr = self.coefficients_mat + rhs_vec_arr = self.rhs_vec + + # finiteness check + if not (np.all(np.isfinite(lhs_coeffs_arr)) and np.all(np.isfinite(rhs_vec_arr))): + raise ValueError( + "LHS coefficient matrix or RHS vector are not finite. " + f"\nGot LHS matrix:\n{lhs_coeffs_arr},\nRHS vector:\n{rhs_vec_arr}" + ) + + # check no column is all zeros. otherwise, set is unbounded + cols_with_all_zeros = np.nonzero( + [np.all(col == 0) for col in lhs_coeffs_arr.T] + )[0] + if cols_with_all_zeros.size > 0: + col_str = ", ".join(str(val) for val in cols_with_all_zeros) + raise ValueError( + "Attempting to set attribute 'coefficients_mat' to value " + f"with all entries zero in columns at indexes: {col_str}. " + "Ensure column has at least one nonzero entry" + ) + + # check boundedness and nonemptiness + super().validate(config) + + class BudgetSet(UncertaintySet): """ A budget set. From 183b54fb36f8dc501110479d83de86a6c542868c Mon Sep 17 00:00:00 2001 From: Jason Yao Date: Thu, 10 Apr 2025 18:35:12 -0400 Subject: [PATCH 17/36] Add validation tests for PolyhedralSet --- .../pyros/tests/test_uncertainty_sets.py | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/pyomo/contrib/pyros/tests/test_uncertainty_sets.py b/pyomo/contrib/pyros/tests/test_uncertainty_sets.py index b6d867ab39a..dbdacdbbb96 100644 --- a/pyomo/contrib/pyros/tests/test_uncertainty_sets.py +++ b/pyomo/contrib/pyros/tests/test_uncertainty_sets.py @@ -2465,6 +2465,49 @@ def test_add_bounds_on_uncertain_parameters(self): self.assertEqual(m.uncertain_param_vars[0].bounds, (1, 2)) self.assertEqual(m.uncertain_param_vars[1].bounds, (-1, 1)) + @unittest.skipUnless(baron_available, "BARON is not available") + def test_validate(self): + """ + Test validate checks perform as expected. + """ + CONFIG = pyros_config() + CONFIG.global_solver = global_solver + + # construct a valid polyhedral set + polyhedral_set = PolyhedralSet( + lhs_coefficients_mat=[[1., 0.], [-1., 1.], [-1., -1.]], + rhs_vec=[2., -1., -1.] + ) + + # validate raises no issues on valid set + polyhedral_set.validate(config=CONFIG) + + # check when bounds are not finite + polyhedral_set.rhs_vec[0] = np.nan + exc_str = r"LHS coefficient matrix or RHS vector are not finite. .*" + with self.assertRaisesRegex(ValueError, exc_str): + polyhedral_set.validate(config=CONFIG) + + polyhedral_set.rhs_vec[0] = 2 + polyhedral_set.coefficients_mat[0][0] = np.nan + exc_str = r"LHS coefficient matrix or RHS vector are not finite. .*" + with self.assertRaisesRegex(ValueError, exc_str): + polyhedral_set.validate(config=CONFIG) + + # check when LHS matrix is not full column rank + polyhedral_set.coefficients_mat = [[0., 0.], [0., 1.], [0., -1.]] + exc_str = r".*all entries zero in columns at indexes: 0.*" + with self.assertRaisesRegex(ValueError, exc_str): + polyhedral_set.validate(config=CONFIG) + + @unittest.skipUnless(baron_available, "BARON is not available") + def test_bounded_and_nonempty(self): + """ + Test `is_bounded` and `is_nonempty` for a valid cardinality set. + """ + cardinality_set = CardinalitySet(origin=[0, 0], positive_deviation=[1, 1], gamma=2) + bounded_and_nonempty_check(self, cardinality_set), + class CustomUncertaintySet(UncertaintySet): """ From b09647551d369c091d1046baf8af9197b667eff3 Mon Sep 17 00:00:00 2001 From: Jason Yao Date: Thu, 10 Apr 2025 18:35:34 -0400 Subject: [PATCH 18/36] Remove tests for PolyhedralSet setters --- .../pyros/tests/test_uncertainty_sets.py | 21 ------------------- 1 file changed, 21 deletions(-) diff --git a/pyomo/contrib/pyros/tests/test_uncertainty_sets.py b/pyomo/contrib/pyros/tests/test_uncertainty_sets.py index dbdacdbbb96..3ac3daab3a0 100644 --- a/pyomo/contrib/pyros/tests/test_uncertainty_sets.py +++ b/pyomo/contrib/pyros/tests/test_uncertainty_sets.py @@ -2340,27 +2340,6 @@ def test_error_on_empty_set(self): with self.assertRaisesRegex(ValueError, exc_str): PolyhedralSet([[1], [-1]], rhs_vec=[1, -3]) - def test_error_on_polyhedral_mat_all_zero_columns(self): - """ - Test ValueError raised if budget membership mat - has a column with all zeros. - """ - invalid_col_mat = [[0, 0, 1], [0, 0, 1], [0, 0, 1]] - rhs_vec = [1, 1, 2] - - exc_str = r".*all entries zero in columns at indexes: 0, 1.*" - - # assert error on construction - with self.assertRaisesRegex(ValueError, exc_str): - PolyhedralSet(invalid_col_mat, rhs_vec) - - # construct a valid budget set - pset = PolyhedralSet([[1, 0, 1], [1, 1, 0], [1, 1, 1]], rhs_vec) - - # assert error on update - with self.assertRaisesRegex(ValueError, exc_str): - pset.coefficients_mat = invalid_col_mat - def test_set_as_constraint(self): """ Test method for setting up constraints works correctly. From 5cf5d6a0e6616a84305529a83862c81337b00f52 Mon Sep 17 00:00:00 2001 From: Jason Yao Date: Thu, 10 Apr 2025 20:46:06 -0400 Subject: [PATCH 19/36] Move BudgetSet setter checks to validate --- pyomo/contrib/pyros/uncertainty_sets.py | 105 +++++++++++++++--------- 1 file changed, 65 insertions(+), 40 deletions(-) diff --git a/pyomo/contrib/pyros/uncertainty_sets.py b/pyomo/contrib/pyros/uncertainty_sets.py index 3edcec587dd..9a80c929f29 100644 --- a/pyomo/contrib/pyros/uncertainty_sets.py +++ b/pyomo/contrib/pyros/uncertainty_sets.py @@ -1875,38 +1875,6 @@ def budget_membership_mat(self, val): f"to match shape of attribute 'budget_rhs_vec' " f"(provided {lhs_coeffs_arr.shape[0]} rows)" ) - - # ensure all entries are 0-1 values - uniq_entries = np.unique(lhs_coeffs_arr) - non_bool_entries = uniq_entries[(uniq_entries != 0) & (uniq_entries != 1)] - if non_bool_entries.size > 0: - raise ValueError( - "Attempting to set attribute `budget_membership_mat` to value " - "containing entries that are not 0-1 values " - f"(example: {non_bool_entries[0]}). " - "Ensure all entries are of value 0 or 1" - ) - - # check no row is all zeros - rows_with_zero_sums = np.nonzero(lhs_coeffs_arr.sum(axis=1) == 0)[0] - if rows_with_zero_sums.size > 0: - row_str = ", ".join(str(val) for val in rows_with_zero_sums) - raise ValueError( - "Attempting to set attribute `budget_membership_mat` to value " - f"with all entries zero in rows at indexes: {row_str}. " - "Ensure each row and column has at least one nonzero entry" - ) - - # check no column is all zeros - cols_with_zero_sums = np.nonzero(lhs_coeffs_arr.sum(axis=0) == 0)[0] - if cols_with_zero_sums.size > 0: - col_str = ", ".join(str(val) for val in cols_with_zero_sums) - raise ValueError( - "Attempting to set attribute `budget_membership_mat` to value " - f"with all entries zero in columns at indexes: {col_str}. " - "Ensure each row and column has at least one nonzero entry" - ) - # matrix is valid; update self._budget_membership_mat = lhs_coeffs_arr @@ -1942,14 +1910,6 @@ def budget_rhs_vec(self, val): f"(provided {rhs_vec_arr.size} entries)" ) - # ensure all entries are nonnegative - for entry in rhs_vec_arr: - if entry < 0: - raise ValueError( - f"Entry {entry} of attribute 'budget_rhs_vec' is " - "negative. Ensure all entries are nonnegative" - ) - self._budget_rhs_vec = rhs_vec_arr @property @@ -2023,6 +1983,71 @@ def parameter_bounds(self): def set_as_constraint(self, **kwargs): return PolyhedralSet.set_as_constraint(self, **kwargs) + def validate(self, config): + """ + Check BudgetSet validity. + + Raises + ------ + ValueError + If finiteness, full 0 column or row of LHS matrix, + or positive RHS vector checks fail. + """ + lhs_coeffs_arr = self.budget_membership_mat + rhs_vec_arr = self.budget_rhs_vec + orig_val = self.origin + + # finiteness check + if not ( + np.all(np.isfinite(lhs_coeffs_arr)) + and np.all(np.isfinite(rhs_vec_arr)) + and np.all(np.isfinite(orig_val)) + ): + raise ValueError( + "Origin, LHS coefficient matrix or RHS vector are not finite. " + f"\nGot origin:\n{orig_val},\nLHS matrix:\n{lhs_coeffs_arr},\nRHS vector:\n{rhs_vec_arr}" + ) + + # check no row, col, are all zeros and all values are 0-1. + # ensure all entries are 0-1 values + uniq_entries = np.unique(lhs_coeffs_arr) + non_bool_entries = uniq_entries[(uniq_entries != 0) & (uniq_entries != 1)] + if non_bool_entries.size > 0: + raise ValueError( + "Attempting to set attribute `budget_membership_mat` to value " + "containing entries that are not 0-1 values " + f"(example: {non_bool_entries[0]}). " + "Ensure all entries are of value 0 or 1" + ) + + # check no row is all zeros + rows_with_zero_sums = np.nonzero(lhs_coeffs_arr.sum(axis=1) == 0)[0] + if rows_with_zero_sums.size > 0: + row_str = ", ".join(str(val) for val in rows_with_zero_sums) + raise ValueError( + "Attempting to set attribute `budget_membership_mat` to value " + f"with all entries zero in rows at indexes: {row_str}. " + "Ensure each row and column has at least one nonzero entry" + ) + + # check no column is all zeros + cols_with_zero_sums = np.nonzero(lhs_coeffs_arr.sum(axis=0) == 0)[0] + if cols_with_zero_sums.size > 0: + col_str = ", ".join(str(val) for val in cols_with_zero_sums) + raise ValueError( + "Attempting to set attribute `budget_membership_mat` to value " + f"with all entries zero in columns at indexes: {col_str}. " + "Ensure each row and column has at least one nonzero entry" + ) + + # ensure all rhs entries are nonnegative + for entry in rhs_vec_arr: + if entry < 0: + raise ValueError( + f"Entry {entry} of attribute 'budget_rhs_vec' is " + "negative. Ensure all entries are nonnegative" + ) + class FactorModelSet(UncertaintySet): """ From cbd32eba425fc901f2ad51133889655709424532 Mon Sep 17 00:00:00 2001 From: Jason Yao Date: Thu, 10 Apr 2025 20:46:39 -0400 Subject: [PATCH 20/36] Add validation tests for BudgetSet --- .../pyros/tests/test_uncertainty_sets.py | 79 ++++++++++++++++++- 1 file changed, 77 insertions(+), 2 deletions(-) diff --git a/pyomo/contrib/pyros/tests/test_uncertainty_sets.py b/pyomo/contrib/pyros/tests/test_uncertainty_sets.py index 3ac3daab3a0..39ebec55fe1 100644 --- a/pyomo/contrib/pyros/tests/test_uncertainty_sets.py +++ b/pyomo/contrib/pyros/tests/test_uncertainty_sets.py @@ -683,6 +683,78 @@ def test_add_bounds_on_uncertain_parameters(self): self.assertEqual(m.v[0].bounds, (1, 3)) self.assertEqual(m.v[1].bounds, (3, 5)) + def test_validate(self): + """ + Test validate checks perform as expected. + """ + CONFIG = Bunch() + + # construct a valid budget set + budget_mat = [[1., 0., 1.], [0., 1., 0.]] + budget_rhs_vec = [1., 3.] + budget_set = BudgetSet(budget_mat, budget_rhs_vec) + + # validate raises no issues on valid set + budget_set.validate(config=CONFIG) + + # check when bounds are not finite + budget_set.origin[0] = np.nan + exc_str = r"Origin, LHS coefficient matrix or RHS vector are not finite. .*" + with self.assertRaisesRegex(ValueError, exc_str): + budget_set.validate(config=CONFIG) + budget_set.origin[0] = 0 + + budget_set.budget_rhs_vec[0] = np.nan + exc_str = r"Origin, LHS coefficient matrix or RHS vector are not finite. .*" + with self.assertRaisesRegex(ValueError, exc_str): + budget_set.validate(config=CONFIG) + budget_set.budget_rhs_vec[0] = 1 + + budget_set.budget_membership_mat[0][0] = np.nan + exc_str = r"LHS coefficient matrix or RHS vector are not finite. .*" + with self.assertRaisesRegex(ValueError, exc_str): + budget_set.validate(config=CONFIG) + budget_set.budget_membership_mat[0][0] = 1 + + # check when rhs has negative element + budget_set.budget_rhs_vec = [1, -1] + exc_str = r"Entry -1 of.*'budget_rhs_vec' is negative*" + with self.assertRaisesRegex(ValueError, exc_str): + budget_set.validate(config=CONFIG) + budget_set.budget_rhs_vec = budget_rhs_vec + + # check when not all lhs entries are 0-1 + budget_set.budget_membership_mat = [[1, 0, 1], [1, 1, 0.1]] + exc_str = r"Attempting.*entries.*not 0-1 values \(example: 0.1\).*" + with self.assertRaisesRegex(ValueError, exc_str): + budget_set.validate(config=CONFIG) + budget_set.budget_membership_mat = budget_mat + + # check when row has all zeros + invalid_row_mat = [[0, 0, 0], [1, 1, 1], [0, 0, 0]] + budget_rhs_vec = [1, 1, 2] + budget_set = BudgetSet(invalid_row_mat, budget_rhs_vec) + exc_str = r".*all entries zero in rows at indexes: 0, 2.*" + with self.assertRaisesRegex(ValueError, exc_str): + budget_set.validate(config=CONFIG) + + # check when column has all zeros + budget_set.budget_membership_mat = [[0, 0, 1], [0, 0, 1], [0, 0, 1]] + budget_set.budget_rhs_vec = [1, 1, 2] + exc_str = r".*all entries zero in columns at indexes: 0, 1.*" + with self.assertRaisesRegex(ValueError, exc_str): + budget_set.validate(config=CONFIG) + + @unittest.skipUnless(baron_available, "BARON is not available") + def test_bounded_and_nonempty(self): + """ + Test `is_bounded` and `is_nonempty` for a valid cardinality set. + """ + budget_mat = [[1., 0., 1.], [0., 1., 0.]] + budget_rhs_vec = [1., 3.] + budget_set = BudgetSet(budget_mat, budget_rhs_vec) + bounded_and_nonempty_check(self, budget_set), + class TestFactorModelSet(unittest.TestCase): """ @@ -2484,8 +2556,11 @@ def test_bounded_and_nonempty(self): """ Test `is_bounded` and `is_nonempty` for a valid cardinality set. """ - cardinality_set = CardinalitySet(origin=[0, 0], positive_deviation=[1, 1], gamma=2) - bounded_and_nonempty_check(self, cardinality_set), + polyhedral_set = PolyhedralSet( + lhs_coefficients_mat=[[1., 0.], [-1., 1.], [-1., -1.]], + rhs_vec=[2., -1., -1.] + ) + bounded_and_nonempty_check(self, polyhedral_set), class CustomUncertaintySet(UncertaintySet): From 3308cde353150db45271909be8a3b511b62530f1 Mon Sep 17 00:00:00 2001 From: Jason Yao Date: Thu, 10 Apr 2025 20:48:08 -0400 Subject: [PATCH 21/36] Remove tests for BudgetSet setters --- .../pyros/tests/test_uncertainty_sets.py | 84 ------------------- 1 file changed, 84 deletions(-) diff --git a/pyomo/contrib/pyros/tests/test_uncertainty_sets.py b/pyomo/contrib/pyros/tests/test_uncertainty_sets.py index 39ebec55fe1..70549800aac 100644 --- a/pyomo/contrib/pyros/tests/test_uncertainty_sets.py +++ b/pyomo/contrib/pyros/tests/test_uncertainty_sets.py @@ -486,90 +486,6 @@ def test_error_on_budget_member_mat_row_change(self): with self.assertRaisesRegex(ValueError, exc_str): bu_set.budget_rhs_vec = [1] - def test_error_on_neg_budget_rhs_vec_entry(self): - """ - Test ValueError raised if budget RHS vec has entry - with negative value entry. - """ - budget_mat = [[1, 0, 1], [1, 1, 0]] - neg_val_rhs_vec = [1, -1] - - exc_str = r"Entry -1 of.*'budget_rhs_vec' is negative*" - - # assert error on construction - with self.assertRaisesRegex(ValueError, exc_str): - BudgetSet(budget_mat, neg_val_rhs_vec) - - # construct a valid budget set - buset = BudgetSet(budget_mat, [1, 1]) - - # assert error on update - with self.assertRaisesRegex(ValueError, exc_str): - buset.budget_rhs_vec = neg_val_rhs_vec - - def test_error_on_non_bool_budget_mat_entry(self): - """ - Test ValueError raised if budget membership mat has - entry which is not a 0-1 value. - """ - invalid_budget_mat = [[1, 0, 1], [1, 1, 0.1]] - budget_rhs_vec = [1, 1] - - exc_str = r"Attempting.*entries.*not 0-1 values \(example: 0.1\).*" - - # assert error on construction - with self.assertRaisesRegex(ValueError, exc_str): - BudgetSet(invalid_budget_mat, budget_rhs_vec) - - # construct a valid budget set - buset = BudgetSet([[1, 0, 1], [1, 1, 0]], budget_rhs_vec) - - # assert error on update - with self.assertRaisesRegex(ValueError, exc_str): - buset.budget_membership_mat = invalid_budget_mat - - def test_error_on_budget_mat_all_zero_rows(self): - """ - Test ValueError raised if budget membership mat - has a row with all zeros. - """ - invalid_row_mat = [[0, 0, 0], [1, 1, 1], [0, 0, 0]] - budget_rhs_vec = [1, 1, 2] - - exc_str = r".*all entries zero in rows at indexes: 0, 2.*" - - # assert error on construction - with self.assertRaisesRegex(ValueError, exc_str): - BudgetSet(invalid_row_mat, budget_rhs_vec) - - # construct a valid budget set - buset = BudgetSet([[1, 0, 1], [1, 1, 0], [1, 1, 1]], budget_rhs_vec) - - # assert error on update - with self.assertRaisesRegex(ValueError, exc_str): - buset.budget_membership_mat = invalid_row_mat - - def test_error_on_budget_mat_all_zero_columns(self): - """ - Test ValueError raised if budget membership mat - has a column with all zeros. - """ - invalid_col_mat = [[0, 0, 1], [0, 0, 1], [0, 0, 1]] - budget_rhs_vec = [1, 1, 2] - - exc_str = r".*all entries zero in columns at indexes: 0, 1.*" - - # assert error on construction - with self.assertRaisesRegex(ValueError, exc_str): - BudgetSet(invalid_col_mat, budget_rhs_vec) - - # construct a valid budget set - buset = BudgetSet([[1, 0, 1], [1, 1, 0], [1, 1, 1]], budget_rhs_vec) - - # assert error on update - with self.assertRaisesRegex(ValueError, exc_str): - buset.budget_membership_mat = invalid_col_mat - @unittest.skipUnless(baron_available, "BARON is not available") def test_compute_parameter_bounds(self): """ From 53db9d357ba2040b497c6ef067bd0f311233a119 Mon Sep 17 00:00:00 2001 From: Jason Yao Date: Thu, 10 Apr 2025 21:18:50 -0400 Subject: [PATCH 22/36] Move FactorModelSet setter checks to validate --- pyomo/contrib/pyros/uncertainty_sets.py | 55 ++++++++++++++++++------- 1 file changed, 39 insertions(+), 16 deletions(-) diff --git a/pyomo/contrib/pyros/uncertainty_sets.py b/pyomo/contrib/pyros/uncertainty_sets.py index 9a80c929f29..82bed09b5ea 100644 --- a/pyomo/contrib/pyros/uncertainty_sets.py +++ b/pyomo/contrib/pyros/uncertainty_sets.py @@ -2208,16 +2208,6 @@ def psi_mat(self, val): f"(provided shape {psi_mat_arr.shape})" ) - psi_mat_rank = np.linalg.matrix_rank(psi_mat_arr) - is_full_column_rank = psi_mat_rank == self.number_of_factors - if not is_full_column_rank: - raise ValueError( - "Attribute 'psi_mat' should be full column rank. " - f"(Got a matrix of shape {psi_mat_arr.shape} and rank {psi_mat_rank}.) " - "Ensure `psi_mat` does not have more columns than rows, " - "and the columns of `psi_mat` are linearly independent." - ) - self._psi_mat = psi_mat_arr @property @@ -2237,12 +2227,6 @@ def beta(self): @beta.setter def beta(self, val): - if val > 1 or val < 0: - raise ValueError( - "Beta parameter must be a real number between 0 " - f"and 1 inclusive (provided value {val})" - ) - self._beta = val @property @@ -2393,6 +2377,45 @@ def point_in_set(self, point): np.abs(aux_space_pt) <= 1 + tol ) + def validate(self, config): + """ + Check FactorModelSet validity. + + Raises + ------ + ValueError + If finiteness full column rank of Psi matrix, or + beta between 0 and 1 checks fail. + """ + orig_val = self.origin + psi_mat_arr = self.psi_mat + beta = self.beta + + # finiteness check + if not np.all(np.isfinite(orig_val)): + raise ValueError( + "Origin is not finite. " + f"Got origin: {orig_val}" + ) + + # check psi is full column rank + psi_mat_rank = np.linalg.matrix_rank(psi_mat_arr) + check_full_column_rank = psi_mat_rank == self.number_of_factors + if not check_full_column_rank: + raise ValueError( + "Attribute 'psi_mat' should be full column rank. " + f"(Got a matrix of shape {psi_mat_arr.shape} and rank {psi_mat_rank}.) " + "Ensure `psi_mat` does not have more columns than rows, " + "and the columns of `psi_mat` are linearly independent." + ) + + # check beta is between 0 and 1 + if beta > 1 or beta < 0: + raise ValueError( + "Beta parameter must be a real number between 0 " + f"and 1 inclusive (provided value {beta})" + ) + class AxisAlignedEllipsoidalSet(UncertaintySet): """ From 72a1c1809ea31e5a36bafc5cb3aace3cd47894d8 Mon Sep 17 00:00:00 2001 From: Jason Yao Date: Thu, 10 Apr 2025 21:20:37 -0400 Subject: [PATCH 23/36] Add validation tests for FactorModelSet --- .../pyros/tests/test_uncertainty_sets.py | 78 +++++++++++++++++-- 1 file changed, 73 insertions(+), 5 deletions(-) diff --git a/pyomo/contrib/pyros/tests/test_uncertainty_sets.py b/pyomo/contrib/pyros/tests/test_uncertainty_sets.py index 70549800aac..9e131323708 100644 --- a/pyomo/contrib/pyros/tests/test_uncertainty_sets.py +++ b/pyomo/contrib/pyros/tests/test_uncertainty_sets.py @@ -376,7 +376,7 @@ def test_validate(self): # validate raises no issues on valid set box_set.validate(config=CONFIG) - # check when bounds are not finite + # check when values are not finite box_set.bounds[0][0] = np.nan exc_str = r"Not all bounds are finite. \nGot bounds:.*" with self.assertRaisesRegex(ValueError, exc_str): @@ -613,7 +613,7 @@ def test_validate(self): # validate raises no issues on valid set budget_set.validate(config=CONFIG) - # check when bounds are not finite + # check when values are not finite budget_set.origin[0] = np.nan exc_str = r"Origin, LHS coefficient matrix or RHS vector are not finite. .*" with self.assertRaisesRegex(ValueError, exc_str): @@ -644,7 +644,6 @@ def test_validate(self): exc_str = r"Attempting.*entries.*not 0-1 values \(example: 0.1\).*" with self.assertRaisesRegex(ValueError, exc_str): budget_set.validate(config=CONFIG) - budget_set.budget_membership_mat = budget_mat # check when row has all zeros invalid_row_mat = [[0, 0, 0], [1, 1, 1], [0, 0, 0]] @@ -1034,6 +1033,75 @@ def test_add_bounds_on_uncertain_parameters(self): self.assertEqual(m.uncertain_param_vars[2].bounds, (-13.0, 17.0)) self.assertEqual(m.uncertain_param_vars[3].bounds, (-12.0, 18.0)) + def test_validate(self): + """ + Test validate checks perform as expected. + """ + CONFIG = Bunch() + + # construct a valid budget set + origin = [0., 0., 0.] + number_of_factors = 2 + psi_mat = [[1, 0], [0, 1], [1, 1]] + beta = 0.5 + factor_set = FactorModelSet(origin, number_of_factors, psi_mat, beta) + + # validate raises no issues on valid set + factor_set.validate(config=CONFIG) + + # check when values are not finite + factor_set.origin[0] = np.nan + exc_str = r"Origin is not finite. .*" + with self.assertRaisesRegex(ValueError, exc_str): + factor_set.validate(config=CONFIG) + factor_set.origin[0] = 0 + + # check when beta is invalid + neg_beta = -0.5 + big_beta = 1.5 + neg_exc_str = ( + r".*must be a real number between 0 and 1.*\(provided value -0.5\)" + ) + big_exc_str = r".*must be a real number between 0 and 1.*\(provided value 1.5\)" + factor_set.beta = neg_beta + with self.assertRaisesRegex(ValueError, neg_exc_str): + factor_set.validate(config=CONFIG) + factor_set.beta = big_beta + with self.assertRaisesRegex(ValueError, big_exc_str): + factor_set.validate(config=CONFIG) + + # check when psi matrix is rank defficient + with self.assertRaisesRegex(ValueError, r"full column rank.*\(2, 3\)"): + # more columns than rows + factor_set = FactorModelSet( + origin=[0, 0], + number_of_factors=3, + psi_mat=[[1, -1, 1], [1, 0.1, 1]], + beta=1 / 6, + ) + factor_set.validate(config=CONFIG) + with self.assertRaisesRegex(ValueError, r"full column rank.*\(2, 2\)"): + # linearly dependent columns + factor_set = FactorModelSet( + origin=[0, 0], + number_of_factors=2, + psi_mat=[[1, -1], [1, -1]], + beta=1 / 6, + ) + factor_set.validate(config=CONFIG) + + @unittest.skipUnless(baron_available, "BARON is not available") + def test_bounded_and_nonempty(self): + """ + Test `is_bounded` and `is_nonempty` for a valid cardinality set. + """ + origin = [0., 0., 0.] + number_of_factors = 2 + psi_mat = [[1, 0], [0, 1], [1, 1]] + beta = 0.5 + factor_set = FactorModelSet(origin, number_of_factors, psi_mat, beta) + bounded_and_nonempty_check(self, factor_set), + class TestIntersectionSet(unittest.TestCase): """ @@ -1554,7 +1622,7 @@ def test_validate(self): # validate raises no issues on valid set cardinality_set.validate(config=CONFIG) - # check when bounds are not finite + # check when values are not finite cardinality_set.origin[0] = np.nan exc_str = r"Origin value and/or positive deviation are not finite. .*" with self.assertRaisesRegex(ValueError, exc_str): @@ -2449,7 +2517,7 @@ def test_validate(self): # validate raises no issues on valid set polyhedral_set.validate(config=CONFIG) - # check when bounds are not finite + # check when values are not finite polyhedral_set.rhs_vec[0] = np.nan exc_str = r"LHS coefficient matrix or RHS vector are not finite. .*" with self.assertRaisesRegex(ValueError, exc_str): From 97fe35eb6bbc98f90e32d190f5dce98929de9f75 Mon Sep 17 00:00:00 2001 From: Jason Yao Date: Thu, 10 Apr 2025 21:20:59 -0400 Subject: [PATCH 24/36] Remove tests for FactorModelSet setters --- .../pyros/tests/test_uncertainty_sets.py | 52 ------------------- 1 file changed, 52 deletions(-) diff --git a/pyomo/contrib/pyros/tests/test_uncertainty_sets.py b/pyomo/contrib/pyros/tests/test_uncertainty_sets.py index 9e131323708..e6f3aa18802 100644 --- a/pyomo/contrib/pyros/tests/test_uncertainty_sets.py +++ b/pyomo/contrib/pyros/tests/test_uncertainty_sets.py @@ -753,58 +753,6 @@ def test_error_on_invalid_number_of_factors(self): with self.assertRaisesRegex(AttributeError, exc_str): fset.number_of_factors = 3 - def test_error_on_invalid_beta(self): - """ - Test ValueError raised if beta is invalid (exceeds 1 or - is negative) - """ - origin = [0, 0, 0] - number_of_factors = 2 - psi_mat = [[1, 0], [0, 1], [1, 1]] - neg_beta = -0.5 - big_beta = 1.5 - - # assert error on construction - neg_exc_str = ( - r".*must be a real number between 0 and 1.*\(provided value -0.5\)" - ) - big_exc_str = r".*must be a real number between 0 and 1.*\(provided value 1.5\)" - with self.assertRaisesRegex(ValueError, neg_exc_str): - FactorModelSet(origin, number_of_factors, psi_mat, neg_beta) - with self.assertRaisesRegex(ValueError, big_exc_str): - FactorModelSet(origin, number_of_factors, psi_mat, big_beta) - - # create a valid factor model set - fset = FactorModelSet(origin, number_of_factors, psi_mat, 1) - - # assert error on update - with self.assertRaisesRegex(ValueError, neg_exc_str): - fset.beta = neg_beta - with self.assertRaisesRegex(ValueError, big_exc_str): - fset.beta = big_beta - - def test_error_on_rank_deficient_psi_mat(self): - """ - Test exception raised if factor loading matrix `psi_mat` - is rank-deficient. - """ - with self.assertRaisesRegex(ValueError, r"full column rank.*\(2, 3\)"): - # more columns than rows - FactorModelSet( - origin=[0, 0], - number_of_factors=3, - psi_mat=[[1, -1, 1], [1, 0.1, 1]], - beta=1 / 6, - ) - with self.assertRaisesRegex(ValueError, r"full column rank.*\(2, 2\)"): - # linearly dependent columns - FactorModelSet( - origin=[0, 0], - number_of_factors=2, - psi_mat=[[1, -1], [1, -1]], - beta=1 / 6, - ) - @parameterized.expand( [ # map beta to expected parameter bounds From 48553567e99c744d513c6527c435d52f08eaf183 Mon Sep 17 00:00:00 2001 From: Jason Yao Date: Thu, 10 Apr 2025 21:33:56 -0400 Subject: [PATCH 25/36] Move AxisAlignedEllipsoidalSet checks to validate --- pyomo/contrib/pyros/uncertainty_sets.py | 38 +++++++++++++++++++------ 1 file changed, 30 insertions(+), 8 deletions(-) diff --git a/pyomo/contrib/pyros/uncertainty_sets.py b/pyomo/contrib/pyros/uncertainty_sets.py index 82bed09b5ea..e5f2b8a68c7 100644 --- a/pyomo/contrib/pyros/uncertainty_sets.py +++ b/pyomo/contrib/pyros/uncertainty_sets.py @@ -2516,14 +2516,6 @@ def half_lengths(self, val): f"to value of dimension {val_arr.size}" ) - # ensure half-lengths are non-negative - for half_len in val_arr: - if half_len < 0: - raise ValueError( - f"Entry {half_len} of 'half_lengths' " - "is negative. All half-lengths must be nonnegative" - ) - self._half_lengths = val_arr @property @@ -2593,6 +2585,36 @@ def set_as_constraint(self, uncertain_params=None, block=None): auxiliary_vars=aux_var_list, ) + def validate(self, config): + """ + Check AxisAlignedEllipsoidalSet validity. + + Raises + ------ + ValueError + If finiteness or positive half-length checks fail. + """ + ctr = self.center + half_lengths = self.half_lengths + + # finiteness check + if not ( + np.all(np.isfinite(ctr)) + and np.all(np.isfinite(half_lengths)) + ): + raise ValueError( + "Center or half-lengths are not finite. " + f"Got center: {ctr}, half-lengths: {half_lengths}" + ) + + # ensure half-lengths are non-negative + for half_len in half_lengths: + if half_len < 0: + raise ValueError( + f"Entry {half_len} of 'half_lengths' " + "is negative. All half-lengths must be nonnegative" + ) + class EllipsoidalSet(UncertaintySet): """ From 19b7d3fbfc22a1cdcbc00afa94b958eefcbf7687 Mon Sep 17 00:00:00 2001 From: Jason Yao Date: Thu, 10 Apr 2025 21:34:26 -0400 Subject: [PATCH 26/36] Add validation tests for AxisAlignedEllipsoidalSet --- .../pyros/tests/test_uncertainty_sets.py | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/pyomo/contrib/pyros/tests/test_uncertainty_sets.py b/pyomo/contrib/pyros/tests/test_uncertainty_sets.py index e6f3aa18802..248e1347ba4 100644 --- a/pyomo/contrib/pyros/tests/test_uncertainty_sets.py +++ b/pyomo/contrib/pyros/tests/test_uncertainty_sets.py @@ -1886,6 +1886,49 @@ def test_add_bounds_on_uncertain_parameters(self): self.assertEqual(m.uncertain_param_vars[1].bounds, (-0.5, 3.5)) self.assertEqual(m.uncertain_param_vars[2].bounds, (1, 1)) + def test_validate(self): + """ + Test validate checks perform as expected. + """ + CONFIG = Bunch() + + # construct a valid budget set + center = [0., 0.] + half_lengths = [1., 3.] + a_ellipsoid_set = AxisAlignedEllipsoidalSet(center, half_lengths) + + # validate raises no issues on valid set + a_ellipsoid_set.validate(config=CONFIG) + + # check when values are not finite + a_ellipsoid_set.center[0] = np.nan + exc_str = r"Center or half-lengths are not finite. .*" + with self.assertRaisesRegex(ValueError, exc_str): + a_ellipsoid_set.validate(config=CONFIG) + a_ellipsoid_set.center[0] = 0 + + a_ellipsoid_set.half_lengths[0] = np.nan + exc_str = r"Center or half-lengths are not finite. .*" + with self.assertRaisesRegex(ValueError, exc_str): + a_ellipsoid_set.validate(config=CONFIG) + a_ellipsoid_set.half_lengths[0] = 1 + + # check when half lengths are negative + a_ellipsoid_set.half_lengths = [1, -1] + exc_str = r"Entry -1 of.*'half_lengths' is negative.*" + with self.assertRaisesRegex(ValueError, exc_str): + a_ellipsoid_set.validate(config=CONFIG) + + @unittest.skipUnless(baron_available, "BARON is not available") + def test_bounded_and_nonempty(self): + """ + Test `is_bounded` and `is_nonempty` for a valid cardinality set. + """ + center = [0., 0.] + half_lengths = [1., 3.] + a_ellipsoid_set = AxisAlignedEllipsoidalSet(center, half_lengths) + bounded_and_nonempty_check(self, a_ellipsoid_set), + class TestEllipsoidalSet(unittest.TestCase): """ From b7dd69c136ac730e112139b493a8a161174e61b0 Mon Sep 17 00:00:00 2001 From: Jason Yao Date: Thu, 10 Apr 2025 21:34:44 -0400 Subject: [PATCH 27/36] Remove tests for AxisAlignedEllipsoidalSet setters --- .../pyros/tests/test_uncertainty_sets.py | 20 ------------------- 1 file changed, 20 deletions(-) diff --git a/pyomo/contrib/pyros/tests/test_uncertainty_sets.py b/pyomo/contrib/pyros/tests/test_uncertainty_sets.py index 248e1347ba4..e901197cc06 100644 --- a/pyomo/contrib/pyros/tests/test_uncertainty_sets.py +++ b/pyomo/contrib/pyros/tests/test_uncertainty_sets.py @@ -1779,26 +1779,6 @@ def test_error_on_axis_aligned_dim_change(self): with self.assertRaisesRegex(ValueError, exc_str): aset.half_lengths = [0, 0, 1] - def test_error_on_negative_axis_aligned_half_lengths(self): - """ - Test ValueError if half lengths for AxisAlignedEllipsoidalSet - contains a negative value. - """ - center = [1, 1] - invalid_half_lengths = [1, -1] - exc_str = r"Entry -1 of.*'half_lengths' is negative.*" - - # assert error on construction - with self.assertRaisesRegex(ValueError, exc_str): - AxisAlignedEllipsoidalSet(center, invalid_half_lengths) - - # construct a valid axis-aligned ellipsoidal set - aset = AxisAlignedEllipsoidalSet(center, [1, 0]) - - # assert error on update - with self.assertRaisesRegex(ValueError, exc_str): - aset.half_lengths = invalid_half_lengths - def test_set_as_constraint(self): """ Test method for setting up constraints works correctly. From 8e603f9c1b3327fc4ff47fe619357143fbf3dfa7 Mon Sep 17 00:00:00 2001 From: Jason Yao Date: Thu, 10 Apr 2025 21:54:54 -0400 Subject: [PATCH 28/36] Move EllipsoidalSet setter checks to validate --- pyomo/contrib/pyros/uncertainty_sets.py | 39 ++++++++++++++++++++----- 1 file changed, 32 insertions(+), 7 deletions(-) diff --git a/pyomo/contrib/pyros/uncertainty_sets.py b/pyomo/contrib/pyros/uncertainty_sets.py index e5f2b8a68c7..6e357298660 100644 --- a/pyomo/contrib/pyros/uncertainty_sets.py +++ b/pyomo/contrib/pyros/uncertainty_sets.py @@ -2827,7 +2827,6 @@ def shape_matrix(self, val): f"(provided matrix with shape {shape_mat_arr.shape})" ) - self._verify_positive_definite(shape_mat_arr) self._shape_matrix = shape_mat_arr @property @@ -2842,12 +2841,6 @@ def scale(self): @scale.setter def scale(self, val): validate_arg_type("scale", val, valid_num_types, "a valid numeric type", False) - if val < 0: - raise ValueError( - f"{type(self).__name__} attribute " - f"'scale' must be a non-negative real " - f"(provided value {val})" - ) self._scale = val self._gaussian_conf_lvl = sp.stats.chi2.cdf(x=val, df=self.dim) @@ -2967,6 +2960,38 @@ def set_as_constraint(self, uncertain_params=None, block=None): auxiliary_vars=aux_var_list, ) + def validate(self, config): + """ + Check EllipsoidalSet validity. + + Raises + ------ + ValueError + If finiteness, positive semi-definite, or + positive scale checks fail. + """ + ctr = self.center + shape_mat_arr = self.shape_matrix + scale = self.scale + + # finiteness check + if not np.all(np.isfinite(ctr)): + raise ValueError( + "Center is not finite. " + f"Got center: {ctr}" + ) + + # check shape matrix is positive semidefinite + self._verify_positive_definite(shape_mat_arr) + + # ensure scale is non-negative + if scale < 0: + raise ValueError( + f"{type(self).__name__} attribute " + f"'scale' must be a non-negative real " + f"(provided value {scale})" + ) + class DiscreteScenarioSet(UncertaintySet): """ From 08626e3b999ba84efff2cf639446d247a50a236d Mon Sep 17 00:00:00 2001 From: Jason Yao Date: Thu, 10 Apr 2025 21:55:49 -0400 Subject: [PATCH 29/36] Add validation tests for EllipsoidalSet --- .../pyros/tests/test_uncertainty_sets.py | 68 ++++++++++++++++++- 1 file changed, 66 insertions(+), 2 deletions(-) diff --git a/pyomo/contrib/pyros/tests/test_uncertainty_sets.py b/pyomo/contrib/pyros/tests/test_uncertainty_sets.py index e901197cc06..ea256ba0967 100644 --- a/pyomo/contrib/pyros/tests/test_uncertainty_sets.py +++ b/pyomo/contrib/pyros/tests/test_uncertainty_sets.py @@ -987,7 +987,7 @@ def test_validate(self): """ CONFIG = Bunch() - # construct a valid budget set + # construct a valid factor model set origin = [0., 0., 0.] number_of_factors = 2 psi_mat = [[1, 0], [0, 1], [1, 1]] @@ -1872,7 +1872,7 @@ def test_validate(self): """ CONFIG = Bunch() - # construct a valid budget set + # construct a valid axis aligned ellipsoidal set center = [0., 0.] half_lengths = [1., 3.] a_ellipsoid_set = AxisAlignedEllipsoidalSet(center, half_lengths) @@ -2281,6 +2281,70 @@ def test_add_bounds_on_uncertain_parameters(self): self.assertEqual(m.uncertain_param_vars[0].bounds, (0.5, 1.5)) self.assertEqual(m.uncertain_param_vars[1].bounds, (1, 2)) + def test_validate(self): + """ + Test validate checks perform as expected. + """ + CONFIG = Bunch() + + # construct a valid ellipsoidal set + center = [0., 0.] + shape_matrix = [[1., 0.], [0., 2.]] + scale = 1 + ellipsoid_set = EllipsoidalSet(center, shape_matrix, scale) + + # validate raises no issues on valid set + ellipsoid_set.validate(config=CONFIG) + + # check when values are not finite + ellipsoid_set.center[0] = np.nan + exc_str = r"Center is not finite. .*" + with self.assertRaisesRegex(ValueError, exc_str): + ellipsoid_set.validate(config=CONFIG) + ellipsoid_set.center[0] = 0 + + # check when scale is not positive + ellipsoid_set.scale = -1 + exc_str = r".*must be a non-negative real \(provided.*-1\)" + with self.assertRaisesRegex(ValueError, exc_str): + ellipsoid_set.validate(config=CONFIG) + + # check when shape matrix is invalid + center = [0, 0] + scale = 3 + + # assert error on construction + with self.assertRaisesRegex( + ValueError, + r"Shape matrix must be symmetric", + msg="Asymmetric shape matrix test failed", + ): + ellipsoid_set = EllipsoidalSet(center, [[1, 1], [0, 1]], scale) + ellipsoid_set.validate(config=CONFIG) + with self.assertRaises( + np.linalg.LinAlgError, msg="Singular shape matrix test failed" + ): + ellipsoid_set = EllipsoidalSet(center, [[0, 0], [0, 0]], scale) + ellipsoid_set.validate(config=CONFIG) + with self.assertRaisesRegex( + ValueError, + r"Non positive-definite.*", + msg="Indefinite shape matrix test failed", + ): + ellipsoid_set = EllipsoidalSet(center, [[1, 0], [0, -2]], scale) + ellipsoid_set.validate(config=CONFIG) + + @unittest.skipUnless(baron_available, "BARON is not available") + def test_bounded_and_nonempty(self): + """ + Test `is_bounded` and `is_nonempty` for a valid cardinality set. + """ + center = [0., 0.] + shape_matrix = [[1., 0.], [0., 2.]] + scale = 1 + ellipsoid_set = EllipsoidalSet(center, shape_matrix, scale) + bounded_and_nonempty_check(self, ellipsoid_set), + class TestPolyhedralSet(unittest.TestCase): """ From 82a92aafb396384297ccdd24b13a897526355d75 Mon Sep 17 00:00:00 2001 From: Jason Yao Date: Thu, 10 Apr 2025 21:56:18 -0400 Subject: [PATCH 30/36] Remove tests for EllipsoidalSet setters --- .../pyros/tests/test_uncertainty_sets.py | 69 ------------------- 1 file changed, 69 deletions(-) diff --git a/pyomo/contrib/pyros/tests/test_uncertainty_sets.py b/pyomo/contrib/pyros/tests/test_uncertainty_sets.py index ea256ba0967..6997eb44c65 100644 --- a/pyomo/contrib/pyros/tests/test_uncertainty_sets.py +++ b/pyomo/contrib/pyros/tests/test_uncertainty_sets.py @@ -2019,28 +2019,6 @@ def test_error_on_ellipsoidal_dim_change(self): with self.assertRaisesRegex(ValueError, exc_str): eset.center = [0, 0, 0] - def test_error_on_neg_scale(self): - """ - Test ValueError raised if scale attribute set to negative - value. - """ - center = [0, 0] - shape_matrix = [[1, 0], [0, 2]] - neg_scale = -1 - - exc_str = r".*must be a non-negative real \(provided.*-1\)" - - # assert error on construction - with self.assertRaisesRegex(ValueError, exc_str): - EllipsoidalSet(center, shape_matrix, neg_scale) - - # construct a valid EllipsoidalSet - eset = EllipsoidalSet(center, shape_matrix, scale=2) - - # assert error on update - with self.assertRaisesRegex(ValueError, exc_str): - eset.scale = neg_scale - def test_error_invalid_gaussian_conf_lvl(self): """ Test error when attempting to initialize with Gaussian @@ -2105,53 +2083,6 @@ def test_error_on_shape_matrix_with_wrong_size(self): with self.assertRaisesRegex(ValueError, exc_str): eset.shape_matrix = invalid_shape_matrix - def test_error_on_invalid_shape_matrix(self): - """ - Test exceptional cases of invalid square shape matrix - arguments - """ - center = [0, 0] - scale = 3 - - # assert error on construction - with self.assertRaisesRegex( - ValueError, - r"Shape matrix must be symmetric", - msg="Asymmetric shape matrix test failed", - ): - EllipsoidalSet(center, [[1, 1], [0, 1]], scale) - with self.assertRaises( - np.linalg.LinAlgError, msg="Singular shape matrix test failed" - ): - EllipsoidalSet(center, [[0, 0], [0, 0]], scale) - with self.assertRaisesRegex( - ValueError, - r"Non positive-definite.*", - msg="Indefinite shape matrix test failed", - ): - EllipsoidalSet(center, [[1, 0], [0, -2]], scale) - - # construct a valid EllipsoidalSet - eset = EllipsoidalSet(center, [[1, 0], [0, 2]], scale) - - # assert error on update - with self.assertRaisesRegex( - ValueError, - r"Shape matrix must be symmetric", - msg="Asymmetric shape matrix test failed", - ): - eset.shape_matrix = [[1, 1], [0, 1]] - with self.assertRaises( - np.linalg.LinAlgError, msg="Singular shape matrix test failed" - ): - eset.shape_matrix = [[0, 0], [0, 0]] - with self.assertRaisesRegex( - ValueError, - r"Non positive-definite.*", - msg="Indefinite shape matrix test failed", - ): - eset.shape_matrix = [[1, 0], [0, -2]] - def test_set_as_constraint(self): """ Test method for setting up constraints works correctly. From c418e0fb08f04bc1b7b9d62136223cc882813dcc Mon Sep 17 00:00:00 2001 From: Jason Yao Date: Thu, 10 Apr 2025 22:31:43 -0400 Subject: [PATCH 31/36] Add DiscreteSet validate method and tests --- .../pyros/tests/test_uncertainty_sets.py | 39 +++++++++++++++++++ pyomo/contrib/pyros/uncertainty_sets.py | 26 +++++++++++++ 2 files changed, 65 insertions(+) diff --git a/pyomo/contrib/pyros/tests/test_uncertainty_sets.py b/pyomo/contrib/pyros/tests/test_uncertainty_sets.py index 6997eb44c65..905b7f580c5 100644 --- a/pyomo/contrib/pyros/tests/test_uncertainty_sets.py +++ b/pyomo/contrib/pyros/tests/test_uncertainty_sets.py @@ -1720,6 +1720,45 @@ def test_add_bounds_on_uncertain_parameters(self): self.assertEqual(m.uncertain_param_vars[0].bounds, (0, 2)) self.assertEqual(m.uncertain_param_vars[1].bounds, (0, 1.0)) + def test_validate(self): + """ + Test validate checks perform as expected. + """ + CONFIG = Bunch() + + # construct a valid discrete scenario set + discrete_set = DiscreteScenarioSet([[1, 2], [3, 4]]) + + # validate raises no issues on valid set + discrete_set.validate(config=CONFIG) + + # check when scenario set is empty + # TODO should this method can be used to create ragged arrays + # after a set is created. There are currently no checks for this + # in any validate method. It may be good to included validate_array + # in all validate methods as well to guard against it. + discrete_set = DiscreteScenarioSet([[0]]) + discrete_set.scenarios.pop(0) + exc_str = r"Scenarios set must be nonempty. .*" + with self.assertRaisesRegex(ValueError, exc_str): + discrete_set.validate(config=CONFIG) + + # check when not all scenarios are finite + discrete_set = DiscreteScenarioSet([[1, 2], [3, 4]]) + exc_str = r"Not all scenarios are finite. .*" + for val_str in ["inf", "nan"]: + discrete_set.scenarios[0] = [1, float(val_str)] + with self.assertRaisesRegex(ValueError, exc_str): + discrete_set.validate(config=CONFIG) + + @unittest.skipUnless(baron_available, "BARON is not available") + def test_bounded_and_nonempty(self): + """ + Test `is_bounded` and `is_nonempty` for a valid cardinality set. + """ + discrete_set = DiscreteScenarioSet([[1, 2], [3, 4]]) + bounded_and_nonempty_check(self, discrete_set), + class TestAxisAlignedEllipsoidalSet(unittest.TestCase): """ diff --git a/pyomo/contrib/pyros/uncertainty_sets.py b/pyomo/contrib/pyros/uncertainty_sets.py index 6e357298660..e59493fbc8b 100644 --- a/pyomo/contrib/pyros/uncertainty_sets.py +++ b/pyomo/contrib/pyros/uncertainty_sets.py @@ -3158,6 +3158,32 @@ def point_in_set(self, point): rounded_point = np.round(point, decimals=num_decimals) return np.any(np.all(rounded_point == rounded_scenarios, axis=1)) + def validate(self, config): + """ + Check DiscreteScenarioSet validity. + + Raises + ------ + ValueError + If finiteness or nonemptiness checks fail. + """ + scenario_arr = self.scenarios + + # check nonemptiness + if len(scenario_arr) < 1: + raise ValueError( + "Scenarios set must be nonempty. " + f"Got scenarios: {scenario_arr}" + ) + + # check finiteness + for scenario in scenario_arr: + if not np.all(np.isfinite(scenario)): + raise ValueError( + "Not all scenarios are finite. " + f"Got scenario: {scenario}" + ) + class IntersectionSet(UncertaintySet): """ From 4e03241bc7ec694eae9318a63f5ec39a8eea35ce Mon Sep 17 00:00:00 2001 From: Jason Yao Date: Thu, 10 Apr 2025 22:45:48 -0400 Subject: [PATCH 32/36] Add IntersectionSet validate method and tests --- .../pyros/tests/test_uncertainty_sets.py | 40 +++++++++++++++++++ pyomo/contrib/pyros/uncertainty_sets.py | 18 +++++++++ 2 files changed, 58 insertions(+) diff --git a/pyomo/contrib/pyros/tests/test_uncertainty_sets.py b/pyomo/contrib/pyros/tests/test_uncertainty_sets.py index 905b7f580c5..c72f775c3aa 100644 --- a/pyomo/contrib/pyros/tests/test_uncertainty_sets.py +++ b/pyomo/contrib/pyros/tests/test_uncertainty_sets.py @@ -1409,6 +1409,46 @@ def test_add_bounds_on_uncertain_parameters(self): with self.assertRaisesRegex(ValueError, ".*to match the set dimension.*"): iset.point_in_set([1, 2, 3]) + @unittest.skipUnless(baron_available, "BARON is not available") + def test_validate(self): + """ + Test validate checks perform as expected. + """ + CONFIG = pyros_config() + CONFIG.global_solver = global_solver + + # construct a valid intersection set + bset = BoxSet(bounds=[[-1, 1], [-1, 1], [-1, 1]]) + aset = AxisAlignedEllipsoidalSet([0, 0, 0], [1, 1, 1]) + intersection_set = IntersectionSet(box_set=bset, axis_aligned_set=aset) + + # validate raises no issues on valid set + intersection_set.validate(config=CONFIG) + + # check when individual sets fail validation method + bset = BoxSet(bounds=[[-1, 1], [-1, 1], [-1, 1]]) + bset.bounds[0][0] = 2 + aset = AxisAlignedEllipsoidalSet([0, 0, 0], [1, 1, 1]) + intersection_set = IntersectionSet(box_set=bset, axis_aligned_set=aset) + exc_str = r"Lower bound 2 exceeds upper bound 1" + with self.assertRaisesRegex(ValueError, exc_str): + intersection_set.validate(config=CONFIG) + + # check when individual sets are not actually intersecting + bset1 = BoxSet(bounds=[[1, 2], [1, 2]]) + bset2 = BoxSet(bounds=[[-2, -1], [-2, -1]]) + intersection_set = IntersectionSet(box_set1=bset1, box_set2=bset2) + exc_str = r"Could not compute.*bound in dimension.*Solver status summary:.*" + with self.assertRaisesRegex(ValueError, exc_str): + intersection_set.validate(config=CONFIG) + + @unittest.skipUnless(baron_available, "BARON is not available") + def test_bounded_and_nonempty(self): + """ + Test `is_bounded` and `is_nonempty` for a valid cardinality set. + """ + discrete_set = DiscreteScenarioSet([[1, 2], [3, 4]]) + bounded_and_nonempty_check(self, discrete_set), class TestCardinalitySet(unittest.TestCase): """ diff --git a/pyomo/contrib/pyros/uncertainty_sets.py b/pyomo/contrib/pyros/uncertainty_sets.py index e59493fbc8b..91251ef60e4 100644 --- a/pyomo/contrib/pyros/uncertainty_sets.py +++ b/pyomo/contrib/pyros/uncertainty_sets.py @@ -3370,3 +3370,21 @@ def set_as_constraint(self, uncertain_params=None, block=None): uncertainty_cons=all_cons, auxiliary_vars=all_aux_vars, ) + + def validate(self, config): + """ + Check IntersectionSet validity. + + Raises + ------ + ValueError + If finiteness or nonemptiness checks fail. + """ + the_sets = self.all_sets + + # validate each set + for a_set in the_sets: + a_set.validate(config) + + # check boundedness and nonemptiness of intersected set + super().validate(config) From 72d89bef51b973fc2ed71c2d996fb4e08549caa3 Mon Sep 17 00:00:00 2001 From: Jason Yao Date: Thu, 10 Apr 2025 22:51:13 -0400 Subject: [PATCH 33/36] Run black --- .../pyros/tests/test_uncertainty_sets.py | 71 ++++++++++--------- pyomo/contrib/pyros/uncertainty_sets.py | 53 ++++++-------- 2 files changed, 56 insertions(+), 68 deletions(-) diff --git a/pyomo/contrib/pyros/tests/test_uncertainty_sets.py b/pyomo/contrib/pyros/tests/test_uncertainty_sets.py index c72f775c3aa..a48546ed11c 100644 --- a/pyomo/contrib/pyros/tests/test_uncertainty_sets.py +++ b/pyomo/contrib/pyros/tests/test_uncertainty_sets.py @@ -83,16 +83,10 @@ def bounded_and_nonempty_check(test, unc_set): CONFIG.global_solver = global_solver # check is_bounded - test.assertTrue( - unc_set.is_bounded(config=CONFIG), - "Set is not bounded." - ) + test.assertTrue(unc_set.is_bounded(config=CONFIG), "Set is not bounded.") # check is_nonempty - test.assertTrue( - unc_set.is_nonempty(config=CONFIG), - "Set is empty." - ) + test.assertTrue(unc_set.is_nonempty(config=CONFIG), "Set is empty.") class TestBoxSet(unittest.TestCase): @@ -371,7 +365,7 @@ def test_validate(self): CONFIG = Bunch() # construct valid box set - box_set = BoxSet(bounds=[[1., 2.], [3., 4.]]) + box_set = BoxSet(bounds=[[1.0, 2.0], [3.0, 4.0]]) # validate raises no issues on valid set box_set.validate(config=CONFIG) @@ -393,7 +387,7 @@ def test_bounded_and_nonempty(self): """ Test `is_bounded` and `is_nonempty` for a valid box set. """ - box_set = BoxSet(bounds=[[1., 2.], [3., 4.]]) + box_set = BoxSet(bounds=[[1.0, 2.0], [3.0, 4.0]]) bounded_and_nonempty_check(self, box_set), @@ -606,8 +600,8 @@ def test_validate(self): CONFIG = Bunch() # construct a valid budget set - budget_mat = [[1., 0., 1.], [0., 1., 0.]] - budget_rhs_vec = [1., 3.] + budget_mat = [[1.0, 0.0, 1.0], [0.0, 1.0, 0.0]] + budget_rhs_vec = [1.0, 3.0] budget_set = BudgetSet(budget_mat, budget_rhs_vec) # validate raises no issues on valid set @@ -665,8 +659,8 @@ def test_bounded_and_nonempty(self): """ Test `is_bounded` and `is_nonempty` for a valid cardinality set. """ - budget_mat = [[1., 0., 1.], [0., 1., 0.]] - budget_rhs_vec = [1., 3.] + budget_mat = [[1.0, 0.0, 1.0], [0.0, 1.0, 0.0]] + budget_rhs_vec = [1.0, 3.0] budget_set = BudgetSet(budget_mat, budget_rhs_vec) bounded_and_nonempty_check(self, budget_set), @@ -988,7 +982,7 @@ def test_validate(self): CONFIG = Bunch() # construct a valid factor model set - origin = [0., 0., 0.] + origin = [0.0, 0.0, 0.0] number_of_factors = 2 psi_mat = [[1, 0], [0, 1], [1, 1]] beta = 0.5 @@ -1043,7 +1037,7 @@ def test_bounded_and_nonempty(self): """ Test `is_bounded` and `is_nonempty` for a valid cardinality set. """ - origin = [0., 0., 0.] + origin = [0.0, 0.0, 0.0] number_of_factors = 2 psi_mat = [[1, 0], [0, 1], [1, 1]] beta = 0.5 @@ -1450,6 +1444,7 @@ def test_bounded_and_nonempty(self): discrete_set = DiscreteScenarioSet([[1, 2], [3, 4]]) bounded_and_nonempty_check(self, discrete_set), + class TestCardinalitySet(unittest.TestCase): """ Tests for the CardinalitySet. @@ -1604,7 +1599,7 @@ def test_validate(self): # construct a valid cardinality set cardinality_set = CardinalitySet( - origin=[0., 0.], positive_deviation=[1., 1.], gamma=2 + origin=[0.0, 0.0], positive_deviation=[1.0, 1.0], gamma=2 ) # validate raises no issues on valid set @@ -1651,7 +1646,9 @@ def test_bounded_and_nonempty(self): """ Test `is_bounded` and `is_nonempty` for a valid cardinality set. """ - cardinality_set = CardinalitySet(origin=[0, 0], positive_deviation=[1, 1], gamma=2) + cardinality_set = CardinalitySet( + origin=[0, 0], positive_deviation=[1, 1], gamma=2 + ) bounded_and_nonempty_check(self, cardinality_set), @@ -1952,8 +1949,8 @@ def test_validate(self): CONFIG = Bunch() # construct a valid axis aligned ellipsoidal set - center = [0., 0.] - half_lengths = [1., 3.] + center = [0.0, 0.0] + half_lengths = [1.0, 3.0] a_ellipsoid_set = AxisAlignedEllipsoidalSet(center, half_lengths) # validate raises no issues on valid set @@ -1983,8 +1980,8 @@ def test_bounded_and_nonempty(self): """ Test `is_bounded` and `is_nonempty` for a valid cardinality set. """ - center = [0., 0.] - half_lengths = [1., 3.] + center = [0.0, 0.0] + half_lengths = [1.0, 3.0] a_ellipsoid_set = AxisAlignedEllipsoidalSet(center, half_lengths) bounded_and_nonempty_check(self, a_ellipsoid_set), @@ -2298,8 +2295,8 @@ def test_validate(self): CONFIG = Bunch() # construct a valid ellipsoidal set - center = [0., 0.] - shape_matrix = [[1., 0.], [0., 2.]] + center = [0.0, 0.0] + shape_matrix = [[1.0, 0.0], [0.0, 2.0]] scale = 1 ellipsoid_set = EllipsoidalSet(center, shape_matrix, scale) @@ -2349,8 +2346,8 @@ def test_bounded_and_nonempty(self): """ Test `is_bounded` and `is_nonempty` for a valid cardinality set. """ - center = [0., 0.] - shape_matrix = [[1., 0.], [0., 2.]] + center = [0.0, 0.0] + shape_matrix = [[1.0, 0.0], [0.0, 2.0]] scale = 1 ellipsoid_set = EllipsoidalSet(center, shape_matrix, scale) bounded_and_nonempty_check(self, ellipsoid_set), @@ -2555,8 +2552,8 @@ def test_validate(self): # construct a valid polyhedral set polyhedral_set = PolyhedralSet( - lhs_coefficients_mat=[[1., 0.], [-1., 1.], [-1., -1.]], - rhs_vec=[2., -1., -1.] + lhs_coefficients_mat=[[1.0, 0.0], [-1.0, 1.0], [-1.0, -1.0]], + rhs_vec=[2.0, -1.0, -1.0], ) # validate raises no issues on valid set @@ -2575,7 +2572,7 @@ def test_validate(self): polyhedral_set.validate(config=CONFIG) # check when LHS matrix is not full column rank - polyhedral_set.coefficients_mat = [[0., 0.], [0., 1.], [0., -1.]] + polyhedral_set.coefficients_mat = [[0.0, 0.0], [0.0, 1.0], [0.0, -1.0]] exc_str = r".*all entries zero in columns at indexes: 0.*" with self.assertRaisesRegex(ValueError, exc_str): polyhedral_set.validate(config=CONFIG) @@ -2586,8 +2583,8 @@ def test_bounded_and_nonempty(self): Test `is_bounded` and `is_nonempty` for a valid cardinality set. """ polyhedral_set = PolyhedralSet( - lhs_coefficients_mat=[[1., 0.], [-1., 1.], [-1., -1.]], - rhs_vec=[2., -1., -1.] + lhs_coefficients_mat=[[1.0, 0.0], [-1.0, 1.0], [-1.0, -1.0]], + rhs_vec=[2.0, -1.0, -1.0], ) bounded_and_nonempty_check(self, polyhedral_set), @@ -2688,7 +2685,6 @@ def test_solve_feasibility(self): with self.assertRaisesRegex(ValueError, exc_str): custom_set._solve_feasibility(baron) - # test default is_bounded @unittest.skipUnless(baron_available, "BARON is not available") def test_is_bounded(self): @@ -2714,8 +2710,11 @@ def test_is_bounded(self): # check with parameter_bounds should always take less time than solving 2N # optimization problems - self.assertLess(time_with_bounds_provided, time_without_bounds_provided, - "Boundedness check with provided parameter_bounds took longer than expected.") + self.assertLess( + time_with_bounds_provided, + time_without_bounds_provided, + "Boundedness check with provided parameter_bounds took longer than expected.", + ) # when bad bounds are provided for val_str in ["inf", "nan"]: @@ -2742,7 +2741,9 @@ def test_is_nonempty(self): # check when nominal point is not in set CONFIG.nominal_uncertain_param_vals = [-2, -2] - self.assertFalse(custom_set.is_nonempty(config=CONFIG), "Nominal point is in set") + self.assertFalse( + custom_set.is_nonempty(config=CONFIG), "Nominal point is in set" + ) # check when feasibility problem fails CONFIG.nominal_uncertain_param_vals = None diff --git a/pyomo/contrib/pyros/uncertainty_sets.py b/pyomo/contrib/pyros/uncertainty_sets.py index 91251ef60e4..65369fe3241 100644 --- a/pyomo/contrib/pyros/uncertainty_sets.py +++ b/pyomo/contrib/pyros/uncertainty_sets.py @@ -36,7 +36,7 @@ minimize, Var, VarData, - NonNegativeReals + NonNegativeReals, ) from pyomo.core.expr import mutable_expression, native_numeric_types, value from pyomo.core.util import quicksum, dot_product @@ -640,15 +640,15 @@ def validate(self, config): if not check_nonempty: raise ValueError( - "Failed nonemptiness check. Nominal point is not in the set. " - f"Nominal point:\n {config.nominal_uncertain_param_vals}." - ) + "Failed nonemptiness check. Nominal point is not in the set. " + f"Nominal point:\n {config.nominal_uncertain_param_vals}." + ) if not check_bounded: raise ValueError( - "Failed boundedness check. Parameter bounds are not finite. " - f"Parameter bounds:\n {self.parameter_bounds}." - ) + "Failed boundedness check. Parameter bounds are not finite. " + f"Parameter bounds:\n {self.parameter_bounds}." + ) @abc.abstractmethod def set_as_constraint(self, uncertain_params=None, block=None): @@ -790,9 +790,7 @@ def _solve_feasibility(self, solver): model.param_vars = Var(range(self.dim)) # add bounds on param vars - self._add_bounds_on_uncertain_parameters( - model.param_vars, global_solver=solver - ) + self._add_bounds_on_uncertain_parameters(model.param_vars, global_solver=solver) # add constraints self.set_as_constraint(uncertain_params=model.param_vars, block=model) @@ -1205,8 +1203,7 @@ def validate(self, config): # finiteness check if not np.all(np.isfinite(bounds_arr)): raise ValueError( - "Not all bounds are finite. " - f"\nGot bounds:\n {bounds_arr}" + "Not all bounds are finite. " f"\nGot bounds:\n {bounds_arr}" ) # check LB <= UB @@ -1703,7 +1700,6 @@ def set_as_constraint(self, uncertain_params=None, block=None): auxiliary_vars=aux_var_list, ) - def validate(self, config): """ Check PolyhedralSet validity. @@ -1718,7 +1714,9 @@ def validate(self, config): rhs_vec_arr = self.rhs_vec # finiteness check - if not (np.all(np.isfinite(lhs_coeffs_arr)) and np.all(np.isfinite(rhs_vec_arr))): + if not ( + np.all(np.isfinite(lhs_coeffs_arr)) and np.all(np.isfinite(rhs_vec_arr)) + ): raise ValueError( "LHS coefficient matrix or RHS vector are not finite. " f"\nGot LHS matrix:\n{lhs_coeffs_arr},\nRHS vector:\n{rhs_vec_arr}" @@ -1999,9 +1997,9 @@ def validate(self, config): # finiteness check if not ( - np.all(np.isfinite(lhs_coeffs_arr)) - and np.all(np.isfinite(rhs_vec_arr)) - and np.all(np.isfinite(orig_val)) + np.all(np.isfinite(lhs_coeffs_arr)) + and np.all(np.isfinite(rhs_vec_arr)) + and np.all(np.isfinite(orig_val)) ): raise ValueError( "Origin, LHS coefficient matrix or RHS vector are not finite. " @@ -2393,10 +2391,7 @@ def validate(self, config): # finiteness check if not np.all(np.isfinite(orig_val)): - raise ValueError( - "Origin is not finite. " - f"Got origin: {orig_val}" - ) + raise ValueError("Origin is not finite. " f"Got origin: {orig_val}") # check psi is full column rank psi_mat_rank = np.linalg.matrix_rank(psi_mat_arr) @@ -2598,10 +2593,7 @@ def validate(self, config): half_lengths = self.half_lengths # finiteness check - if not ( - np.all(np.isfinite(ctr)) - and np.all(np.isfinite(half_lengths)) - ): + if not (np.all(np.isfinite(ctr)) and np.all(np.isfinite(half_lengths))): raise ValueError( "Center or half-lengths are not finite. " f"Got center: {ctr}, half-lengths: {half_lengths}" @@ -2976,10 +2968,7 @@ def validate(self, config): # finiteness check if not np.all(np.isfinite(ctr)): - raise ValueError( - "Center is not finite. " - f"Got center: {ctr}" - ) + raise ValueError("Center is not finite. " f"Got center: {ctr}") # check shape matrix is positive semidefinite self._verify_positive_definite(shape_mat_arr) @@ -3172,16 +3161,14 @@ def validate(self, config): # check nonemptiness if len(scenario_arr) < 1: raise ValueError( - "Scenarios set must be nonempty. " - f"Got scenarios: {scenario_arr}" + "Scenarios set must be nonempty. " f"Got scenarios: {scenario_arr}" ) # check finiteness for scenario in scenario_arr: if not np.all(np.isfinite(scenario)): raise ValueError( - "Not all scenarios are finite. " - f"Got scenario: {scenario}" + "Not all scenarios are finite. " f"Got scenario: {scenario}" ) From af2d26101c8e6d345a933766b3a2b006747e3bdf Mon Sep 17 00:00:00 2001 From: Jason Yao Date: Fri, 11 Apr 2025 09:26:43 -0400 Subject: [PATCH 34/36] Fix typos in test_bounded_and_nonempty docstring --- .../pyros/tests/test_uncertainty_sets.py | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/pyomo/contrib/pyros/tests/test_uncertainty_sets.py b/pyomo/contrib/pyros/tests/test_uncertainty_sets.py index a48546ed11c..ab3e4b8e8e7 100644 --- a/pyomo/contrib/pyros/tests/test_uncertainty_sets.py +++ b/pyomo/contrib/pyros/tests/test_uncertainty_sets.py @@ -657,7 +657,7 @@ def test_validate(self): @unittest.skipUnless(baron_available, "BARON is not available") def test_bounded_and_nonempty(self): """ - Test `is_bounded` and `is_nonempty` for a valid cardinality set. + Test `is_bounded` and `is_nonempty` for a valid budget set. """ budget_mat = [[1.0, 0.0, 1.0], [0.0, 1.0, 0.0]] budget_rhs_vec = [1.0, 3.0] @@ -1035,7 +1035,7 @@ def test_validate(self): @unittest.skipUnless(baron_available, "BARON is not available") def test_bounded_and_nonempty(self): """ - Test `is_bounded` and `is_nonempty` for a valid cardinality set. + Test `is_bounded` and `is_nonempty` for a valid factor model set. """ origin = [0.0, 0.0, 0.0] number_of_factors = 2 @@ -1439,10 +1439,12 @@ def test_validate(self): @unittest.skipUnless(baron_available, "BARON is not available") def test_bounded_and_nonempty(self): """ - Test `is_bounded` and `is_nonempty` for a valid cardinality set. + Test `is_bounded` and `is_nonempty` for a valid intersection set. """ - discrete_set = DiscreteScenarioSet([[1, 2], [3, 4]]) - bounded_and_nonempty_check(self, discrete_set), + bset = BoxSet(bounds=[[-1, 1], [-1, 1], [-1, 1]]) + aset = AxisAlignedEllipsoidalSet([0, 0, 0], [1, 1, 1]) + intersection_set = IntersectionSet(box_set=bset, axis_aligned_set=aset) + bounded_and_nonempty_check(self, intersection_set), class TestCardinalitySet(unittest.TestCase): @@ -1791,7 +1793,7 @@ def test_validate(self): @unittest.skipUnless(baron_available, "BARON is not available") def test_bounded_and_nonempty(self): """ - Test `is_bounded` and `is_nonempty` for a valid cardinality set. + Test `is_bounded` and `is_nonempty` for a valid discrete scenario set. """ discrete_set = DiscreteScenarioSet([[1, 2], [3, 4]]) bounded_and_nonempty_check(self, discrete_set), @@ -1978,7 +1980,7 @@ def test_validate(self): @unittest.skipUnless(baron_available, "BARON is not available") def test_bounded_and_nonempty(self): """ - Test `is_bounded` and `is_nonempty` for a valid cardinality set. + Test `is_bounded` and `is_nonempty` for a valid axis aligned ellipsoidal set. """ center = [0.0, 0.0] half_lengths = [1.0, 3.0] @@ -2344,7 +2346,7 @@ def test_validate(self): @unittest.skipUnless(baron_available, "BARON is not available") def test_bounded_and_nonempty(self): """ - Test `is_bounded` and `is_nonempty` for a valid cardinality set. + Test `is_bounded` and `is_nonempty` for a valid ellipsoidal set. """ center = [0.0, 0.0] shape_matrix = [[1.0, 0.0], [0.0, 2.0]] @@ -2580,7 +2582,7 @@ def test_validate(self): @unittest.skipUnless(baron_available, "BARON is not available") def test_bounded_and_nonempty(self): """ - Test `is_bounded` and `is_nonempty` for a valid cardinality set. + Test `is_bounded` and `is_nonempty` for a valid polyhedral set. """ polyhedral_set = PolyhedralSet( lhs_coefficients_mat=[[1.0, 0.0], [-1.0, 1.0], [-1.0, -1.0]], From 9c97b6904b171c6354224488e97817fd1addac53 Mon Sep 17 00:00:00 2001 From: Jason Yao Date: Thu, 24 Apr 2025 21:56:31 -0400 Subject: [PATCH 35/36] make valid_num_types set, update validate_arg_type --- pyomo/contrib/pyros/uncertainty_sets.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/pyomo/contrib/pyros/uncertainty_sets.py b/pyomo/contrib/pyros/uncertainty_sets.py index cd3f2f325ef..7dff0d38f1a 100644 --- a/pyomo/contrib/pyros/uncertainty_sets.py +++ b/pyomo/contrib/pyros/uncertainty_sets.py @@ -50,7 +50,7 @@ ) -valid_num_types = tuple(native_numeric_types) +valid_num_types = native_numeric_types def standardize_uncertain_param_vars(obj, dim): @@ -219,7 +219,7 @@ def validate_arg_type( Name of argument to be displayed in exception message. arg_val : object Value of argument to be checked. - valid_types : type or tuple of types + valid_types : type, tuple of types, or iterable of types Valid types for the argument value. valid_type_desc : str or None, optional Description of valid types for the argument value; @@ -242,6 +242,9 @@ def validate_arg_type( If the finiteness check on a numerical value returns a negative result. """ + # convert to tuple if necessary + if isinstance(valid_types, Iterable): + valid_types = tuple(valid_types) if not isinstance(arg_val, valid_types): if valid_type_desc is not None: type_phrase = f"not {valid_type_desc}" From 1e508f31ca67c99869e275601a78c171fd528e76 Mon Sep 17 00:00:00 2001 From: Jason Yao Date: Thu, 24 Apr 2025 21:58:56 -0400 Subject: [PATCH 36/36] Update EllipsoidalSet docstring --- pyomo/contrib/pyros/uncertainty_sets.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyomo/contrib/pyros/uncertainty_sets.py b/pyomo/contrib/pyros/uncertainty_sets.py index 7dff0d38f1a..8fabbf0e320 100644 --- a/pyomo/contrib/pyros/uncertainty_sets.py +++ b/pyomo/contrib/pyros/uncertainty_sets.py @@ -2772,7 +2772,7 @@ class EllipsoidalSet(UncertaintySet): [0, 0, 3, 0]], [0, 0, 0. 4]]) >>> conf_ellipsoid.scale - ...9.4877... + np.float64(9.4877...) >>> conf_ellipsoid.gaussian_conf_lvl 0.95