Skip to content

Commit e8ed5dd

Browse files
authored
Merge pull request #3720 from shermanjasonaf/pyros-allow-no-vars-params
Ensure PyROS Supports Problems Containing No Variables/Uncertain Parameters
2 parents a212291 + 18d56e4 commit e8ed5dd

File tree

4 files changed

+240
-39
lines changed

4 files changed

+240
-39
lines changed

pyomo/contrib/pyros/CHANGELOG.txt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,12 @@ PyROS CHANGELOG
33
===============
44

55

6+
-------------------------------------------------------------------------------
7+
PyROS 1.3.10 24 Sep 2025
8+
-------------------------------------------------------------------------------
9+
- Ensure PyROS supports problems containing no variables/uncertain parameters
10+
11+
612
-------------------------------------------------------------------------------
713
PyROS 1.3.9 19 Jul 2025
814
-------------------------------------------------------------------------------

pyomo/contrib/pyros/pyros.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@
3333
)
3434

3535

36-
__version__ = "1.3.9"
36+
__version__ = "1.3.10"
3737

3838

3939
default_pyros_solver_logger = setup_pyros_logger()

pyomo/contrib/pyros/tests/test_grcs.py

Lines changed: 217 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -67,11 +67,15 @@
6767

6868
from pyomo.contrib.pyros.solve_data import ROSolveResults
6969
from pyomo.contrib.pyros.uncertainty_sets import (
70-
BoxSet,
70+
_setup_standard_uncertainty_set_constraint_block,
7171
AxisAlignedEllipsoidalSet,
72+
BoxSet,
73+
DiscreteScenarioSet,
7274
FactorModelSet,
75+
Geometry,
7376
IntersectionSet,
74-
DiscreteScenarioSet,
77+
UncertaintyQuantification,
78+
UncertaintySet,
7579
)
7680
from pyomo.contrib.pyros.util import (
7781
IterationLogRecord,
@@ -3771,6 +3775,217 @@ def test_pyros_write_separation_problem(self):
37713775
)
37723776

37733777

