From cb0a8e63ac7baebd4159675dab8beb48acee5a18 Mon Sep 17 00:00:00 2001 From: jasherma Date: Mon, 1 Jun 2026 17:37:49 -0400 Subject: [PATCH 1/7] Extend PyROS `CardinalitySet` to allow for negative deviations --- .../solvers/pyros/uncertainty_sets.rst | 4 +- pyomo/contrib/pyros/tests/test_grcs.py | 57 ++++ .../pyros/tests/test_uncertainty_sets.py | 197 +++++++++---- pyomo/contrib/pyros/uncertainty_sets.py | 260 ++++++++++++------ 4 files changed, 379 insertions(+), 139 deletions(-) diff --git a/doc/OnlineDocs/explanation/solvers/pyros/uncertainty_sets.rst b/doc/OnlineDocs/explanation/solvers/pyros/uncertainty_sets.rst index cb948b271de..2ee0ea2d67f 100644 --- a/doc/OnlineDocs/explanation/solvers/pyros/uncertainty_sets.rst +++ b/doc/OnlineDocs/explanation/solvers/pyros/uncertainty_sets.rst @@ -75,8 +75,8 @@ subclasses are provided below. - :math:`\begin{array}{l} q^{0} \in \mathbb{R}^{n}, \\ b \in \mathbb{R}_{+}^{L}, \\ B \in \{0, 1\}^{L \times n} \end{array}` - :math:`\left\{ q \in \mathbb{R}^{n} \middle| \begin{array}{l} \begin{pmatrix} B \\ -I \end{pmatrix} q \leq \begin{pmatrix} b + Bq^{0} \\ -q^{0} \end{pmatrix} \end{array} \right\}` * - :class:`~pyomo.contrib.pyros.uncertainty_sets.CardinalitySet` - - :math:`\begin{array}{l} q^{0} \in \mathbb{R}^{n}, \\ \hat{q} \in \mathbb{R}_{+}^{n}, \\ \Gamma \in [0, n] \end{array}` - - :math:`\left\{ q \in \mathbb{R}^{n} \middle| \begin{array}{l} \exists\,\xi \in [0, 1]^n\,:\\ \quad \,q = q^{0} + \hat{q} \circ \xi \\ \quad \displaystyle \sum_{i=1}^{n} \xi_{i} \leq \Gamma \end{array} \right\}` + - :math:`\begin{array}{l} q^{0} \in \mathbb{R}^{n}, \\ \hat{q}^+ \in \mathbb{R}_{+}^{n}, \\ \hat{q}^- \in \mathbb{R}_{+}^{n}, \\ \Gamma \in [0, n] \end{array}` + - :math:`\left\{ q \in \mathbb{R}^{n} \middle| \begin{array}{l} \exists\,\xi^+, \xi^- \in [0, 1]^n\,:\\ \quad \,q = q^{0} + \hat{q}^+ \circ \xi^+ - \hat{q}^- \circ \xi^- \\ \quad \displaystyle \sum_{i=1}^{n} (\xi_{i}^+ + \xi_{i}^-) \leq \Gamma \end{array} \right\}` * - :class:`~pyomo.contrib.pyros.uncertainty_sets.CartesianProductSet` - :math:`\begin{array}{l} \mathcal{Q}_{1} \subset \mathbb{R}^{n_1}, \\ \mathcal{Q}_{2} \subset \mathbb{R}^{n_2}, \\ \vdots \\ \mathcal{Q}_{m} \subset \mathbb{R}^{n_m} \end{array}` - :math:`\displaystyle \mathcal{Q}_{1} \times \mathcal{Q}_{2} \times \cdots \times \mathcal{Q}_{m}` diff --git a/pyomo/contrib/pyros/tests/test_grcs.py b/pyomo/contrib/pyros/tests/test_grcs.py index 8d059bb3b76..01a8f3b3a17 100644 --- a/pyomo/contrib/pyros/tests/test_grcs.py +++ b/pyomo/contrib/pyros/tests/test_grcs.py @@ -262,6 +262,63 @@ def build_leyffer_two_cons_two_params(): return m +class TestPyROSSolveCardinalitySet(unittest.TestCase): + """ + Test PyROS successfully solves model with cardinality-constrained + uncertainty set. + """ + + @unittest.skipUnless(ipopt_available, "IPOPT is not available.") + def test_cardinality_set_solve(self): + m = ConcreteModel() + m.q = Param(range(4), initialize=1, mutable=True) + m.x = Var(initialize=0, bounds=(0, None)) + m.obj = Objective(expr=m.x) + m.ineq_con = Constraint(expr=m.x >= m.q[0] + m.q[1] - m.q[2] - m.q[3]) + + cset = CardinalitySet( + origin=[1] * 4, positive_deviation=[1, 0, 2, 0.5], gamma=2 + ) + res = SolverFactory("pyros").solve( + model=m, + first_stage_variables=m.x, + second_stage_variables=[], + uncertain_params=m.q, + uncertainty_set=cset, + local_solver="ipopt", + global_solver="ipopt", + objective_focus="worst_case", + solve_master_globally=True, + ) + self.assertEqual(res.iterations, 2) + # worst-case objective is just maximum sum of uncertain + # parameters (per cardinality constraints) + self.assertAlmostEqual(res.final_objective_value, 1) + self.assertEqual( + res.pyros_termination_condition, pyrosTerminationCondition.robust_optimal + ) + + cset.negative_deviation = [0, 4.5, 0.5, 3] + res2 = SolverFactory("pyros").solve( + model=m, + first_stage_variables=m.x, + second_stage_variables=[], + uncertain_params=m.q, + uncertainty_set=cset, + local_solver="ipopt", + global_solver="ipopt", + objective_focus="worst_case", + solve_master_globally=True, + ) + self.assertEqual(res2.iterations, 2) + # worst-case objective changes due to + # change of maximum negative deviations + self.assertAlmostEqual(res2.final_objective_value, 4) + self.assertEqual( + res.pyros_termination_condition, pyrosTerminationCondition.robust_optimal + ) + + class TestPyROSSolveFactorModelSet(unittest.TestCase): """ Test PyROS successfully solves model with factor model uncertainty. diff --git a/pyomo/contrib/pyros/tests/test_uncertainty_sets.py b/pyomo/contrib/pyros/tests/test_uncertainty_sets.py index 9542dc875fe..247d48891e3 100644 --- a/pyomo/contrib/pyros/tests/test_uncertainty_sets.py +++ b/pyomo/contrib/pyros/tests/test_uncertainty_sets.py @@ -24,7 +24,7 @@ ) from pyomo.environ import SolverFactory -from pyomo.core.base import ConcreteModel, Param, Var, maximize, minimize, UnitInterval +from pyomo.core.base import ConcreteModel, Param, Var, minimize, UnitInterval from pyomo.core.expr import RangedExpression from pyomo.core.expr.compare import assertExpressionsEqual @@ -1462,7 +1462,7 @@ def test_set_as_constraint(self): set2=FactorModelSet( origin=[0, 0], number_of_factors=2, beta=0.75, psi_mat=[[1, 1], [1, 2]] ), - set3=CardinalitySet([-0.5, -0.5], [2, 2], 2), + set3=CardinalitySet([-0.5, -0.5], [2, 2], 2, [1.5, 0]), # ellipsoid. this is enclosed in all the other sets set4=AxisAlignedEllipsoidalSet([0, 0], [0.25, 0.25]), ) @@ -1471,7 +1471,7 @@ def test_set_as_constraint(self): self.assertIs(uq.block, m) self.assertEqual(uq.uncertain_param_vars, [m.v1, m.v2]) - self.assertEqual(len(uq.auxiliary_vars), 4) + self.assertEqual(len(uq.auxiliary_vars), 6) self.assertEqual(len(uq.uncertainty_cons), 9) # box set constraints @@ -1502,18 +1502,24 @@ def test_set_as_constraint(self): self.assertEqual(aux_vars[0].bounds, (-1, 1)) self.assertEqual(aux_vars[1].bounds, (-1, 1)) - # cardinality set constraints + # cardinality constraints assertExpressionsEqual( - self, uq.uncertainty_cons[5].expr, -0.5 + 2 * aux_vars[2] == m.v1 + self, + uq.uncertainty_cons[5].expr, + -0.5 + 2 * aux_vars[2] - 1.5 * aux_vars[4] == m.v1, ) assertExpressionsEqual( - self, uq.uncertainty_cons[6].expr, -0.5 + 2 * aux_vars[3] == m.v2 + self, + uq.uncertainty_cons[6].expr, + -0.5 + 2 * aux_vars[3] - 0.0 * aux_vars[5] == m.v2, ) assertExpressionsEqual( - self, uq.uncertainty_cons[7].expr, sum(aux_vars[2:4]) <= 2 + self, uq.uncertainty_cons[7].expr, sum(aux_vars[2:6]) <= 2 ) self.assertEqual(aux_vars[2].bounds, (0, 1)) - self.assertEqual(uq.auxiliary_vars[3].bounds, (0, 1)) + self.assertEqual(aux_vars[3].bounds, (0, 1)) + self.assertEqual(aux_vars[4].bounds, (0, 1)) + self.assertEqual(aux_vars[5].bounds, (0, 1)) # axis-aligned ellipsoid constraint assertExpressionsEqual( @@ -1601,7 +1607,7 @@ def test_point_in_set(self): # box vertex self.assertFalse(i_set.point_in_set([0.5, 0.5])) - # cardinality set origin and vertex of the box + # cardinality-constrained set origin and vertex of the box # are outside the ellipse self.assertFalse(i_set.point_in_set([-0.5, -0.5])) @@ -1722,13 +1728,13 @@ def test_intersection_aux_param_set(self): # auxiliary parameter value calculations np.testing.assert_allclose( - iset.compute_auxiliary_uncertain_param_vals([0, 0]), np.zeros(4) + iset.compute_auxiliary_uncertain_param_vals([0, 0]), np.zeros(6) ) # check uncertainty set constraints setup uq = iset.set_as_constraint() self.assertEqual(len(uq.uncertain_param_vars), 2) - self.assertEqual(len(uq.auxiliary_vars), 4) + self.assertEqual(len(uq.auxiliary_vars), 6) self.assertEqual(len(uq.uncertainty_cons), 6) param_vars = uq.uncertain_param_vars aux_vars = uq.auxiliary_vars @@ -1750,14 +1756,24 @@ def test_intersection_aux_param_set(self): ) # cardinality constraints assertExpressionsEqual( - self, uq.uncertainty_cons[3].expr, 0.0 + 0.8 * aux_vars[2] == param_vars[0] + self, + uq.uncertainty_cons[3].expr, + 0.0 + 0.8 * aux_vars[2] - 0.0 * aux_vars[4] == param_vars[0], ) assertExpressionsEqual( - self, uq.uncertainty_cons[4].expr, 0.0 + 0.8 * aux_vars[3] == param_vars[1] + self, + uq.uncertainty_cons[4].expr, + 0.0 + 0.8 * aux_vars[3] - 0.0 * aux_vars[5] == param_vars[1], ) assertExpressionsEqual( - self, uq.uncertainty_cons[5].expr, aux_vars[2] + aux_vars[3] <= 1 + self, uq.uncertainty_cons[5].expr, sum(aux_vars[2:6]) <= 1 ) + self.assertEqual(aux_vars[0].bounds, (-1, 1)) + self.assertEqual(aux_vars[1].bounds, (-1, 1)) + self.assertEqual(aux_vars[2].bounds, (0, 1)) + self.assertEqual(aux_vars[3].bounds, (0, 1)) + self.assertEqual(aux_vars[4].bounds, (0, 1)) + self.assertEqual(aux_vars[5].bounds, (0, 1)) def test_intersection_discrete_set(self): """ @@ -1827,18 +1843,21 @@ def test_normal_cardinality_construction_and_update(self): self.assertEqual(cset.type, "cardinality") self.assertEqual(cset.dim, 2) np.testing.assert_allclose( - cset.compute_auxiliary_uncertain_param_vals(cset.origin), [0] * 2 + cset.compute_auxiliary_uncertain_param_vals(cset.origin), [0] * 4 ) + np.testing.assert_equal(cset.negative_deviation, [0, 0]) # update the set cset.origin = [1, 2] cset.positive_deviation = [3, 0] cset.gamma = 0.5 + cset.negative_deviation = [0, -1.5] # check updates work np.testing.assert_allclose(cset.origin, [1, 2]) np.testing.assert_allclose(cset.positive_deviation, [3, 0]) np.testing.assert_allclose(cset.gamma, 0.5) + np.testing.assert_equal(cset.negative_deviation, [0, -1.5]) def test_error_on_cardinality_set_dim_change(self): """ @@ -1846,7 +1865,7 @@ def test_error_on_cardinality_set_dim_change(self): Test ValueError raised when attempting to alter the set dimension (i.e. number of entries of `origin`). """ - # construct a valid cardinality set + # construct a valid cardinality-constrained set cset = CardinalitySet(origin=[0, 0], positive_deviation=[1, 1], gamma=2) exc_str = r"Attempting to set.*dimension 2 to value of dimension 3" @@ -1856,30 +1875,50 @@ def test_error_on_cardinality_set_dim_change(self): cset.origin = [0, 0, 0] with self.assertRaisesRegex(ValueError, exc_str): cset.positive_deviation = [1, 1, 1] + with self.assertRaisesRegex(ValueError, exc_str): + cset.negative_deviation = [1, 2.5, 1] def test_set_as_constraint(self): """ Test method for setting up constraints works correctly. """ m = ConcreteModel() - cset = CardinalitySet([-0.5, 1, 2], [2.5, 3, 0], 1.5) + cset = CardinalitySet([-0.5, 1, 2], [2.5, 3, 0], 1.5, [1.5, 0, 1]) uq = cset.set_as_constraint(uncertain_params=None, block=m) self.assertEqual(len(uq.uncertainty_cons), 4) - self.assertEqual(len(uq.auxiliary_vars), 3) + self.assertEqual(len(uq.auxiliary_vars), 6) self.assertEqual(len(uq.uncertain_param_vars), 3) self.assertIs(uq.block, m) *hadamard_cons, gamma_con = uq.uncertainty_cons var1, var2, var3 = uq.uncertain_param_vars - auxvar1, auxvar2, auxvar3 = uq.auxiliary_vars + auxvars = uq.auxiliary_vars assertExpressionsEqual( - self, hadamard_cons[0].expr, -0.5 + 2.5 * auxvar1 == var1 + self, + hadamard_cons[0].expr, + -0.5 + 2.5 * auxvars[0] - 1.5 * auxvars[3] == var1, + ) + assertExpressionsEqual( + self, + hadamard_cons[1].expr, + 1.0 + 3.0 * auxvars[1] - 0.0 * auxvars[4] == var2, + ) + assertExpressionsEqual( + self, + hadamard_cons[2].expr, + 2.0 + 0.0 * auxvars[2] - 1.0 * auxvars[5] == var3, ) - assertExpressionsEqual(self, hadamard_cons[1].expr, 1.0 + 3.0 * auxvar2 == var2) - assertExpressionsEqual(self, hadamard_cons[2].expr, 2.0 + 0.0 * auxvar3 == var3) - assertExpressionsEqual(self, gamma_con.expr, auxvar1 + auxvar2 + auxvar3 <= 1.5) + assertExpressionsEqual(self, gamma_con.expr, sum(auxvars) <= 1.5) + + # check auxiliary variable bounds + self.assertEqual(auxvars[0].bounds, (0, 1)) + self.assertEqual(auxvars[3].bounds, (0, 1)) + self.assertEqual(auxvars[1].bounds, (0, 1)) + self.assertEqual(auxvars[4].bounds, (0, 1)) + self.assertEqual(auxvars[2].bounds, (0, 1)) + self.assertEqual(auxvars[5].bounds, (0, 1)) def test_set_as_constraint_dim_mismatch(self): """ @@ -1908,28 +1947,41 @@ def test_set_as_constraint_type_mismatch(self): def test_point_in_set(self): cset = CardinalitySet( - origin=[-0.5, 1, 2], positive_deviation=[2.5, 3, 0], gamma=1.5 + origin=[-0.5, 1, 2, 0], + positive_deviation=[2.5, 3, 0, 0], + gamma=1.5, + negative_deviation=[1.5, 0, 0, 3], ) + # origin: no deviations self.assertTrue(cset.point_in_set(cset.origin)) - # first param full deviation - self.assertTrue(cset.point_in_set([-0.5, 4, 2])) + self.assertTrue(cset.point_in_set([2, 1, 2, 0])) + self.assertTrue(cset.point_in_set([-2, 1, 2, 0])) # second param full deviation - self.assertTrue(cset.point_in_set([2, 1, 2])) + self.assertTrue(cset.point_in_set([-0.5, 4, 2, 0])) + # fourth param full deviation + self.assertTrue(cset.point_in_set([-0.5, 1, 2, -3])) # one and a half deviations (max) - self.assertTrue(cset.point_in_set([2, 2.5, 2])) + self.assertTrue(cset.point_in_set([2, 2.5, 2, 0])) + self.assertTrue(cset.point_in_set([-2, 2.5, 2, 0])) + self.assertTrue(cset.point_in_set([2, 1, 2, -1.5])) + self.assertTrue(cset.point_in_set([-2, 1, 2, -1.5])) + self.assertTrue(cset.point_in_set([-0.5, 4, 2, -1.5])) # over one and a half deviations; out of set - self.assertFalse(cset.point_in_set([2.05, 2.5, 2])) - self.assertFalse(cset.point_in_set([2, 2.55, 2])) + self.assertFalse(cset.point_in_set([2, 2.55, 2, 0])) + self.assertFalse(cset.point_in_set([-2, 2.55, 2, 0])) + self.assertFalse(cset.point_in_set([2, 1, 2, -1.55])) + self.assertFalse(cset.point_in_set([-2, 1, 2, -1.55])) + self.assertFalse(cset.point_in_set([-0.5, 4, 2, -1.55])) # deviation in dimension that has been fixed - self.assertFalse(cset.point_in_set([-0.25, 4, 2.01])) + self.assertFalse(cset.point_in_set([-0.5, 1, 2.1, 0])) # check what happens if dimensions are off with self.assertRaisesRegex(ValueError, ".*to match the set dimension.*"): - cset.point_in_set([1, 2, 3, 4]) + cset.point_in_set([1, 2, 3]) @unittest.skipUnless(baron_available, "BARON is not available.") def test_compute_exact_parameter_bounds(self): @@ -1937,10 +1989,13 @@ def test_compute_exact_parameter_bounds(self): Test parameter bounds computations give expected results. """ cset = CardinalitySet( - origin=[-0.5, 1, 2], positive_deviation=[2.5, 3, 0], gamma=1.5 + origin=[-0.5, 1, 2, 0], + positive_deviation=[2.5, 3, 0, 0], + gamma=1.5, + negative_deviation=[1.5, 0, 0, 3], ) computed_bounds = cset._compute_exact_parameter_bounds(SolverFactory("baron")) - np.testing.assert_allclose(computed_bounds, [[-0.5, 2], [1, 4], [2, 2]]) + np.testing.assert_allclose(computed_bounds, [[-2, 2], [1, 4], [2, 2], [-3, 0]]) np.testing.assert_allclose(computed_bounds, cset.parameter_bounds) def test_add_bounds_on_uncertain_parameters(self): @@ -1963,7 +2018,7 @@ def test_validate(self): """ CONFIG = Bunch() - # construct a valid cardinality set + # construct a valid cardinality-constrained set cardinality_set = CardinalitySet( origin=[0.0, 0.0], positive_deviation=[1.0, 1.0], gamma=2 ) @@ -1977,7 +2032,7 @@ def test_validate_finiteness(self): """ CONFIG = Bunch() - # construct a valid cardinality set + # construct a valid cardinality-constrained set cardinality_set = CardinalitySet( origin=[0.0, 0.0], positive_deviation=[1.0, 1.0], gamma=2 ) @@ -1997,30 +2052,37 @@ def test_validate_finiteness(self): with self.assertRaisesRegex(ValueError, exc_str): cardinality_set.validate(config=CONFIG) - def test_validate_pos_deviation(self): + def test_validate_deviations(self): """ - Test validate positive deviation check performs as expected. + Test validate positive deviation and negative deviation + checks performs as expected. """ CONFIG = Bunch() - # construct a valid cardinality set + # construct a valid cardinality-constrained set cardinality_set = CardinalitySet( origin=[0.0, 0.0], positive_deviation=[1.0, 1.0], gamma=2 ) - # check when deviation is negative - cardinality_set.positive_deviation[0] = -2 + # positive_deviation has negative entries + cardinality_set.positive_deviation[1] = -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) + # negative deviation has negative entries + cardinality_set.negative_deviation[0] = -1 + exc_str = r"Entry -1.0 of attribute 'negative_deviation' is negative value" + with self.assertRaisesRegex(ValueError, exc_str): + cardinality_set.validate(config=CONFIG) + def test_validate_gamma(self): """ Test validate gamma check performs as expected. """ CONFIG = Bunch() - # construct a valid cardinality set + # construct a valid cardinality-constrained set cardinality_set = CardinalitySet( origin=[0.0, 0.0], positive_deviation=[1.0, 1.0], gamma=2 ) @@ -2045,7 +2107,8 @@ def test_validate_gamma(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 + cardinality-constrained set. """ cardinality_set = CardinalitySet( origin=[0, 0], positive_deviation=[1, 1], gamma=2 @@ -3248,7 +3311,7 @@ def test_set_as_constraint(self): self.assertIs(uq.block, m) self.assertEqual(uq.uncertain_param_vars, list(m.v.values())) - self.assertEqual(len(uq.auxiliary_vars), 3) + self.assertEqual(len(uq.auxiliary_vars), 5) self.assertEqual(len(uq.uncertainty_cons), 8) # box constraint @@ -3271,18 +3334,24 @@ def test_set_as_constraint(self): ) self.assertEqual(aux_vars[0].bounds, (-1, 1)) - # cardinality set constraints + # cardinality constraints assertExpressionsEqual( - self, uq.uncertainty_cons[4].expr, -0.5 + 2 * aux_vars[1] == m.v[3] + self, + uq.uncertainty_cons[4].expr, + -0.5 + 2 * aux_vars[1] - 0.0 * aux_vars[3] == m.v[3], ) assertExpressionsEqual( - self, uq.uncertainty_cons[5].expr, -0.5 + 2 * aux_vars[2] == m.v[4] + self, + uq.uncertainty_cons[5].expr, + -0.5 + 2 * aux_vars[2] - 0.0 * aux_vars[4] == m.v[4], ) assertExpressionsEqual( - self, uq.uncertainty_cons[6].expr, sum(aux_vars[1:3]) <= 2 + self, uq.uncertainty_cons[6].expr, sum(aux_vars[1:5]) <= 2 ) self.assertEqual(aux_vars[1].bounds, (0, 1)) self.assertEqual(aux_vars[2].bounds, (0, 1)) + self.assertEqual(aux_vars[3].bounds, (0, 1)) + self.assertEqual(aux_vars[4].bounds, (0, 1)) # axis-aligned ellipsoid constraint assertExpressionsEqual( @@ -3623,7 +3692,7 @@ def test_compute_auxiliary_param_vals(self): FactorModelSet( origin=[0, 1], number_of_factors=1, beta=0.75, psi_mat=[[1], [4]] ), - CardinalitySet([-0.5, -0.5], [2, 2], 1), + CardinalitySet([-0.5, -0.5], [2, 2], 1, [1.5, 0]), AxisAlignedEllipsoidalSet([0, 0, 0], [0.25, 0.25, 0.25]), ] ) @@ -3631,58 +3700,70 @@ def test_compute_auxiliary_param_vals(self): cpset.compute_auxiliary_uncertain_param_vals( [0.5] + [0, 1] + [-0.5, -0.5] + [0, 0, 0] ), - [0, 0, 0], + [0] + [0, 0, 0, 0], ) # deviations from factor model origin np.testing.assert_allclose( cpset.compute_auxiliary_uncertain_param_vals( [0.5] + [0.75, 4] + [-0.5, -0.5] + [0, 0, 0] ), - [0.75, 0, 0], + [0.75] + [0, 0, 0, 0], ) np.testing.assert_allclose( cpset.compute_auxiliary_uncertain_param_vals( [0.5] + [-0.75, -2] + [-0.5, -0.5] + [0, 0, 0] ), - [-0.75, 0, 0], + [-0.75] + [0, 0, 0, 0], ) # deviations from cardinality origin np.testing.assert_allclose( cpset.compute_auxiliary_uncertain_param_vals( [0.5] + [0, 1] + [1.5, -0.5] + [0, 0, 0] ), - [0, 1, 0], + [0] + [1, 0, 0, 0], ) np.testing.assert_allclose( cpset.compute_auxiliary_uncertain_param_vals( [0.5] + [0, 1] + [-0.5, 1.5] + [0, 0, 0] ), - [0, 0, 1], + [0] + [0, 1, 0, 0], + ) + np.testing.assert_allclose( + cpset.compute_auxiliary_uncertain_param_vals( + [0.5] + [0, 1] + [-2, -0.5] + [0, 0, 0] + ), + [0] + [0, 0, 1, 0], ) # deviations from cardinality and factor model origins np.testing.assert_allclose( cpset.compute_auxiliary_uncertain_param_vals( [0.5] + [0.75, 4] + [-0.5, 1.5] + [0, 0, 0] ), - [0.75, 0, 1], + [0.75] + [0, 1, 0, 0], ) np.testing.assert_allclose( cpset.compute_auxiliary_uncertain_param_vals( [0.5] + [-0.75, -2] + [-0.5, 1.5] + [0, 0, 0] ), - [-0.75, 0, 1], + [-0.75] + [0, 1, 0, 0], ) np.testing.assert_allclose( cpset.compute_auxiliary_uncertain_param_vals( [0.5] + [0.75, 4] + [1.5, -0.5] + [0, 0, 0] ), - [0.75, 1, 0], + [0.75] + [1, 0, 0, 0], ) np.testing.assert_allclose( cpset.compute_auxiliary_uncertain_param_vals( [0.5] + [-0.75, -2] + [1.5, -0.5] + [0, 0, 0] ), - [-0.75, 1, 0], + [-0.75] + [1, 0, 0, 0], + ) + np.testing.assert_allclose( + cpset.compute_auxiliary_uncertain_param_vals( + [0.5] + [-0.75, -2] + [-2, -0.5] + [0, 0, 0] + ), + [-0.75] + [0, 0, 1, 0], ) diff --git a/pyomo/contrib/pyros/uncertainty_sets.py b/pyomo/contrib/pyros/uncertainty_sets.py index 6f5f564c2a7..ebf9cc88dc7 100644 --- a/pyomo/contrib/pyros/uncertainty_sets.py +++ b/pyomo/contrib/pyros/uncertainty_sets.py @@ -1475,61 +1475,103 @@ class CardinalitySet(UncertaintySet): origin : (N,) array_like Origin of the set (e.g., nominal uncertain parameter values). positive_deviation : (N,) array_like - Maximal non-negative coordinate deviation from the origin - in each dimension. + Upper bounds for absolute values of the positive coordinate + deviations from the origin. gamma : numeric type - Upper bound for the number of uncertain parameters which - may realize their maximal deviations from the origin - simultaneously. + Upper bound for the number of coordinates that can + simultaneously realize their maximal deviations from + the origin. Must be a numerical value ranging from 0 + to the set dimension `N`. + negative_deviation : (N,) array_like, optional + Upper bounds for absolute values of the negative coordinate + deviations from the origin. + If `None` is passed, then this argument is set to + an (`N`,) shaped array of zeros. Notes ----- - The :math:`n`-dimensional cardinality set is defined by + The :math:`n`-dimensional cardinality-constrained set is defined by .. math:: \\left\\{ q \\in \\mathbb{R}^n\\,\\middle| - \\,\\exists\\, \\xi \\in [0, 1]^n \\,:\\, + \\,\\exists\\, \\xi^+, \\xi^- \\in [0, 1]^n \\,:\\, \\left[ \\begin{array}{l} - q = q^0 + \\hat{q} \\circ \\xi \\\\ - \\displaystyle \\sum_{i=1}^n \\xi_i \\leq \\Gamma + q = q^0 + \\hat{q}^+ \\circ \\xi^+ - \\hat{q}^-\\xi^- \\\\ + \\displaystyle \\sum_{i=1}^n (\\xi_i^+ + \\xi_i^-) + \\leq \\Gamma \\end{array} \\right] \\right\\} in which :math:`q^\\text{0} \\in \\mathbb{R}^n` refers to ``origin``, - the quantity :math:`\\hat{q} \\in \\mathbb{R}_{+}^n` + the quantity :math:`\\hat{q}^+ \\in \\mathbb{R}_{+}^n` refers to ``positive_deviation``, - and :math:`\\Gamma \\in [0, n]` refers to ``gamma``. - The operator ":math:`\\circ`" denotes the element-wise product. + the quantity :math:`\\hat{q}^- \\in \\mathbb{R}_{+}^n` + refers to ``negative_deviation``, + and + :math:`\\Gamma \\in [0, n]` refers to ``gamma``. + + .. note:: + + If :math:`\\hat{q}^+ = \\hat{q}^-`, + then this set is mathematically equal to + + .. math:: + + \\left\\{ q \\in \\mathbb{R}^n\\,\\middle| + \\,\\exists\\, \\delta \\in [-1, 1]^n \\,:\\, + \\left[ + \\begin{array}{l} + q = q^0 + \\hat{q}^+ \\circ \\delta \\\\ + \\displaystyle \\sum_{i=1}^n |\\delta_i| + \\leq \\Gamma + \\end{array} + \\right] + \\right\\}, + + the cardinality-constrained set implicitly defined + in the popular robust optimization literature [1]_. + + References + ---------- + .. [1] D. Bertsimas and M. Sim. "The price of robustness", + *Operations research*, 52(1), 35-53, 2004. DOI + `10.1287/opre.1030.0065 `_. Examples -------- - A 3D cardinality set: + A 4D cardinality-constrained set: >>> from pyomo.contrib.pyros import CardinalitySet >>> gamma_set = CardinalitySet( - ... origin=[0, 0, 0], - ... positive_deviation=[1.0, 2.0, 1.5], + ... origin=[0, 0, 0, 0], + ... positive_deviation=[1.0, 2.0, 1.5, 0.0], ... gamma=1, + ... negative_deviation=[0.0, 2.0, 0.0, 5.0], ... ) >>> gamma_set.origin - array([0, 0, 0]) + array([0, 0, 0, 0]) >>> gamma_set.positive_deviation - array([1. , 2. , 1.5]) + array([1. , 2. , 1.5, 0. ]) >>> gamma_set.gamma 1 + >>> gamma_set.negative_deviation + array([0., 2., 0., 5.]) """ _PARAMETER_BOUNDS_EXACT = True - def __init__(self, origin, positive_deviation, gamma): + def __init__(self, origin, positive_deviation, gamma, negative_deviation=None): """Initialize self (see class docstring).""" self.origin = origin self.positive_deviation = positive_deviation self.gamma = gamma + if negative_deviation is None: + negative_deviation = np.zeros(self.dim) + self.negative_deviation = negative_deviation @property def type(self): @@ -1541,8 +1583,8 @@ def type(self): @property def origin(self): """ - (N,) numpy.ndarray : Origin of the cardinality set - (e.g. nominal parameter values). + (N,) numpy.ndarray : Origin of the cardinality-constrained set + (e.g., nominal parameter values). """ return self._origin @@ -1571,8 +1613,8 @@ def origin(self, val): @property def positive_deviation(self): """ - (N,) numpy.ndarray : Maximal coordinate deviations from the - origin in each dimension. All entries should be nonnegative. + (N,) numpy.ndarray : Upper bounds for absolute values of + the positive coordinate deviations from the origin. """ return self._positive_deviation @@ -1593,26 +1635,56 @@ def positive_deviation(self, val): if val_arr.size != self.dim: raise ValueError( "Attempting to set attribute 'positive_deviation' of " - f"cardinality set of dimension {self.dim} " + f"{CardinalitySet.__name__} of dimension {self.dim} " f"to value of dimension {val_arr.size}" ) self._positive_deviation = val_arr + @property + def negative_deviation(self): + """ + (N,) numpy.ndarray : Upper bounds for absolute values of + the negative coordinate deviations from the origin. + """ + return self._negative_deviation + + @negative_deviation.setter + def negative_deviation(self, val): + validate_array( + arr=val, + arr_name="negative_deviation", + dim=1, + valid_types=native_numeric_types, + valid_type_desc="a valid numeric type", + ) + + val_arr = np.array(val) + + # dimension of the set is immutable + if hasattr(self, "_origin"): + if val_arr.size != self.dim: + raise ValueError( + "Attempting to set attribute 'negative_deviation' of " + f"{CardinalitySet.__name__} of dimension {self.dim} " + f"to value of dimension {val_arr.size}" + ) + + self._negative_deviation = val_arr + @property def gamma(self): """ - numeric type : Upper bound for the number of uncertain - parameters that may maximally deviate from their respective - origin values simultaneously. Must be a numerical value ranging - from 0 to the set dimension `N`. + numeric type : Upper bound for the number of coordinates that + can simultaneously realize their maximal deviations from + the origin. Must be a numerical value ranging from 0 + to the set dimension `N`. Note that, mathematically, setting `gamma` to 0 reduces the set to a singleton containing the point represented by ``self.origin``, while setting `gamma` to the set dimension `N` makes the set mathematically equivalent - to a `BoxSet` with bounds - ``numpy.array([self.origin, self.origin + self.positive_deviation]).T``. + to a box set. """ return self._gamma @@ -1627,21 +1699,21 @@ def gamma(self, val): @property def dim(self): """ - int : Dimension `N` of the cardinality set. + int : Dimension `N` of the cardinality-constrained set. """ return len(self.origin) @property def geometry(self): """ - Geometry : Geometry of the cardinality set. + Geometry : Geometry of the cardinality-constrained set. """ return Geometry.LINEAR @property def parameter_bounds(self): """ - Bounds in each dimension of the cardinality set. + Bounds in each dimension of the cardinality-constrained set. Returns ------- @@ -1649,14 +1721,9 @@ def parameter_bounds(self): List, length `N`, of coordinate value (lower, upper) bound pairs. """ - nom_val = self.origin - deviation = self.positive_deviation - gamma = self.gamma - parameter_bounds = [ - (nom_val[i], nom_val[i] + min(gamma, 1) * deviation[i]) - for i in range(len(nom_val)) - ] - return parameter_bounds + lower_bounds = self.origin - min(self.gamma, 1) * self.negative_deviation + upper_bounds = self.origin + min(self.gamma, 1) * self.positive_deviation + return [(lb, ub) for lb, ub in zip(lower_bounds, upper_bounds)] @copy_docstring(UncertaintySet.set_as_constraint) def set_as_constraint(self, uncertain_params=None, block=None): @@ -1666,21 +1733,30 @@ def set_as_constraint(self, uncertain_params=None, block=None): block=block, uncertain_param_vars=uncertain_params, dim=self.dim, - num_auxiliary_vars=self.dim, + num_auxiliary_vars=2 * self.dim, ) ) - cardinality_zip = zip( - self.origin, self.positive_deviation, aux_var_list, param_var_data_list + card_enum = enumerate( + zip( + self.origin, + self.positive_deviation, + self.negative_deviation, + param_var_data_list, + aux_var_list[: self.dim], + aux_var_list[self.dim :], + ) ) - for orig_val, pos_dev, auxvar, param_var in cardinality_zip: - conlist.add(orig_val + pos_dev * auxvar == param_var) + for idx, (orig_val, pos_dev, neg_dev, param_var, *aux_pair) in card_enum: + pos_aux, neg_aux = aux_pair + # deviation constraint for the main parameter + conlist.add(orig_val + pos_dev * pos_aux - neg_dev * neg_aux == param_var) - conlist.add(quicksum(aux_var_list) <= self.gamma) + # set auxiliary variable bounds + pos_aux.bounds = (0, 1) + neg_aux.bounds = (0, 1) - for aux_var in aux_var_list: - aux_var.setlb(0) - aux_var.setub(1) + conlist.add(quicksum(aux_var_list) <= self.gamma) return UncertaintyQuantification( block=block, @@ -1701,19 +1777,32 @@ def compute_auxiliary_uncertain_param_vals(self, point, solver=None): required_shape_qual="to match the set dimension", ) point_arr = np.array(point) + aux_vals = np.zeros(2 * self.dim) + pos_aux_vals, neg_aux_vals = aux_vals[: self.dim], aux_vals[self.dim :] + point_in_set_tol = POINT_IN_UNCERTAINTY_SET_TOL - is_dev_nonzero = self.positive_deviation != 0 - aux_space_pt = np.empty(self.dim) - aux_space_pt[is_dev_nonzero] = ( - point_arr[is_dev_nonzero] - self.origin[is_dev_nonzero] - ) / self.positive_deviation[is_dev_nonzero] - aux_space_pt[self.positive_deviation == 0] = 0 + for idx, orig_val in enumerate(self.origin): + net_deviation = point_arr[idx] - orig_val + + # only the positive or the negative auxiliary variable + # is set to a nonzero value; the variable that gets set + # depends on the sign of the net deviation + max_abs_dev, aux_arr = ( + (self.positive_deviation[idx], pos_aux_vals) + if net_deviation >= 0 + else (self.negative_deviation[idx], neg_aux_vals) + ) + if max_abs_dev == 0: + aux_arr[idx] = 0 if abs(net_deviation) <= point_in_set_tol else np.nan + else: + aux_arr[idx] = abs(net_deviation) / max_abs_dev - return aux_space_pt + return aux_vals def point_in_set(self, point): """ - Determine whether a given point lies in the cardinality set. + Determine whether a given point lies in the + cardinality-constrained set. Parameters ---------- @@ -1722,15 +1811,20 @@ def point_in_set(self, point): Returns ------- - : bool + bool True if the point lies in the set, False otherwise. """ + tol = POINT_IN_UNCERTAINTY_SET_TOL aux_space_pt = self.compute_auxiliary_uncertain_param_vals(point) + deviations = ( + self.positive_deviation * aux_space_pt[: self.dim] + - self.negative_deviation * aux_space_pt[self.dim :] + ) return ( - np.all(point == self.origin + self.positive_deviation * aux_space_pt) - and aux_space_pt.sum() <= self.gamma - and np.all(0 <= aux_space_pt) - and np.all(aux_space_pt <= 1) + np.all(np.abs(point - (self.origin + deviations))) <= tol + and aux_space_pt.sum() <= self.gamma + tol + and np.all(-tol <= aux_space_pt) + and np.all(aux_space_pt <= 1 + tol) ) def validate(self, config): @@ -1745,45 +1839,53 @@ def validate(self, config): ``self.positive_deviation`` has negative values, or ``self.gamma`` is out of range). """ - orig_val = self.origin - pos_dev = self.positive_deviation - gamma = self.gamma - # check origin, positive deviation, and gamma are valid # this includes a finiteness check validate_array( - arr=orig_val, + arr=self.origin, arr_name="origin", dim=1, valid_types=native_numeric_types, valid_type_desc="a valid numeric type", ) validate_array( - arr=pos_dev, + arr=self.positive_deviation, + arr_name="positive_deviation", + dim=1, + valid_types=native_numeric_types, + valid_type_desc="a valid numeric type", + ) + validate_array( + arr=self.negative_deviation, arr_name="positive_deviation", dim=1, valid_types=native_numeric_types, valid_type_desc="a valid numeric type", ) validate_arg_type( - "gamma", gamma, native_numeric_types, "a valid numeric type", False + arg_name="gamma", + arg_val=self.gamma, + valid_types=native_numeric_types, + valid_type_desc="a valid numeric type", + is_entry_of_arg=False, ) - # 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 deviations are positive + for dev_pair in zip(self.positive_deviation, self.negative_deviation): + for dev_name, dev in zip(("positive", "negative"), dev_pair): + if dev < 0: + raise ValueError( + f"Entry {dev} of attribute '{dev_name}_deviation' " + f"is negative value" + ) # check gamma between 0 and n - if gamma < 0 or gamma > self.dim: + if self.gamma < 0 or self.gamma > self.dim: raise ValueError( - "Cardinality set attribute " + f"{CardinalitySet.__name__} attribute " f"'gamma' must be a real number between 0 and dimension " f"{self.dim} " - f"(provided value {gamma})" + f"(provided value {self.gamma})" ) From 6c56ce5733e91bdeb2da8c8ae736efd7679ff11f Mon Sep 17 00:00:00 2001 From: jasherma Date: Tue, 2 Jun 2026 10:31:19 -0400 Subject: [PATCH 2/7] Add efficient `CardinalitySet` auxiliary variable fixing --- .../solvers/pyros/uncertainty_sets.rst | 2 +- .../contrib/pyros/tests/test_uncertainty_sets.py | 16 ++++++++-------- pyomo/contrib/pyros/uncertainty_sets.py | 12 +++++++++++- 3 files changed, 20 insertions(+), 10 deletions(-) diff --git a/doc/OnlineDocs/explanation/solvers/pyros/uncertainty_sets.rst b/doc/OnlineDocs/explanation/solvers/pyros/uncertainty_sets.rst index 2ee0ea2d67f..c6551ca72fc 100644 --- a/doc/OnlineDocs/explanation/solvers/pyros/uncertainty_sets.rst +++ b/doc/OnlineDocs/explanation/solvers/pyros/uncertainty_sets.rst @@ -76,7 +76,7 @@ subclasses are provided below. - :math:`\left\{ q \in \mathbb{R}^{n} \middle| \begin{array}{l} \begin{pmatrix} B \\ -I \end{pmatrix} q \leq \begin{pmatrix} b + Bq^{0} \\ -q^{0} \end{pmatrix} \end{array} \right\}` * - :class:`~pyomo.contrib.pyros.uncertainty_sets.CardinalitySet` - :math:`\begin{array}{l} q^{0} \in \mathbb{R}^{n}, \\ \hat{q}^+ \in \mathbb{R}_{+}^{n}, \\ \hat{q}^- \in \mathbb{R}_{+}^{n}, \\ \Gamma \in [0, n] \end{array}` - - :math:`\left\{ q \in \mathbb{R}^{n} \middle| \begin{array}{l} \exists\,\xi^+, \xi^- \in [0, 1]^n\,:\\ \quad \,q = q^{0} + \hat{q}^+ \circ \xi^+ - \hat{q}^- \circ \xi^- \\ \quad \displaystyle \sum_{i=1}^{n} (\xi_{i}^+ + \xi_{i}^-) \leq \Gamma \end{array} \right\}` + - :math:`\left\{ q \in \mathbb{R}^{n} \middle| \begin{array}{l} \exists\,\xi^+, \xi^- \in [0, 1]^n\,:\\ \quad \,q = q^{0} + \hat{q}^+ \circ \xi^+ - \hat{q}^- \circ \xi^- \\ \quad \displaystyle \sum_{i=1}^{n} (\xi_{i}^+ + \xi_{i}^-) \leq \Gamma \\ \quad \xi_i^+ = 0 \quad\forall\, i : \hat{q}_i^+ = 0 \\ \quad \xi_i^- = 0 \quad\forall\, i : \hat{q}_i^- = 0 \end{array} \right\}` * - :class:`~pyomo.contrib.pyros.uncertainty_sets.CartesianProductSet` - :math:`\begin{array}{l} \mathcal{Q}_{1} \subset \mathbb{R}^{n_1}, \\ \mathcal{Q}_{2} \subset \mathbb{R}^{n_2}, \\ \vdots \\ \mathcal{Q}_{m} \subset \mathbb{R}^{n_m} \end{array}` - :math:`\displaystyle \mathcal{Q}_{1} \times \mathcal{Q}_{2} \times \cdots \times \mathcal{Q}_{m}` diff --git a/pyomo/contrib/pyros/tests/test_uncertainty_sets.py b/pyomo/contrib/pyros/tests/test_uncertainty_sets.py index 247d48891e3..593c5866a8a 100644 --- a/pyomo/contrib/pyros/tests/test_uncertainty_sets.py +++ b/pyomo/contrib/pyros/tests/test_uncertainty_sets.py @@ -1519,7 +1519,7 @@ def test_set_as_constraint(self): self.assertEqual(aux_vars[2].bounds, (0, 1)) self.assertEqual(aux_vars[3].bounds, (0, 1)) self.assertEqual(aux_vars[4].bounds, (0, 1)) - self.assertEqual(aux_vars[5].bounds, (0, 1)) + self.assertEqual(aux_vars[5].bounds, (0, 0)) # axis-aligned ellipsoid constraint assertExpressionsEqual( @@ -1772,8 +1772,8 @@ def test_intersection_aux_param_set(self): self.assertEqual(aux_vars[1].bounds, (-1, 1)) self.assertEqual(aux_vars[2].bounds, (0, 1)) self.assertEqual(aux_vars[3].bounds, (0, 1)) - self.assertEqual(aux_vars[4].bounds, (0, 1)) - self.assertEqual(aux_vars[5].bounds, (0, 1)) + self.assertEqual(aux_vars[4].bounds, (0, 0)) + self.assertEqual(aux_vars[5].bounds, (0, 0)) def test_intersection_discrete_set(self): """ @@ -1914,10 +1914,10 @@ def test_set_as_constraint(self): # check auxiliary variable bounds self.assertEqual(auxvars[0].bounds, (0, 1)) - self.assertEqual(auxvars[3].bounds, (0, 1)) self.assertEqual(auxvars[1].bounds, (0, 1)) - self.assertEqual(auxvars[4].bounds, (0, 1)) - self.assertEqual(auxvars[2].bounds, (0, 1)) + self.assertEqual(auxvars[2].bounds, (0, 0)) + self.assertEqual(auxvars[3].bounds, (0, 1)) + self.assertEqual(auxvars[4].bounds, (0, 0)) self.assertEqual(auxvars[5].bounds, (0, 1)) def test_set_as_constraint_dim_mismatch(self): @@ -3350,8 +3350,8 @@ def test_set_as_constraint(self): ) self.assertEqual(aux_vars[1].bounds, (0, 1)) self.assertEqual(aux_vars[2].bounds, (0, 1)) - self.assertEqual(aux_vars[3].bounds, (0, 1)) - self.assertEqual(aux_vars[4].bounds, (0, 1)) + self.assertEqual(aux_vars[3].bounds, (0, 0)) + self.assertEqual(aux_vars[4].bounds, (0, 0)) # axis-aligned ellipsoid constraint assertExpressionsEqual( diff --git a/pyomo/contrib/pyros/uncertainty_sets.py b/pyomo/contrib/pyros/uncertainty_sets.py index ebf9cc88dc7..a53d6e504e9 100644 --- a/pyomo/contrib/pyros/uncertainty_sets.py +++ b/pyomo/contrib/pyros/uncertainty_sets.py @@ -1500,7 +1500,11 @@ class CardinalitySet(UncertaintySet): \\begin{array}{l} q = q^0 + \\hat{q}^+ \\circ \\xi^+ - \\hat{q}^-\\xi^- \\\\ \\displaystyle \\sum_{i=1}^n (\\xi_i^+ + \\xi_i^-) - \\leq \\Gamma + \\leq \\Gamma \\\\ + \\xi_i^+ = 0 \\quad\\forall\\,i : + \\hat{q}_i^+ = 0 \\\\ + \\xi_i^- = 0 \\quad\\forall\\,i : + \\hat{q}_i^- = 0 \\end{array} \\right] \\right\\} @@ -1756,6 +1760,12 @@ def set_as_constraint(self, uncertain_params=None, block=None): pos_aux.bounds = (0, 1) neg_aux.bounds = (0, 1) + # fix aux vars by bounds if no deviations allowed + if pos_dev == 0: + pos_aux.bounds = (0, 0) + if neg_dev == 0: + neg_aux.bounds = (0, 0) + conlist.add(quicksum(aux_var_list) <= self.gamma) return UncertaintyQuantification( From 1e386e4fc9706338ba9978361e9d8683dd93e0bb Mon Sep 17 00:00:00 2001 From: John Siirola Date: Tue, 2 Jun 2026 21:54:39 -0600 Subject: [PATCH 3/7] Guard test that requires licensed BARON --- pyomo/contrib/pyros/tests/test_uncertainty_sets.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pyomo/contrib/pyros/tests/test_uncertainty_sets.py b/pyomo/contrib/pyros/tests/test_uncertainty_sets.py index 593c5866a8a..e53606769f4 100644 --- a/pyomo/contrib/pyros/tests/test_uncertainty_sets.py +++ b/pyomo/contrib/pyros/tests/test_uncertainty_sets.py @@ -1983,7 +1983,9 @@ def test_point_in_set(self): with self.assertRaisesRegex(ValueError, ".*to match the set dimension.*"): cset.point_in_set([1, 2, 3]) - @unittest.skipUnless(baron_available, "BARON is not available.") + @unittest.skipUnless( + baron_license_is_valid, "Global NLP solver is not available and licensed." + ) def test_compute_exact_parameter_bounds(self): """ Test parameter bounds computations give expected results. From 9cdf60217131bf4de36c71efc2ecfd28b8988154 Mon Sep 17 00:00:00 2001 From: jasherma Date: Wed, 3 Jun 2026 10:48:25 -0400 Subject: [PATCH 4/7] Update PyROS Bertsimas/Sim reference --- doc/OnlineDocs/reference/bibliography.rst | 4 ++++ pyomo/contrib/pyros/uncertainty_sets.py | 8 +------- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/doc/OnlineDocs/reference/bibliography.rst b/doc/OnlineDocs/reference/bibliography.rst index bafa85198da..274d09ce14e 100644 --- a/doc/OnlineDocs/reference/bibliography.rst +++ b/doc/OnlineDocs/reference/bibliography.rst @@ -97,6 +97,10 @@ Bibliography *SIAM Journal on Applied Mathematics* 23(1), 61-19, 1972. DOI `10.1137/0123007 `_ +.. [BS04] D. Bertsimas and M. Sim. "The price of robustness", + *Operations research*, 52(1), 35-53, 2004. DOI + `10.1287/opre.1030.0065 `_. + .. [Dje20] H. Djelassi. "Discretization-based algorithms for the global solution of hierarchical programs". Dissertation, Rheinisch-Westfälische Technische Hochschule Aachen, 2020. diff --git a/pyomo/contrib/pyros/uncertainty_sets.py b/pyomo/contrib/pyros/uncertainty_sets.py index a53d6e504e9..ee9a53423de 100644 --- a/pyomo/contrib/pyros/uncertainty_sets.py +++ b/pyomo/contrib/pyros/uncertainty_sets.py @@ -1537,13 +1537,7 @@ class CardinalitySet(UncertaintySet): \\right\\}, the cardinality-constrained set implicitly defined - in the popular robust optimization literature [1]_. - - References - ---------- - .. [1] D. Bertsimas and M. Sim. "The price of robustness", - *Operations research*, 52(1), 35-53, 2004. DOI - `10.1287/opre.1030.0065 `_. + in the popular robust optimization work [BS04]_. Examples -------- From 722d6d37bd32b22ad57303f18df71ad83f17ebe5 Mon Sep 17 00:00:00 2001 From: jasherma Date: Wed, 3 Jun 2026 10:55:59 -0400 Subject: [PATCH 5/7] Update `CardinalitySet` exception message class name refs --- pyomo/contrib/pyros/uncertainty_sets.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pyomo/contrib/pyros/uncertainty_sets.py b/pyomo/contrib/pyros/uncertainty_sets.py index ee9a53423de..ba891b304c6 100644 --- a/pyomo/contrib/pyros/uncertainty_sets.py +++ b/pyomo/contrib/pyros/uncertainty_sets.py @@ -1633,7 +1633,7 @@ def positive_deviation(self, val): if val_arr.size != self.dim: raise ValueError( "Attempting to set attribute 'positive_deviation' of " - f"{CardinalitySet.__name__} of dimension {self.dim} " + f"{type(self).__name__} of dimension {self.dim} " f"to value of dimension {val_arr.size}" ) @@ -1664,7 +1664,7 @@ def negative_deviation(self, val): if val_arr.size != self.dim: raise ValueError( "Attempting to set attribute 'negative_deviation' of " - f"{CardinalitySet.__name__} of dimension {self.dim} " + f"{type(self).__name__} of dimension {self.dim} " f"to value of dimension {val_arr.size}" ) @@ -1886,7 +1886,7 @@ def validate(self, config): # check gamma between 0 and n if self.gamma < 0 or self.gamma > self.dim: raise ValueError( - f"{CardinalitySet.__name__} attribute " + f"{type(self).__name__} attribute " f"'gamma' must be a real number between 0 and dimension " f"{self.dim} " f"(provided value {self.gamma})" From 88306bdf8b8136975c9c1c6b5ce15be64a257b9e Mon Sep 17 00:00:00 2001 From: jasherma Date: Wed, 3 Jun 2026 10:58:33 -0400 Subject: [PATCH 6/7] Simplify loop in `CardinalitySet.set_as_constraint()` --- pyomo/contrib/pyros/uncertainty_sets.py | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/pyomo/contrib/pyros/uncertainty_sets.py b/pyomo/contrib/pyros/uncertainty_sets.py index ba891b304c6..4b85f47b237 100644 --- a/pyomo/contrib/pyros/uncertainty_sets.py +++ b/pyomo/contrib/pyros/uncertainty_sets.py @@ -1735,18 +1735,15 @@ def set_as_constraint(self, uncertain_params=None, block=None): ) ) - card_enum = enumerate( - zip( - self.origin, - self.positive_deviation, - self.negative_deviation, - param_var_data_list, - aux_var_list[: self.dim], - aux_var_list[self.dim :], - ) + card_zip = zip( + self.origin, + self.positive_deviation, + self.negative_deviation, + param_var_data_list, + aux_var_list[: self.dim], + aux_var_list[self.dim :], ) - for idx, (orig_val, pos_dev, neg_dev, param_var, *aux_pair) in card_enum: - pos_aux, neg_aux = aux_pair + for orig_val, pos_dev, neg_dev, param_var, pos_aux, neg_aux in card_zip: # deviation constraint for the main parameter conlist.add(orig_val + pos_dev * pos_aux - neg_dev * neg_aux == param_var) From fc17ae0340eecbe68d360af109db2ee2325bfbbb Mon Sep 17 00:00:00 2001 From: jasherma Date: Wed, 3 Jun 2026 11:18:10 -0400 Subject: [PATCH 7/7] Update PyROS version number and changelog --- pyomo/contrib/pyros/CHANGELOG.txt | 6 ++++++ pyomo/contrib/pyros/pyros.py | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/pyomo/contrib/pyros/CHANGELOG.txt b/pyomo/contrib/pyros/CHANGELOG.txt index dd9a46df066..1ebf2bc60ce 100644 --- a/pyomo/contrib/pyros/CHANGELOG.txt +++ b/pyomo/contrib/pyros/CHANGELOG.txt @@ -3,6 +3,12 @@ PyROS CHANGELOG =============== +------------------------------------------------------------------------------- +PyROS 1.3.15 12 May 2026 +------------------------------------------------------------------------------- +- Extend `CardinalitySet` to allow for negative deviations + + ------------------------------------------------------------------------------- PyROS 1.3.14 12 May 2026 ------------------------------------------------------------------------------- diff --git a/pyomo/contrib/pyros/pyros.py b/pyomo/contrib/pyros/pyros.py index d62a97ad834..7013ca3fa24 100644 --- a/pyomo/contrib/pyros/pyros.py +++ b/pyomo/contrib/pyros/pyros.py @@ -31,7 +31,7 @@ ModelData, ) -__version__ = "1.3.14" +__version__ = "1.3.15" default_pyros_solver_logger = setup_pyros_logger()