diff --git a/pyomo/contrib/pyros/tests/test_uncertainty_sets.py b/pyomo/contrib/pyros/tests/test_uncertainty_sets.py index 0e5482eb8e0..f072744e523 100644 --- a/pyomo/contrib/pyros/tests/test_uncertainty_sets.py +++ b/pyomo/contrib/pyros/tests/test_uncertainty_sets.py @@ -24,6 +24,9 @@ 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 from pyomo.core.expr.compare import assertExpressionsEqual @@ -45,6 +48,9 @@ _setup_standard_uncertainty_set_constraint_block, ) +from pyomo.contrib.pyros.config import pyros_config +import time + import logging logger = logging.getLogger(__name__) @@ -71,6 +77,21 @@ baron_version = (0, 0, 0) +def bounded_and_nonempty_check(test, 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 + 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.") + + class TestBoxSet(unittest.TestCase): """ Tests for the BoxSet. @@ -107,25 +128,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 @@ -359,6 +361,38 @@ 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 = Bunch() + + # construct valid box set + box_set = BoxSet(bounds=[[1.0, 2.0], [3.0, 4.0]]) + + # validate raises no issues on valid set + box_set.validate(config=CONFIG) + + # 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): + 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.0, 2.0], [3.0, 4.0]]) + bounded_and_nonempty_check(self, box_set), + def test_is_coordinate_fixed(self): """ Test method for checking whether there are coordinates @@ -469,90 +503,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): """ @@ -666,6 +616,77 @@ 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, 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 + budget_set.validate(config=CONFIG) + + # 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): + 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) + + # 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 budget set. + """ + 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), + def test_is_coordinate_fixed(self): """ Test method for checking whether there are coordinates @@ -761,58 +782,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 @@ -1041,6 +1010,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 factor model set + origin = [0.0, 0.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 factor model set. + """ + origin = [0.0, 0.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), + def test_is_coordinate_fixed(self): """ Test method for checking whether there are coordinates @@ -1420,6 +1458,48 @@ def test_add_bounds_on_uncertain_parameters(self): 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 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) + bounded_and_nonempty_check(self, intersection_set), + def test_is_coordinate_fixed(self): """ Test method for checking whether there are coordinates @@ -1463,58 +1543,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. @@ -1632,6 +1660,66 @@ 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, 0.0], positive_deviation=[1.0, 1.0], gamma=2 + ) + + # validate raises no issues on valid set + cardinality_set.validate(config=CONFIG) + + # 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): + 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. .*" + 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), + def test_is_coordinate_fixed(self): """ Test method for checking whether there are coordinates @@ -1748,6 +1836,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 discrete scenario set. + """ + discrete_set = DiscreteScenarioSet([[1, 2], [3, 4]]) + bounded_and_nonempty_check(self, discrete_set), + def test_is_coordinate_fixed(self): """ Test method for checking whether there are coordinates @@ -1817,26 +1944,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. @@ -1924,6 +2031,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 axis aligned ellipsoidal set + 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 + 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 axis aligned ellipsoidal set. + """ + 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), + def test_is_coordinate_fixed(self): """ Test method for checking whether there are coordinates @@ -2044,28 +2194,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 @@ -2130,53 +2258,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. @@ -2306,6 +2387,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, 0.0] + shape_matrix = [[1.0, 0.0], [0.0, 2.0]] + 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 ellipsoidal set. + """ + 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), + def test_is_coordinate_fixed(self): """ Test method for checking whether there are coordinates @@ -2406,27 +2551,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. @@ -2532,6 +2656,51 @@ def test_add_bounds_on_uncertain_parameters(self): 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, 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 + polyhedral_set.validate(config=CONFIG) + + # 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): + 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.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) + + @unittest.skipUnless(baron_available, "BARON is not available") + def test_bounded_and_nonempty(self): + """ + 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]], + rhs_vec=[2.0, -1.0, -1.0], + ) + bounded_and_nonempty_check(self, polyhedral_set), + def test_is_coordinate_fixed(self): """ Test method for checking whether there are coordinates @@ -2554,6 +2723,7 @@ class CustomUncertaintySet(UncertaintySet): def __init__(self, dim): self._dim = dim + self._parameter_bounds = [(-1, 1)] * self.dim @property def geometry(self): @@ -2589,7 +2759,11 @@ 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): @@ -2623,6 +2797,88 @@ def test_compute_parameter_bounds(self): 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): + """ + 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) + def test_is_coordinate_fixed(self): """ Test method for checking whether there are coordinates diff --git a/pyomo/contrib/pyros/uncertainty_sets.py b/pyomo/contrib/pyros/uncertainty_sets.py index bf0b2a4ce86..8fabbf0e320 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 @@ -49,7 +50,7 @@ ) -valid_num_types = tuple(native_numeric_types) +valid_num_types = native_numeric_types def standardize_uncertain_param_vars(obj, dim): @@ -218,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; @@ -241,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}" @@ -508,6 +512,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 @@ -558,23 +565,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}" ) @@ -583,16 +599,65 @@ 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) + + # log result + if not set_nonempty: + config.progress_logger.error( + "Nominal point is not within the uncertainty set. " + 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): @@ -735,6 +800,56 @@ def _compute_parameter_bounds(self, solver, index=None): 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 ): @@ -1097,10 +1212,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( @@ -1161,6 +1272,28 @@ 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"\nGot 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): """ @@ -1259,13 +1392,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 @@ -1298,13 +1424,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 @@ -1419,6 +1538,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): """ @@ -1464,6 +1620,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 @@ -1547,19 +1705,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 @@ -1640,6 +1785,43 @@ def set_as_constraint(self, uncertain_params=None, block=None): auxiliary_vars=aux_var_list, ) + 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): """ @@ -1778,38 +1960,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 @@ -1845,14 +1995,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 @@ -1926,6 +2068,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): """ @@ -2088,16 +2295,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 @@ -2117,12 +2314,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 @@ -2273,6 +2464,42 @@ 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): """ @@ -2375,14 +2602,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 @@ -2452,6 +2671,33 @@ 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): """ @@ -2526,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 @@ -2666,7 +2912,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 @@ -2681,12 +2926,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) @@ -2806,6 +3045,35 @@ 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): """ @@ -2974,6 +3242,30 @@ 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): """ @@ -3160,3 +3452,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) diff --git a/pyomo/contrib/pyros/util.py b/pyomo/contrib/pyros/util.py index 257d2529bf5..898935876de 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