3778+
class ZeroDimensionalSet(UncertaintySet):
3779+
@property
3780+
def geometry(self):
3781+
return Geometry.LINEAR
3782+
3783+
@property
3784+
def parameter_bounds(self):
3785+
return []
3786+
3787+
@property
3788+
def dim(self):
3789+
return 0
3790+
3791+
@property
3792+
def type(self):
3793+
return "zero-d"
3794+
3795+
def validate(self, config):
3796+
pass
3797+
3798+
def point_in_set(self, point):
3799+
if list(point):
3800+
raise ValueError
3801+
return True
3802+
3803+
def set_as_constraint(self, uncertain_params=None, block=None):
3804+
block, params, cons, auxvars = _setup_standard_uncertainty_set_constraint_block(
3805+
block=block,
3806+
uncertain_param_vars=uncertain_params,
3807+
num_auxiliary_vars=None,
3808+
dim=0,
3809+
)
3810+
return UncertaintyQuantification(
3811+
block=block,
3812+
uncertain_param_vars=params,
3813+
auxiliary_vars=auxvars,
3814+
uncertainty_cons=list(cons.values()),
3815+
)
3816+
3817+
3818+
class TestPyROSNoVarsParams(unittest.TestCase):
3819+
"""
3820+
Test PyROS is capable of solving models without variables
3821+
or uncertain parameters.
3822+
"""
3823+
3824+
@unittest.skipUnless(ipopt_available, "IPOPT is not available")
3825+
def test_pyros_trivial_block(self):
3826+
"""
3827+
Test PyROS solver successfully operates on a model
3828+
with no variables, constraints, or uncertain parameters.
3829+
"""
3830+
mdl = ConcreteModel()
3831+
mdl.obj = Objective(expr=0)
3832+
3833+
# prepare solvers
3834+
ipopt = SolverFactory("ipopt")
3835+
pyros = SolverFactory("pyros")
3836+
3837+
with LoggingIntercept(level=logging.WARNING) as LOG:
3838+
res = pyros.solve(
3839+
model=mdl,
3840+
first_stage_variables=[],
3841+
second_stage_variables=[],
3842+
uncertain_params=[],
3843+
uncertainty_set=ZeroDimensionalSet(),
3844+
local_solver=ipopt,
3845+
global_solver=ipopt,
3846+
objective_focus="worst_case",
3847+
)
3848+
3849+
log_msg = LOG.getvalue()
3850+
self.assertRegex(
3851+
log_msg,
3852+
"NOTE: No variables.*appear in the active model objective.*constraints",
3853+
)
3854+
# need 2 iterations to satisfy epigraph constraint
3855+
# due to worst-case objective focus
3856+
self.assertEqual(res.iterations, 1)
3857+
self.assertAlmostEqual(res.final_objective_value, 0)
3858+
self.assertEqual(
3859+
res.pyros_termination_condition, pyrosTerminationCondition.robust_feasible
3860+
)
3861+
3862+
@parameterized.expand([[True], [False]])
3863+
@unittest.skipUnless(ipopt_available, "IPOPT is not available")
3864+
def test_pyros_only_state_vars(self, add_x_out_of_scope):
3865+
"""
3866+
Test PyROS solver successfully operates on a model with
3867+
no first-stage variables or second-stage variables in
3868+
the problem scope.
3869+
"""
3870+
mdl = ConcreteModel()
3871+
mdl.q = Param(initialize=0.5, mutable=True)
3872+
if add_x_out_of_scope:
3873+
mdl.x = Var(bounds=[1, 2])
3874+
mdl.y = Var(initialize=0.5)
3875+
mdl.eq = Constraint(expr=mdl.y == mdl.q)
3876+
mdl.obj = Objective(expr=mdl.y)
3877+
3878+
# prepare solvers
3879+
ipopt = SolverFactory("ipopt")
3880+
pyros = SolverFactory("pyros")
3881+
3882+
with LoggingIntercept(level=logging.WARNING) as LOG:
3883+
res = pyros.solve(
3884+
model=mdl,
3885+
# note: if 'x' was declared, then it is out of scope,
3886+
# (not in active objective or constraints)
3887+
# so still no DOF variables in scope
3888+
first_stage_variables=mdl.x if add_x_out_of_scope else [],
3889+
second_stage_variables=[],
3890+
uncertain_params=[mdl.q],
3891+
uncertainty_set=BoxSet([[0, 1]]),
3892+
local_solver=ipopt,
3893+
global_solver=ipopt,
3894+
objective_focus="worst_case",
3895+
)
3896+
3897+
log_msg = LOG.getvalue()
3898+
self.assertRegex(
3899+
log_msg, "NOTE: No user-provided first-stage variables or second-stage.*"
3900+
)
3901+
# need 2 iterations to satisfy epigraph constraint
3902+
# due to worst-case objective focus
3903+
self.assertEqual(res.iterations, 2)
3904+
self.assertAlmostEqual(res.final_objective_value, 1)
3905+
self.assertEqual(
3906+
res.pyros_termination_condition, pyrosTerminationCondition.robust_feasible
3907+
)
3908+
3909+
@parameterized.expand([[True], [False]])
3910+
@unittest.skipUnless(ipopt_available, "IPOPT is not available")
3911+
def test_pyros_no_vars(self, add_var_out_of_scope):
3912+
"""
3913+
Test PyROS solver successfully operates on a model with
3914+
no variables appearing in the active model objective
3915+
or constraints.
3916+
"""
3917+
mdl = ConcreteModel()
3918+
mdl.q = Param(initialize=0.5, mutable=True)
3919+
if add_var_out_of_scope:
3920+
# note: if declared, does not appear in active
3921+
# objective/constraints, so out of scope
3922+
mdl.x = Var(bounds=[1, mdl.q])
3923+
mdl.obj = Objective(expr=mdl.q)
3924+
3925+
# prepare solvers
3926+
ipopt = SolverFactory("ipopt")
3927+
pyros = SolverFactory("pyros")
3928+
3929+
with LoggingIntercept(level=logging.WARNING) as LOG:
3930+
res = pyros.solve(
3931+
model=mdl,
3932+
first_stage_variables=[],
3933+
second_stage_variables=[],
3934+
uncertain_params=[mdl.q],
3935+
uncertainty_set=BoxSet([[0, 1]]),
3936+
local_solver=ipopt,
3937+
global_solver=ipopt,
3938+
)
3939+
3940+
log_msg = LOG.getvalue()
3941+
self.assertRegex(
3942+
log_msg,
3943+
"NOTE: No variables.*appear in the active model objective.*constraints",
3944+
)
3945+
self.assertEqual(res.iterations, 1)
3946+
self.assertAlmostEqual(res.final_objective_value, 0.5)
3947+
self.assertEqual(
3948+
res.pyros_termination_condition, pyrosTerminationCondition.robust_feasible
3949+
)
3950+
3951+
@unittest.skipUnless(ipopt_available, "IPOPT is not available")
3952+
def test_pyros_no_uncertain_params(self):
3953+
"""
3954+
Test PyROS successfully operates on a model with no uncertain
3955+
parameters (zero-dimensional uncertainty set).
3956+
"""
3957+
3958+
m = ConcreteModel()
3959+
m.x = Var(bounds=(1, 2))
3960+
m.z = Var(bounds=(1, 2))
3961+
m.y = Var(bounds=(1, 2))
3962+
m.obj = Objective(expr=m.x**2 + m.z**2 + m.y**2)
3963+
3964+
ipopt = SolverFactory("ipopt")
3965+
pyros = SolverFactory("pyros")
3966+
3967+
res = pyros.solve(
3968+
model=m,
3969+
first_stage_variables=m.x,
3970+
second_stage_variables=m.z,
3971+
uncertain_params=[],
3972+
uncertainty_set=ZeroDimensionalSet(),
3973+
local_solver=ipopt,
3974+
global_solver=ipopt,
3975+
decision_rule_order=1,
3976+
)
3977+
3978+
# check results
3979+
self.assertEqual(res.iterations, 1)
3980+
self.assertAlmostEqual(res.final_objective_value, 3, places=6)
3981+
self.assertAlmostEqual(m.x.value, 1)
3982+
self.assertAlmostEqual(m.z.value, 1)
3983+
self.assertAlmostEqual(m.y.value, 1)
3984+
self.assertEqual(
3985+
res.pyros_termination_condition, pyrosTerminationCondition.robust_feasible
3986+
)
3987+
3988+
37743989
class TestPyROSSolverAdvancedValidation(unittest.TestCase):
37753990
"""
37763991
Test PyROS solver validation routines result in
@@ -3832,35 +4047,6 @@ def test_pyros_multiple_objectives(self):
38324047
global_solver=global_solver,
38334048
)
38344049

3835-
def test_pyros_empty_dof_vars(self):
3836-
"""
3837-
Test PyROS solver raises exception raised if there are no
3838-
first-stage variables or second-stage variables.
3839-
"""
3840-
# build model
3841-
mdl = self.build_simple_test_model()
3842-
3843-
# prepare solvers
3844-
pyros = SolverFactory("pyros")
3845-
local_solver = SimpleTestSolver()
3846-
global_solver = SimpleTestSolver()
3847-
3848-
# perform checks
3849-
exc_str = (
3850-
"Arguments `first_stage_variables` and "
3851-
"`second_stage_variables` are both empty lists."
3852-
)
3853-
with self.assertRaisesRegex(ValueError, exc_str):
3854-
pyros.solve(
3855-
model=mdl,
3856-
first_stage_variables=[],
3857-
second_stage_variables=[],
3858-
uncertain_params=[mdl.u],
3859-
uncertainty_set=BoxSet([[1 / 4, 2]]),
3860-
local_solver=local_solver,
3861-
global_solver=global_solver,
3862-
)
3863-
38644050
def test_pyros_overlap_dof_vars(self):
38654051
"""
38664052
Test PyROS solver raises exception raised if there are Vars

