From caec18dfffda3164c71da824cf0338f587c942ba Mon Sep 17 00:00:00 2001 From: jasherma Date: Fri, 14 Nov 2025 04:02:19 -0500 Subject: [PATCH 1/9] Fix PyROS intersection set implementation --- pyomo/contrib/pyros/tests/test_grcs.py | 91 +++++++++++++ .../pyros/tests/test_uncertainty_sets.py | 123 ++++++++++++++++++ pyomo/contrib/pyros/uncertainty_sets.py | 100 ++++++++++++-- 3 files changed, 300 insertions(+), 14 deletions(-) diff --git a/pyomo/contrib/pyros/tests/test_grcs.py b/pyomo/contrib/pyros/tests/test_grcs.py index fe3b99864c0..d5702e96508 100644 --- a/pyomo/contrib/pyros/tests/test_grcs.py +++ b/pyomo/contrib/pyros/tests/test_grcs.py @@ -2069,6 +2069,97 @@ def test_two_stage_set_nonstatic_dr_robust_opt(self, use_discrete_set, dr_order) self.assertAlmostEqual(m.x.value, 2, places=4) self.assertAlmostEqual(m.z.value, 2, places=4) + @unittest.skipUnless(baron_available, "BARON is not available.") + def test_pyros_discrete_intersection(self): + """ + Test PyROS properly supports intersection set involving + discrete set. + """ + m = ConcreteModel() + m.q1 = Param(initialize=0.5, mutable=True) + m.q2 = Param(initialize=0.5, mutable=True) + m.x1 = Var(bounds=[0, 1]) + m.x2 = Var(bounds=[0, 1]) + m.obj = Objective(expr=m.x1 + m.x2) + m.con1 = Constraint(expr=m.x1 >= m.q1) + m.con2 = Constraint(expr=m.x2 >= m.q2) + iset = IntersectionSet( + set1=BoxSet(bounds=[[0, 2]] * 2), + set2=DiscreteScenarioSet([[0, 0], [0.5, 0.5], [1, 1], [3, 3]]), + ) + res = SolverFactory("pyros").solve( + model=m, + first_stage_variables=[m.x1, m.x2], + second_stage_variables=[], + uncertain_params=[m.q1, m.q2], + uncertainty_set=iset, + # note: using BARON, as this doesn't work with + # IPOPT. will be addressed when subproblem + # solve routines are refactored + local_solver="baron", + global_solver="baron", + solve_master_globally=True, + objective_focus="worst_case", + ) + self.assertEqual( + res.pyros_termination_condition, pyrosTerminationCondition.robust_optimal + ) + self.assertEqual(res.iterations, 2) + # check worst-case optimal solution + self.assertAlmostEqual(res.final_objective_value, 2) + self.assertAlmostEqual(m.x1.value, 1) + self.assertAlmostEqual(m.x2.value, 1) + + @unittest.skipUnless(baron_available, "BARON is not available.") + def test_pyros_intersection_aux_vars(self): + """ + Test PyROS properly supports intersection set + in which at least one of the intersected sets + is defined with auxiliary variables. + """ + m = ConcreteModel() + m.q1 = Param(initialize=0.5, mutable=True) + m.q2 = Param(initialize=0.5, mutable=True) + m.x1 = Var(bounds=[0, 1]) + m.x2 = Var(bounds=[0, 1]) + m.obj = Objective(expr=m.x1 + m.x2) + m.con1 = Constraint(expr=m.x1 >= m.q1) + m.con2 = Constraint(expr=m.x2 >= m.q2) + iset = IntersectionSet( + set1=AxisAlignedEllipsoidalSet(center=(0, 0), half_lengths=(1, 1)), + # factor model set requires auxiliary variables + set2=FactorModelSet( + origin=[0, 0], psi_mat=np.eye(2), number_of_factors=2, beta=0.5 + ), + ) + res = SolverFactory("pyros").solve( + model=m, + first_stage_variables=[m.x1, m.x2], + second_stage_variables=[], + uncertain_params=[m.q1, m.q2], + uncertainty_set=iset, + # note: using BARON instead of IPOPT. + # when IPOPT is used, this test will fail, + # as the discrete separation routine does not + # account for the case where there are no + # adjustable variables in the model + # (i.e. separation models without any variables). + # will be addressed later when the subproblem + # solve routines are refactored + local_solver="baron", + global_solver="baron", + solve_master_globally=True, + objective_focus="worst_case", + ) + self.assertEqual( + res.pyros_termination_condition, pyrosTerminationCondition.robust_optimal + ) + self.assertEqual(res.iterations, 3) + # check worst-case optimal solution + self.assertAlmostEqual(res.final_objective_value, 2, places=5) + self.assertAlmostEqual(m.x1.value, 1) + self.assertAlmostEqual(m.x2.value, 1) + @unittest.skipUnless(ipopt_available, "IPOPT not available.") class TestPyROSSeparationPriorityOrder(unittest.TestCase): diff --git a/pyomo/contrib/pyros/tests/test_uncertainty_sets.py b/pyomo/contrib/pyros/tests/test_uncertainty_sets.py index 920b22feaad..c521df07d10 100644 --- a/pyomo/contrib/pyros/tests/test_uncertainty_sets.py +++ b/pyomo/contrib/pyros/tests/test_uncertainty_sets.py @@ -1253,6 +1253,15 @@ def test_normal_construction_and_update(self): ), ) + # check geometry is as expected + self.assertIs(iset.geometry, Geometry.CONVEX_NONLINEAR) + + # since intersection does not involve discrete sets, + # expect error when trying to get scenarios + exc_str = r"Uncertainty set.*not reducible.*scenarios" + with self.assertRaisesRegex(ValueError, exc_str): + iset.scenarios + def test_error_on_intersecting_wrong_dims(self): """ Test ValueError raised if IntersectionSet sets @@ -1637,6 +1646,120 @@ def test_is_coordinate_fixed(self): iset._is_coordinate_fixed(config=Bunch(global_solver=baron)), [True, False] ) + @unittest.skipUnless(baron_available, "BARON not available") + def test_intersection_aux_param_set(self): + """ + Test intersection set involving at least one set + defined using auxiliary parameters. + """ + iset = IntersectionSet( + set1=FactorModelSet( + origin=[0, 0], psi_mat=np.eye(2), beta=0.2, number_of_factors=2 + ), + set2=CardinalitySet(origin=[0, 0], positive_deviation=[0.8, 0.8], gamma=1), + ) + + self.assertIs(iset.geometry, Geometry.LINEAR) + self.assertFalse(iset._PARAMETER_BOUNDS_EXACT) + + # parameter bound calculations + self.assertFalse(iset.parameter_bounds) + baron = SolverFactory("baron") + np.testing.assert_allclose( + iset._compute_exact_parameter_bounds(solver=baron), [(0, 0.4), (0, 0.4)] + ) + + # set membership checks + self.assertTrue(iset.point_in_set([0, 0])) + self.assertTrue(iset.point_in_set([0, 0.4])) + self.assertTrue(iset.point_in_set([0.4, 0])) + self.assertTrue(iset.point_in_set([0.2, 0.2])) + self.assertFalse(iset.point_in_set([0.2 + 1e-5, 0.2 + 1e-5])) + self.assertFalse(iset.point_in_set([-1e-5, -1e-5])) + + # auxiliary parameter value calculations + np.testing.assert_allclose( + iset.compute_auxiliary_uncertain_param_vals([0, 0]), np.zeros(4) + ) + + # 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.uncertainty_cons), 6) + param_vars = uq.uncertain_param_vars + aux_vars = uq.auxiliary_vars + # factor model constraints + assertExpressionsEqual( + self, + uq.uncertainty_cons[0].expr, + aux_vars[0] + 0.0 * aux_vars[1] == param_vars[0], + ) + assertExpressionsEqual( + self, + uq.uncertainty_cons[1].expr, + 0.0 * aux_vars[0] + aux_vars[1] == param_vars[1], + ) + assertExpressionsEqual( + self, + uq.uncertainty_cons[2].expr, + RangedExpression((-0.4, aux_vars[0] + aux_vars[1], 0.4), False), + ) + # cardinality constraints + assertExpressionsEqual( + self, uq.uncertainty_cons[3].expr, 0.0 + 0.8 * aux_vars[2] == param_vars[0] + ) + assertExpressionsEqual( + self, uq.uncertainty_cons[4].expr, 0.0 + 0.8 * aux_vars[3] == param_vars[1] + ) + assertExpressionsEqual( + self, uq.uncertainty_cons[5].expr, aux_vars[2] + aux_vars[3] <= 1 + ) + + def test_intersection_discrete_set(self): + """ + Test intersection set involving discrete set. + """ + iset = IntersectionSet( + set1=BoxSet(bounds=[[1.5, 2], [1.5, 2]]), + set2=AxisAlignedEllipsoidalSet(center=[1.5, 1.5], half_lengths=[0.5, 0.5]), + set3=DiscreteScenarioSet(list(it.product([1, 1.5, 2], [1, 1.5, 2]))), + set4=FactorModelSet( + origin=[1.5, 1.5], psi_mat=0.5 * np.eye(2), number_of_factors=2, beta=1 + ), + ) + + # test behavior resembles that of discrete set + self.assertIs(iset.geometry, Geometry.DISCRETE_SCENARIOS) + np.testing.assert_allclose(iset.scenarios, [[1.5, 1.5], [1.5, 2], [2, 1.5]]) + + # set membership checks + self.assertTrue(iset.point_in_set([1.5, 1.5])) + self.assertTrue(iset.point_in_set([1.5, 2])) + self.assertTrue(iset.point_in_set([2, 1.5])) + self.assertFalse(iset.point_in_set([1, 1])) + self.assertFalse(iset.point_in_set([1, 1.5])) + self.assertFalse(iset.point_in_set([1, 2])) + self.assertFalse(iset.point_in_set([2, 1])) + self.assertFalse(iset.point_in_set([2, 2])) + + # test bounds + np.testing.assert_allclose(iset.parameter_bounds, [[1.5, 2], [1.5, 2]]) + + # auxiliary param calculation: + # since there is a factor model set, should return values + np.testing.assert_allclose( + iset.compute_auxiliary_uncertain_param_vals([2, 2]), [1, 1] + ) + + # uncertainty set constraint setup + # since set is discrete, no constraints or auxiliary + # variables should be added + uq = iset.set_as_constraint() + self.assertEqual(len(uq.uncertain_param_vars), 2) + self.assertFalse(uq.auxiliary_vars) + self.assertFalse(uq.uncertainty_cons) + class TestCardinalitySet(unittest.TestCase): """ diff --git a/pyomo/contrib/pyros/uncertainty_sets.py b/pyomo/contrib/pyros/uncertainty_sets.py index d2b1474ee4b..c1a49b53455 100644 --- a/pyomo/contrib/pyros/uncertainty_sets.py +++ b/pyomo/contrib/pyros/uncertainty_sets.py @@ -21,6 +21,7 @@ import abc import math import functools +import itertools from numbers import Integral from collections import namedtuple from collections.abc import Iterable, MutableSequence @@ -1339,6 +1340,10 @@ def set_as_constraint(self, uncertain_params=None, block=None): auxiliary_vars=aux_var_list, ) + @copy_docstring(UncertaintySet.compute_auxiliary_uncertain_param_vals) + def compute_auxiliary_uncertain_param_vals(self, point, solver=None): + return np.array([]) + def validate(self, config): """ Check BoxSet validity. @@ -1879,6 +1884,10 @@ def set_as_constraint(self, uncertain_params=None, block=None): auxiliary_vars=aux_var_list, ) + @copy_docstring(UncertaintySet.compute_auxiliary_uncertain_param_vals) + def compute_auxiliary_uncertain_param_vals(self, point, solver=None): + return np.array([]) + def validate(self, config): """ Check PolyhedralSet validity. @@ -2196,6 +2205,10 @@ def parameter_bounds(self): def set_as_constraint(self, **kwargs): return PolyhedralSet.set_as_constraint(self, **kwargs) + @copy_docstring(UncertaintySet.compute_auxiliary_uncertain_param_vals) + def compute_auxiliary_uncertain_param_vals(self, point, solver=None): + return np.array([]) + def validate(self, config): """ Check BudgetSet validity. @@ -2893,6 +2906,10 @@ def set_as_constraint(self, uncertain_params=None, block=None): auxiliary_vars=aux_var_list, ) + @copy_docstring(UncertaintySet.compute_auxiliary_uncertain_param_vals) + def compute_auxiliary_uncertain_param_vals(self, point, solver=None): + return np.array([]) + def validate(self, config): """ Check AxisAlignedEllipsoidalSet validity. @@ -3316,6 +3333,10 @@ def set_as_constraint(self, uncertain_params=None, block=None): auxiliary_vars=aux_var_list, ) + @copy_docstring(UncertaintySet.compute_auxiliary_uncertain_param_vals) + def compute_auxiliary_uncertain_param_vals(self, point, solver=None): + return np.array([]) + def validate(self, config): """ Check EllipsoidalSet validity. @@ -3515,6 +3536,10 @@ def set_as_constraint(self, uncertain_params=None, block=None): auxiliary_vars=aux_var_list, ) + @copy_docstring(UncertaintySet.compute_auxiliary_uncertain_param_vals) + def compute_auxiliary_uncertain_param_vals(self, point, solver=None): + return np.array([]) + def point_in_set(self, point): """ Determine whether a given point lies in the discrete @@ -3672,17 +3697,56 @@ def geometry(self): Geometry of the intersection set. See the `Geometry` class documentation. """ - return max(self.all_sets[i].geometry.value for i in range(len(self.all_sets))) + return Geometry(max(uset.geometry.value for uset in self.all_sets)) + + @property + def scenarios(self): + """ + numpy.ndarray : If the set represented by `self` reduces to a + discrete uncertainty set, retrieve the scenarios comprising + the set. Otherwise, a ValueError is raised. + """ + if self.geometry == Geometry.DISCRETE_SCENARIOS: + discrete_intersection = functools.reduce(self.intersect, self.all_sets) + return discrete_intersection.scenarios + + raise ValueError( + "Uncertainty set represented by `self` is not reducible " + "to a finite set of scenarios." + ) + + @property + def _PARAMETER_BOUNDS_EXACT(self): + """ + bool : True if the coordinate value bounds returned by + ``self.parameter_bounds`` are exact + (i.e., specify the minimum bounding box), + False otherwise. + + For the intersection set, parameter bounds are exact only + if the intersection turns out to be a discrete set. + """ + return self.geometry == Geometry.DISCRETE_SCENARIOS @property def parameter_bounds(self): """ - Uncertain parameter value bounds for the intersection - set. + Compute parameter bounds of the intersection set. - Currently, an empty list, as the bounds cannot, in general, - be computed without access to an optimization solver. + Returns + ------- + : list of tuples + If one of the sets to be intersected is discrete, + then the bounds of the intersection set are returned + as a list, with length ``self.dim``, of 2-tuples. + Otherwise, an empty list is returned, as the bounds cannot, + in general, be computed without access to an optimization + solver. """ + if self._PARAMETER_BOUNDS_EXACT: + discrete_intersection = functools.reduce(self.intersect, self.all_sets) + return discrete_intersection.parameter_bounds + return [] def point_in_set(self, point): @@ -3699,10 +3763,7 @@ def point_in_set(self, point): : bool True if the point lies in the set, False otherwise. """ - if all(a_set.point_in_set(point=point) for a_set in self.all_sets): - return True - else: - return False + return all(a_set.point_in_set(point=point) for a_set in self.all_sets) # === Define pairwise intersection function @staticmethod @@ -3727,7 +3788,7 @@ def intersect(Q1, Q2): for set1, set2 in zip((Q1, Q2), (Q2, Q1)): if isinstance(set1, DiscreteScenarioSet): return DiscreteScenarioSet( - scenarios=[pt for pt in set1.scenarios if set1.point_in_set(pt)] + scenarios=[pt for pt in set1.scenarios if set2.point_in_set(pt)] ) # === This case is if both sets are continuous @@ -3744,14 +3805,15 @@ def set_as_constraint(self, uncertain_params=None, block=None): ) ) - intersection_set = functools.reduce(self.intersect, self.all_sets) - if isinstance(intersection_set, DiscreteScenarioSet): - return intersection_set.set_as_constraint( + # handle special case where the intersection is a discrete set + if self.geometry == Geometry.DISCRETE_SCENARIOS: + discrete_intersection = functools.reduce(self.intersect, self.all_sets) + return discrete_intersection.set_as_constraint( uncertain_params=uncertain_params, block=block ) all_cons, all_aux_vars = [], [] - for idx, unc_set in enumerate(intersection_set.all_sets): + for idx, unc_set in enumerate(self.all_sets): sub_block = Block() block.add_component( unique_component_name(block, f"sub_block_{idx}"), sub_block @@ -3769,6 +3831,16 @@ def set_as_constraint(self, uncertain_params=None, block=None): auxiliary_vars=all_aux_vars, ) + @copy_docstring(UncertaintySet.compute_auxiliary_uncertain_param_vals) + def compute_auxiliary_uncertain_param_vals(self, point, solver=None): + aux_param_vals_iter = itertools.chain( + *tuple( + uset.compute_auxiliary_uncertain_param_vals(point, solver=solver) + for uset in self.all_sets + ) + ) + return np.array(list(aux_param_vals_iter)) + def validate(self, config): """ Check IntersectionSet validity. From 3043dcccd4a5a94161f98e7ee5bf0186b75a9e9a Mon Sep 17 00:00:00 2001 From: jasherma Date: Fri, 14 Nov 2025 04:53:27 -0500 Subject: [PATCH 2/9] More thoroughly check inherited `UncertaintySet` attributes in unit tests --- .../pyros/tests/test_uncertainty_sets.py | 59 ++++++++++++++++++- 1 file changed, 57 insertions(+), 2 deletions(-) diff --git a/pyomo/contrib/pyros/tests/test_uncertainty_sets.py b/pyomo/contrib/pyros/tests/test_uncertainty_sets.py index c521df07d10..82274759b95 100644 --- a/pyomo/contrib/pyros/tests/test_uncertainty_sets.py +++ b/pyomo/contrib/pyros/tests/test_uncertainty_sets.py @@ -47,7 +47,6 @@ ) from pyomo.contrib.pyros.config import pyros_config -import time import logging @@ -106,6 +105,12 @@ def test_normal_construction_and_update(self): bounds, bset.bounds, err_msg="BoxSet bounds not as expected" ) + # check defined attributes/methods inherited from base class + self.assertIs(bset.geometry, Geometry.LINEAR) + self.assertEqual(bset.type, "box") + self.assertEqual(bset.dim, 2) + self.assertEqual(bset.compute_auxiliary_uncertain_param_vals([0, 0]).size, 0) + # check bounds update new_bounds = [[3, 4], [5, 6]] bset.bounds = new_bounds @@ -475,6 +480,12 @@ def test_normal_budget_construction_and_update(self): np.testing.assert_allclose([1, 3, 0, 0, 0], buset.rhs_vec) np.testing.assert_allclose(np.zeros(3), buset.origin) + # check defined attributes/methods inherited from base class + self.assertIs(buset.geometry, Geometry.LINEAR) + self.assertEqual(buset.type, "budget") + self.assertEqual(buset.dim, 3) + self.assertEqual(buset.compute_auxiliary_uncertain_param_vals([0] * 3).size, 0) + # update the set buset.budget_membership_mat = [[1, 1, 0], [0, 0, 1]] buset.budget_rhs_vec = [3, 4] @@ -815,6 +826,14 @@ def test_normal_factor_model_construction_and_update(self): np.testing.assert_allclose(fset.beta, 0.1) self.assertEqual(fset.dim, 3) + # check defined attributes/methods inherited from base class + self.assertIs(fset.geometry, Geometry.LINEAR) + self.assertEqual(fset.type, "factor_model") + self.assertEqual(fset.dim, 3) + np.testing.assert_allclose( + fset.compute_auxiliary_uncertain_param_vals(fset.origin), [0] * 2 + ) + # update the set fset.origin = [1, 1, 0] fset.psi_mat = [[1, 0], [0, 1], [1, 1]] @@ -1253,8 +1272,11 @@ def test_normal_construction_and_update(self): ), ) - # check geometry is as expected + # check defined attributes/methods inherited from base class self.assertIs(iset.geometry, Geometry.CONVEX_NONLINEAR) + self.assertEqual(iset.type, "intersection") + self.assertEqual(iset.dim, 3) + self.assertEqual(iset.compute_auxiliary_uncertain_param_vals([0] * 3).size, 0) # since intersection does not involve discrete sets, # expect error when trying to get scenarios @@ -1778,7 +1800,14 @@ def test_normal_cardinality_construction_and_update(self): np.testing.assert_allclose(cset.origin, [0, 0]) np.testing.assert_allclose(cset.positive_deviation, [1, 3]) np.testing.assert_allclose(cset.gamma, 2) + + # check defined attributes/methods inherited from base class + self.assertIs(cset.geometry, Geometry.LINEAR) + self.assertEqual(cset.type, "cardinality") self.assertEqual(cset.dim, 2) + np.testing.assert_allclose( + cset.compute_auxiliary_uncertain_param_vals(cset.origin), [0] * 2 + ) # update the set cset.origin = [1, 2] @@ -2031,6 +2060,12 @@ def test_normal_discrete_set_construction_and_update(self): # check scenarios added appropriately np.testing.assert_allclose(scenarios, dset.scenarios) + # check defined attributes/methods inherited from base class + self.assertIs(dset.geometry, Geometry.DISCRETE_SCENARIOS) + self.assertEqual(dset.type, "discrete") + self.assertEqual(dset.dim, 3) + self.assertEqual(dset.compute_auxiliary_uncertain_param_vals([0] * 3).size, 0) + # check scenarios updated appropriately new_scenarios = [[0, 1, 2], [1, 2, 0], [3, 5, 4]] dset.scenarios = new_scenarios @@ -2206,6 +2241,7 @@ def test_normal_construction_and_update(self): center = [0, 0] half_lengths = [1, 3] aset = AxisAlignedEllipsoidalSet(center, half_lengths) + np.testing.assert_allclose( center, aset.center, @@ -2217,6 +2253,12 @@ def test_normal_construction_and_update(self): err_msg="AxisAlignedEllipsoidalSet half-lengths not as expected", ) + # check defined attributes/methods inherited from base class + self.assertIs(aset.geometry, Geometry.CONVEX_NONLINEAR) + self.assertEqual(aset.type, "ellipsoidal") + self.assertEqual(aset.dim, 2) + self.assertEqual(aset.compute_auxiliary_uncertain_param_vals([0] * 2).size, 0) + # check attributes update new_center = [-1, -3] new_half_lengths = [0, 1] @@ -2432,6 +2474,13 @@ def test_normal_construction_and_update(self): shape_matrix = [[1, 0], [0, 2]] scale = 2 eset = EllipsoidalSet(center, shape_matrix, scale) + + # check defined attributes/methods inherited from base class + self.assertIs(eset.geometry, Geometry.CONVEX_NONLINEAR) + self.assertEqual(eset.type, "ellipsoidal") + self.assertEqual(eset.dim, 2) + self.assertEqual(eset.compute_auxiliary_uncertain_param_vals([0] * 2).size, 0) + np.testing.assert_allclose( center, eset.center, err_msg="EllipsoidalSet center not as expected" ) @@ -2848,6 +2897,12 @@ def test_normal_construction_and_update(self): pset = PolyhedralSet(lhs_coefficients_mat, rhs_vec) + # check defined attributes/methods inherited from base class + self.assertIs(pset.geometry, Geometry.LINEAR) + self.assertEqual(pset.type, "polyhedral") + self.assertEqual(pset.dim, 3) + self.assertEqual(pset.compute_auxiliary_uncertain_param_vals([0] * 3).size, 0) + # check attributes are as expected np.testing.assert_allclose(lhs_coefficients_mat, pset.coefficients_mat) np.testing.assert_allclose(rhs_vec, pset.rhs_vec) From 00ad6798122bbdebda831f65e510f5cfc24c7349 Mon Sep 17 00:00:00 2001 From: jasherma Date: Fri, 14 Nov 2025 05:29:50 -0500 Subject: [PATCH 3/9] Make `UncertaintySet.point_in_set()` more robust --- .../pyros/tests/test_uncertainty_sets.py | 20 ++++++++++++++ pyomo/contrib/pyros/uncertainty_sets.py | 26 ++++++++++++------- 2 files changed, 37 insertions(+), 9 deletions(-) diff --git a/pyomo/contrib/pyros/tests/test_uncertainty_sets.py b/pyomo/contrib/pyros/tests/test_uncertainty_sets.py index 82274759b95..72407bd2f76 100644 --- a/pyomo/contrib/pyros/tests/test_uncertainty_sets.py +++ b/pyomo/contrib/pyros/tests/test_uncertainty_sets.py @@ -1079,6 +1079,16 @@ def test_point_in_set(self): "is not in the set." ), ) + # test base class method, as well + self.assertTrue( + UncertaintySet.point_in_set(fset, fset_pt_from_crit), + msg=( + f"Base method {UncertaintySet.point_in_set.__name__!r} " + f"returns wrong result for point {fset_pt_from_crit}" + f"generated from critical point {aux_space_pt} of the " + "auxiliary variable space" + ), + ) fset_pt_from_neg_crit = fset.origin - fset.psi_mat @ aux_space_pt self.assertTrue( @@ -1089,6 +1099,16 @@ def test_point_in_set(self): "is not in the set." ), ) + # test base class method, as well + self.assertTrue( + UncertaintySet.point_in_set(fset, fset_pt_from_neg_crit), + msg=( + f"Base method {UncertaintySet.point_in_set.__name__!r} " + f"returns wrong result for point {fset_pt_from_neg_crit}" + f"generated from critical point {aux_space_pt} of the " + "auxiliary variable space" + ), + ) # some points transformed from hypercube vertices. # since F - k = 2 < 1 = k, no such point should be in the set diff --git a/pyomo/contrib/pyros/uncertainty_sets.py b/pyomo/contrib/pyros/uncertainty_sets.py index c1a49b53455..b9b51e16d77 100644 --- a/pyomo/contrib/pyros/uncertainty_sets.py +++ b/pyomo/contrib/pyros/uncertainty_sets.py @@ -735,19 +735,27 @@ def point_in_set(self, point): m = ConcreteModel() uncertainty_quantification = self.set_as_constraint(block=m) - for var, val in zip(uncertainty_quantification.uncertain_param_vars, point): + + main_vars = uncertainty_quantification.uncertain_param_vars + for var, val in zip(main_vars, point): var.set_value(val) - # since constraint expressions are relational, - # `value()` returns True if constraint satisfied, False else - # NOTE: this check may be inaccurate if there are auxiliary - # variables and they have not been initialized to - # feasible values - is_in_set = all( - value(con.expr) for con in uncertainty_quantification.uncertainty_cons + aux_vars = uncertainty_quantification.auxiliary_vars + aux_vals = self.compute_auxiliary_uncertain_param_vals(point) + for aux_var, aux_val in zip(aux_vars, aux_vals): + aux_var.set_value(aux_val) + + all_vars_within_bounds = all( + (var.lb is None or var.value - var.lb >= -POINT_IN_UNCERTAINTY_SET_TOL) + and (var.ub is None or var.ub - var.value >= -POINT_IN_UNCERTAINTY_SET_TOL) + for var in main_vars + aux_vars ) - return is_in_set + return all_vars_within_bounds and all( + con.lslack() > -POINT_IN_UNCERTAINTY_SET_TOL + and con.uslack() > -POINT_IN_UNCERTAINTY_SET_TOL + for con in uncertainty_quantification.uncertainty_cons + ) def _compute_exact_parameter_bounds(self, solver, index=None): """ From 0a362aabbf52d20e98286d934cbcb116a7c9deb3 Mon Sep 17 00:00:00 2001 From: jasherma Date: Fri, 14 Nov 2025 17:58:23 -0500 Subject: [PATCH 4/9] Tweak documentation of various uncertainty set attributes --- pyomo/contrib/pyros/uncertainty_sets.py | 63 +++++++++++-------------- 1 file changed, 27 insertions(+), 36 deletions(-) diff --git a/pyomo/contrib/pyros/uncertainty_sets.py b/pyomo/contrib/pyros/uncertainty_sets.py index b9b51e16d77..bbe86f45279 100644 --- a/pyomo/contrib/pyros/uncertainty_sets.py +++ b/pyomo/contrib/pyros/uncertainty_sets.py @@ -496,7 +496,7 @@ class UncertaintySet(object, metaclass=abc.ABCMeta): @abc.abstractmethod def dim(self): """ - Dimension of the uncertainty set (number of uncertain + int : Dimension of the uncertainty set (number of uncertain parameters in a corresponding optimization model of interest). """ raise NotImplementedError @@ -505,8 +505,7 @@ def dim(self): @abc.abstractmethod def geometry(self): """ - Geometry of the uncertainty set. See the `Geometry` class - documentation. + Geometry : Geometry of the uncertainty set. """ raise NotImplementedError @@ -519,7 +518,7 @@ def parameter_bounds(self): Returns ------- - : list of tuple + list of tuple If the bounds can be calculated, then the list is of length `N`, and each entry is a pair of numeric (lower, upper) bounds for the corresponding @@ -713,7 +712,7 @@ def point_in_set(self, point): Returns ------- - is_in_set : bool + bool True if the point lies in the uncertainty set, False otherwise. @@ -1306,8 +1305,7 @@ def dim(self): @property def geometry(self): """ - Geometry of the box set. - See the `Geometry` class documentation. + Geometry : Geometry of the box set. """ return Geometry.LINEAR @@ -1319,7 +1317,7 @@ def parameter_bounds(self): Returns ------- - : list of tuples + list of tuple List, length `N`, of 2-tuples. Each tuple specifies the bounds in its corresponding dimension. @@ -1550,8 +1548,7 @@ def dim(self): @property def geometry(self): """ - Geometry of the cardinality set. - See the `Geometry` class documentation. + Geometry : Geometry of the cardinality set. """ return Geometry.LINEAR @@ -1562,7 +1559,7 @@ def parameter_bounds(self): Returns ------- - : list of tuples + list of tuple List, length `N`, of 2-tuples. Each tuple specifies the bounds in its corresponding dimension. @@ -1858,8 +1855,7 @@ def dim(self): @property def geometry(self): """ - Geometry of the polyhedral set. - See the `Geometry` class documentation. + Geometry : Geometry of the polyhedral set. """ return Geometry.LINEAR @@ -2184,8 +2180,7 @@ def dim(self): @property def geometry(self): """ - Geometry of the budget set. - See the `Geometry` class documentation. + Geometry : Geometry of the budget set. """ return Geometry.LINEAR @@ -2196,7 +2191,7 @@ def parameter_bounds(self): Returns ------- - : list of tuples + list of tuple List, length `N`, of 2-tuples. Each tuple specifies the bounds in its corresponding dimension. @@ -2522,8 +2517,7 @@ def dim(self): @property def geometry(self): """ - Geometry of the factor model set. - See the `Geometry` class documentation. + Geometry : Geometry of the factor model set. """ return Geometry.LINEAR @@ -2534,7 +2528,7 @@ def parameter_bounds(self): Returns ------- - : list of tuples + list of tuple List, length `N`, of 2-tuples. Each tuple specifies the bounds in its corresponding dimension. @@ -2857,8 +2851,7 @@ def dim(self): @property def geometry(self): """ - Geometry of the axis-aligned ellipsoidal set. - See the `Geometry` class documentation. + Geometry : Geometry of the axis-aligned ellipsoidal set. """ return Geometry.CONVEX_NONLINEAR @@ -2869,7 +2862,7 @@ def parameter_bounds(self): Returns ------- - : list of tuples + list of tuple List, length `N`, of 2-tuples. Each tuple specifies the bounds in its corresponding dimension. @@ -3263,8 +3256,7 @@ def dim(self): @property def geometry(self): """ - Geometry of the ellipsoidal set. - See the `Geometry` class documentation. + Geometry : Geometry of the ellipsoidal set. """ return Geometry.CONVEX_NONLINEAR @@ -3275,7 +3267,7 @@ def parameter_bounds(self): Returns ------- - : list of tuples + list of tuple List, length `N`, of 2-tuples. Each tuple specifies the bounds in its corresponding dimension. @@ -3447,7 +3439,7 @@ def type(self): @property def scenarios(self): """ - list of tuples : Uncertain parameter realizations comprising the + list of tuple : Uncertain parameter realizations comprising the set. Each tuple is an uncertain parameter realization. Note that the `scenarios` attribute may be modified, but @@ -3488,8 +3480,7 @@ def dim(self): @property def geometry(self): """ - Geometry of the discrete scenario set. - See the `Geometry` class documentation. + Geometry : Geometry of the discrete scenario set. """ return Geometry.DISCRETE_SCENARIOS @@ -3500,7 +3491,7 @@ def parameter_bounds(self): Returns ------- - : list of tuples + list of tuple List, length `N`, of 2-tuples. Each tuple specifies the bounds in its corresponding dimension. @@ -3702,17 +3693,17 @@ def dim(self): @property def geometry(self): """ - Geometry of the intersection set. - See the `Geometry` class documentation. + Geometry : Geometry of the intersection set. """ return Geometry(max(uset.geometry.value for uset in self.all_sets)) @property def scenarios(self): """ - numpy.ndarray : If the set represented by `self` reduces to a - discrete uncertainty set, retrieve the scenarios comprising - the set. Otherwise, a ValueError is raised. + list of tuple : If the set represented by `self` reduces to a + discrete uncertainty set, then this attribute contains the + scenarios comprising the intersection. + Otherwise, a ValueError is raised. """ if self.geometry == Geometry.DISCRETE_SCENARIOS: discrete_intersection = functools.reduce(self.intersect, self.all_sets) @@ -3743,7 +3734,7 @@ def parameter_bounds(self): Returns ------- - : list of tuples + list of tuple If one of the sets to be intersected is discrete, then the bounds of the intersection set are returned as a list, with length ``self.dim``, of 2-tuples. @@ -3784,7 +3775,7 @@ def intersect(Q1, Q2): Parameters ---------- Q1, Q2 : UncertaintySet - Operand uncertainty sets. + Operand uncertainty set. Returns ------- From 1ef8fbcc666983fcb772d7a9ad8ec7719c223720 Mon Sep 17 00:00:00 2001 From: jasherma Date: Fri, 14 Nov 2025 18:04:21 -0500 Subject: [PATCH 5/9] Test `IntersectionSet` attribute repr --- pyomo/contrib/pyros/tests/test_uncertainty_sets.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pyomo/contrib/pyros/tests/test_uncertainty_sets.py b/pyomo/contrib/pyros/tests/test_uncertainty_sets.py index 72407bd2f76..4b72f994827 100644 --- a/pyomo/contrib/pyros/tests/test_uncertainty_sets.py +++ b/pyomo/contrib/pyros/tests/test_uncertainty_sets.py @@ -1448,6 +1448,8 @@ class behaves like a regular Python list. # assigning to slices should work fine all_sets[3:] = [BoxSet([[1, 1.5]]), BoxSet([[1, 3]])] + self.assertRegex(repr(all_sets), r"UncertaintySetList\(\[.*\]\)") + def test_set_as_constraint(self): """ Test method for setting up constraints works correctly. From 3fc25477997cb2ccd001808b6a37f422cf3a3e66 Mon Sep 17 00:00:00 2001 From: jasherma Date: Fri, 14 Nov 2025 18:30:44 -0500 Subject: [PATCH 6/9] Tweak new PyROS solver tests --- pyomo/contrib/pyros/tests/test_grcs.py | 23 ++++++++++------------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/pyomo/contrib/pyros/tests/test_grcs.py b/pyomo/contrib/pyros/tests/test_grcs.py index d5702e96508..340a058f7fd 100644 --- a/pyomo/contrib/pyros/tests/test_grcs.py +++ b/pyomo/contrib/pyros/tests/test_grcs.py @@ -2093,8 +2093,13 @@ def test_pyros_discrete_intersection(self): second_stage_variables=[], uncertain_params=[m.q1, m.q2], uncertainty_set=iset, - # note: using BARON, as this doesn't work with - # IPOPT. will be addressed when subproblem + # note: using BARON instead of IPOPT. + # when IPOPT is used, this test will fail, + # as the discrete separation routine does not + # account for the case where there are no + # adjustable variables in the model + # (i.e. separation models without any variables). + # will be addressed later when the subproblem # solve routines are refactored local_solver="baron", global_solver="baron", @@ -2110,7 +2115,7 @@ def test_pyros_discrete_intersection(self): self.assertAlmostEqual(m.x1.value, 1) self.assertAlmostEqual(m.x2.value, 1) - @unittest.skipUnless(baron_available, "BARON is not available.") + @unittest.skipUnless(ipopt_available, "IPOPT is not available.") def test_pyros_intersection_aux_vars(self): """ Test PyROS properly supports intersection set @@ -2138,16 +2143,8 @@ def test_pyros_intersection_aux_vars(self): second_stage_variables=[], uncertain_params=[m.q1, m.q2], uncertainty_set=iset, - # note: using BARON instead of IPOPT. - # when IPOPT is used, this test will fail, - # as the discrete separation routine does not - # account for the case where there are no - # adjustable variables in the model - # (i.e. separation models without any variables). - # will be addressed later when the subproblem - # solve routines are refactored - local_solver="baron", - global_solver="baron", + local_solver="ipopt", + global_solver="ipopt", solve_master_globally=True, objective_focus="worst_case", ) From a292315ab7c88f7e2796789c7dbf43dd49a7997f Mon Sep 17 00:00:00 2001 From: jasherma Date: Mon, 17 Nov 2025 20:20:01 -0500 Subject: [PATCH 7/9] Update version number, changelog --- pyomo/contrib/pyros/CHANGELOG.txt | 10 ++++++++++ pyomo/contrib/pyros/pyros.py | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/pyomo/contrib/pyros/CHANGELOG.txt b/pyomo/contrib/pyros/CHANGELOG.txt index 5010789eb2c..b5caefba2d0 100644 --- a/pyomo/contrib/pyros/CHANGELOG.txt +++ b/pyomo/contrib/pyros/CHANGELOG.txt @@ -3,6 +3,16 @@ PyROS CHANGELOG =============== +------------------------------------------------------------------------------- +PyROS 1.3.12 15 Nov 2025 +------------------------------------------------------------------------------- +- Fix implementation of PyROS IntersectionSet class, + particularly for cases involving discrete sets and/or sets defined + with auxiliary parameters +- Tweak and more thoroughly test implementations of common + uncertainty set attributes + + ------------------------------------------------------------------------------- PyROS 1.3.11 17 Oct 2025 ------------------------------------------------------------------------------- diff --git a/pyomo/contrib/pyros/pyros.py b/pyomo/contrib/pyros/pyros.py index 311bcfdd9f7..9724d472ca0 100644 --- a/pyomo/contrib/pyros/pyros.py +++ b/pyomo/contrib/pyros/pyros.py @@ -34,7 +34,7 @@ ) -__version__ = "1.3.11" +__version__ = "1.3.12" default_pyros_solver_logger = setup_pyros_logger() From 3dd0dd6b88c010140f20b59fd7a665cf08ce0834 Mon Sep 17 00:00:00 2001 From: jasherma Date: Thu, 20 Nov 2025 09:47:34 -0500 Subject: [PATCH 8/9] Modify docstrings of uncertainty set `parameter_bounds` attributes --- pyomo/contrib/pyros/uncertainty_sets.py | 65 +++++++++++-------------- 1 file changed, 29 insertions(+), 36 deletions(-) diff --git a/pyomo/contrib/pyros/uncertainty_sets.py b/pyomo/contrib/pyros/uncertainty_sets.py index bbe86f45279..1f8f6fa741b 100644 --- a/pyomo/contrib/pyros/uncertainty_sets.py +++ b/pyomo/contrib/pyros/uncertainty_sets.py @@ -518,11 +518,11 @@ def parameter_bounds(self): Returns ------- - list of tuple - If the bounds can be calculated, then the list is of - length `N`, and each entry is a pair of numeric - (lower, upper) bounds for the corresponding - (Cartesian) coordinate. Otherwise, the list is empty. + list[tuple[float, float]] + If the bounds can be calculated efficiently, then this list + should be of length ``self.dim`` and contain the + (lower, upper) bound pairs. + Otherwise, the list should be empty. """ raise NotImplementedError @@ -1317,10 +1317,9 @@ def parameter_bounds(self): Returns ------- - list of tuple - List, length `N`, of 2-tuples. Each tuple - specifies the bounds in its corresponding - dimension. + list[tuple[float, float]] + List, length `N`, of coordinate value + (lower, upper) bound pairs. """ return [tuple(bound) for bound in self.bounds] @@ -1559,10 +1558,9 @@ def parameter_bounds(self): Returns ------- - list of tuple - List, length `N`, of 2-tuples. Each tuple - specifies the bounds in its corresponding - dimension. + list[tuple[float, float]] + List, length `N`, of coordinate value + (lower, upper) bound pairs. """ nom_val = self.origin deviation = self.positive_deviation @@ -2191,10 +2189,9 @@ def parameter_bounds(self): Returns ------- - list of tuple - List, length `N`, of 2-tuples. Each tuple - specifies the bounds in its corresponding - dimension. + list[tuple[float, float]] + List, length `N`, of coordinate value + (lower, upper) bound pairs. """ bounds = [] for orig_val, col in zip(self.origin, self.budget_membership_mat.T): @@ -2528,10 +2525,9 @@ def parameter_bounds(self): Returns ------- - list of tuple - List, length `N`, of 2-tuples. Each tuple - specifies the bounds in its corresponding - dimension. + list[tuple[float, float]] + List, length `N`, of coordinate value + (lower, upper) bound pairs. """ F = self.number_of_factors psi_mat = self.psi_mat @@ -2862,10 +2858,9 @@ def parameter_bounds(self): Returns ------- - list of tuple - List, length `N`, of 2-tuples. Each tuple - specifies the bounds in its corresponding - dimension. + list[tuple[float, float]] + List, length `N`, of coordinate value + (lower, upper) bound pairs. """ nom_value = self.center half_length = self.half_lengths @@ -3267,10 +3262,9 @@ def parameter_bounds(self): Returns ------- - list of tuple - List, length `N`, of 2-tuples. Each tuple - specifies the bounds in its corresponding - dimension. + list[tuple[float, float]] + List, length `N`, of coordinate value + (lower, upper) bound pairs. """ scale = self.scale nom_value = self.center @@ -3491,10 +3485,9 @@ def parameter_bounds(self): Returns ------- - list of tuple - List, length `N`, of 2-tuples. Each tuple - specifies the bounds in its corresponding - dimension. + list[tuple[float, float]] + List, length `N`, of coordinate value + (lower, upper) bound pairs. """ parameter_bounds = [ (min(s[i] for s in self.scenarios), max(s[i] for s in self.scenarios)) @@ -3734,10 +3727,10 @@ def parameter_bounds(self): Returns ------- - list of tuple + list[tuple[float, float]] If one of the sets to be intersected is discrete, - then the bounds of the intersection set are returned - as a list, with length ``self.dim``, of 2-tuples. + then the list is of length ``self.dim`` and contains + the coordinate value (lower, upper) bound pairs. Otherwise, an empty list is returned, as the bounds cannot, in general, be computed without access to an optimization solver. From edf458c72080a6ea0a8eae627a5963660f4f21f6 Mon Sep 17 00:00:00 2001 From: jasherma Date: Mon, 24 Nov 2025 17:03:22 -0500 Subject: [PATCH 9/9] Update documented types for uncertainty set attributes --- pyomo/contrib/pyros/uncertainty_sets.py | 29 +++++++++++++------------ 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/pyomo/contrib/pyros/uncertainty_sets.py b/pyomo/contrib/pyros/uncertainty_sets.py index 1f8f6fa741b..69803265883 100644 --- a/pyomo/contrib/pyros/uncertainty_sets.py +++ b/pyomo/contrib/pyros/uncertainty_sets.py @@ -518,7 +518,7 @@ def parameter_bounds(self): Returns ------- - list[tuple[float, float]] + list[tuple[numbers.Real, numbers.Real]] If the bounds can be calculated efficiently, then this list should be of length ``self.dim`` and contain the (lower, upper) bound pairs. @@ -1317,7 +1317,7 @@ def parameter_bounds(self): Returns ------- - list[tuple[float, float]] + list[tuple[numbers.Real, numbers.Real]] List, length `N`, of coordinate value (lower, upper) bound pairs. """ @@ -1558,7 +1558,7 @@ def parameter_bounds(self): Returns ------- - list[tuple[float, float]] + list[tuple[numbers.Real, numbers.Real]] List, length `N`, of coordinate value (lower, upper) bound pairs. """ @@ -2189,7 +2189,7 @@ def parameter_bounds(self): Returns ------- - list[tuple[float, float]] + list[tuple[numbers.Real, numbers.Real]] List, length `N`, of coordinate value (lower, upper) bound pairs. """ @@ -2525,7 +2525,7 @@ def parameter_bounds(self): Returns ------- - list[tuple[float, float]] + list[tuple[numbers.Real, numbers.Real]] List, length `N`, of coordinate value (lower, upper) bound pairs. """ @@ -2858,7 +2858,7 @@ def parameter_bounds(self): Returns ------- - list[tuple[float, float]] + list[tuple[numbers.Real, numbers.Real]] List, length `N`, of coordinate value (lower, upper) bound pairs. """ @@ -3262,7 +3262,7 @@ def parameter_bounds(self): Returns ------- - list[tuple[float, float]] + list[tuple[numbers.Real, numbers.Real]] List, length `N`, of coordinate value (lower, upper) bound pairs. """ @@ -3433,8 +3433,9 @@ def type(self): @property def scenarios(self): """ - list of tuple : Uncertain parameter realizations comprising the - set. Each tuple is an uncertain parameter realization. + list[tuple[numbers.Real, ...]] : Uncertain parameter + realizations comprising the set. Each tuple is an uncertain + parameter realization. Note that the `scenarios` attribute may be modified, but only such that the dimension of the set remains unchanged. @@ -3485,7 +3486,7 @@ def parameter_bounds(self): Returns ------- - list[tuple[float, float]] + list[tuple[numbers.Real, numbers.Real]] List, length `N`, of coordinate value (lower, upper) bound pairs. """ @@ -3693,9 +3694,9 @@ def geometry(self): @property def scenarios(self): """ - list of tuple : If the set represented by `self` reduces to a - discrete uncertainty set, then this attribute contains the - scenarios comprising the intersection. + list[tuple[numbers.Real, ...]] : If the set represented by + `self` reduces to a discrete uncertainty set, then this attribute + contains the scenarios that comprise the set. Otherwise, a ValueError is raised. """ if self.geometry == Geometry.DISCRETE_SCENARIOS: @@ -3727,7 +3728,7 @@ def parameter_bounds(self): Returns ------- - list[tuple[float, float]] + list[tuple[numbers.Real, numbers.Real]] If one of the sets to be intersected is discrete, then the list is of length ``self.dim`` and contains the coordinate value (lower, upper) bound pairs.