pyomo/contrib/pyros/util.py

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -815,13 +815,6 @@ def validate_variable_partitioning(model, config):
815815
overlap, or there are no first-stage variables
816816
and no second-stage variables.
817817
"""
818-
# at least one DOF required
819-
if not config.first_stage_variables and not config.second_stage_variables:
820-
raise ValueError(
821-
"Arguments `first_stage_variables` and "
822-
"`second_stage_variables` are both empty lists."
823-
)
824-
825818
# ensure no overlap between DOF var sets
826819
overlapping_vars = ComponentSet(config.first_stage_variables) & ComponentSet(
827820
config.second_stage_variables
@@ -864,6 +857,22 @@ def validate_variable_partitioning(model, config):
864857
second_stage_vars = ComponentSet(config.second_stage_variables) & active_model_vars
865858
state_vars = active_model_vars - (first_stage_vars | second_stage_vars)
866859

860+
if not active_model_vars:
861+
config.progress_logger.warning(
862+
"NOTE: No variables declared on the user-provided model "
863+
"appear in the active model objective or constraints. "
864+
"PyROS will proceed with solving for the optimal objective value, "
865+
"subject to the active declared constraints, "
866+
"according to the user-provided options."
867+
)
868+
elif not (first_stage_vars or second_stage_vars):
869+
config.progress_logger.warning(
870+
"NOTE: No user-provided first-stage variables or second-stage variables "
871+
"appear in the active model objective or constraints. "
872+
"PyROS will proceed with optimizing the state variables "
873+
"according to the user-provided options."
874+
)
875+
867876
return VariablePartitioning(
868877
list(first_stage_vars), list(second_stage_vars), list(state_vars)
869878
)

0 commit comments

Comments
 (0)