Skip to content
Open
Show file tree
Hide file tree
Changes from 30 commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
53bbc30
Move bounds calculation to separate, cached method
jas-yao Mar 20, 2026
b6a03aa
Clear any cached bounds at set validation
jas-yao Mar 20, 2026
65e9564
Add tests checking caching.
jas-yao Mar 20, 2026
a483ab6
Fix _fbbt_parameter_bounds bound value issues
jas-yao Mar 20, 2026
549d5b8
Run black
jas-yao Mar 20, 2026
bb7fca5
Fix test comments
jas-yao Mar 20, 2026
a6ef4f5
Merge branch 'main' into pyros-cache-computed-param-bounds
jas-yao Mar 22, 2026
3a955f6
Update _solve_bounds_optimization docstring
jas-yao Mar 26, 2026
d003145
Merge branch 'main' into pyros-cache-computed-param-bounds
jsiirola Apr 2, 2026
c573512
Merge branch 'main' into pyros-cache-computed-param-bounds
jas-yao May 6, 2026
f0e86b6
Update bounds optimization caching
jas-yao May 6, 2026
2af61c8
Add test_solve_exact_bounds_optimization
jas-yao May 6, 2026
bb53c9d
Add test_fbbt_values
jas-yao May 6, 2026
be21c04
Move cache clearing to before/after solving
jas-yao May 6, 2026
7619f3e
Add tests for PyROS caching
jas-yao May 6, 2026
81a98ab
Run black
jas-yao May 6, 2026
4f78486
Run updated black
jas-yao May 6, 2026
581a9f7
Update CHANGELOG
jas-yao May 6, 2026
7aeeef5
Merge branch 'main' into pyros-cache-computed-param-bounds
jsiirola May 8, 2026
6de83b7
Merge branch 'main' into pyros-cache-computed-param-bounds
jas-yao May 11, 2026
31e96fd
Update caching with custom dict
jas-yao May 11, 2026
43f09d8
Simplify cache clearing setup
jas-yao May 11, 2026
ee87245
Update caching tests
jas-yao May 11, 2026
c1145cd
Run black
jas-yao May 11, 2026
d905ab4
Merge branch 'main' into pyros-cache-computed-param-bounds
jas-yao May 12, 2026
5518ee8
Use `var.lb` and `var.ub` for numerical bounds
jas-yao May 12, 2026
d954aba
Add assertion check for empty _cache
jas-yao May 12, 2026
c242b6a
Update caching tests for assertion error test
jas-yao May 12, 2026
e0863b0
Run black
jas-yao May 12, 2026
4ebca37
Merge branch 'main' into pyros-cache-computed-param-bounds
jsiirola May 14, 2026
6259595
Merge branch 'main' into pyros-cache-computed-param-bounds
jas-yao May 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions pyomo/contrib/pyros/CHANGELOG.txt
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@ PyROS CHANGELOG
===============


-------------------------------------------------------------------------------
PyROS 1.3.14 20 Mar 2026
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After PR #3951 is either merged or closed, you may update the PyROS version number (in pyros.py) and further update the changelog here according to this comment. In particular, if #3951 is closed rather than merged, then the final version number here will be 1.3.14.

-------------------------------------------------------------------------------
- Add caching for uncertainty set parameter bounds


-------------------------------------------------------------------------------
PyROS 1.3.13 16 Jan 2026
-------------------------------------------------------------------------------
Expand Down
11 changes: 7 additions & 4 deletions pyomo/contrib/pyros/pyros.py
Original file line number Diff line number Diff line change
Expand Up @@ -386,10 +386,13 @@ def solve(
)

model_data = ModelData(original_model=model, timing=TimingData(), config=None)
with time_code(
timing_data_obj=model_data.timing,
code_block_name="main",
is_main_timer=True,
with (
uncertainty_set._cache,
time_code(
timing_data_obj=model_data.timing,
code_block_name="main",
is_main_timer=True,
),
):
kwds.update(
dict(
Expand Down
249 changes: 248 additions & 1 deletion pyomo/contrib/pyros/tests/test_grcs.py
Original file line number Diff line number Diff line change
Expand Up @@ -3985,7 +3985,7 @@ def solve(self, model, *args, **kwargs):

class TestPyROSUnavailableSubsolvers(unittest.TestCase):
"""
Check that appropriate exceptionsa are raised if
Check that appropriate exceptions are raised if
PyROS is invoked with unavailable subsolvers.
"""

Expand Down Expand Up @@ -5232,5 +5232,252 @@ def test_discrete_set_subsolver_error_recovery(self, name, sec_con_UB):
)


# @SolverFactory.register("slow_solver")
class SlowSolver:
"""
Solver which sleeps for a specified time before solving.
"""

def __init__(self, sleep_time, sub_solver):
self.sleep_time = sleep_time
self.sub_solver = sub_solver

self.options = Bunch()

def available(self, exception_flag=True):
return True

def license_is_valid(self):
return True

def __enter__(self):
return self

def __exit__(self, et, ev, tb):
pass

def solve(self, model, **kwargs):
"""
Sleep, then solve a model.

Parameters
----------
model : ConcreteModel
Model of interest.

Returns
-------
results : SolverResults
Solver results.
"""

# ensure only one active objective
active_objs = [
obj for obj in model.component_data_objects(Objective, active=True)
]
assert len(active_objs) == 1

# sleep for specified time
time.sleep(self.sleep_time)

print("I slept. Now, I will solve.")
# invoke subsolver
results = self.sub_solver.solve(model, **kwargs)

return results


class CustomExactBoundsUncertaintySet(BoxSet):
"""
Custom uncertainty set that always solves optimization bounding problems.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider modifying this to be more precise about the "optimization bounding problems". Something to the effect of:

Suggested change
Custom uncertainty set that always solves optimization bounding problems.
A box set, modified such that the `parameter_bounds`
attribute calculation involves globally solving the coordinate
value bounding problems.

"""

def __init__(self, bounds, sleep_time, cache):
super().__init__(bounds)
self.sleep_time = sleep_time
self.cache = cache

@property
def parameter_bounds(self):
"""
Solve bounding problems to calculate exact parameter bounds.
"""
solver = SlowSolver(
sub_solver=SolverFactory("baron"), sleep_time=self.sleep_time
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are you sure that BARON is needed here? If so, then shouldn't the tests defined in TestPyROSCache be skipped unless BARON is available?

)
bounds = self._compute_exact_parameter_bounds(solver=solver)

if not self.cache:
self._cache.clear()

return bounds


@unittest.skipUnless(ipopt_available, "IPOPT is not available.")
class TestPyROSCache(unittest.TestCase):
"""
Test PyROS cache creation and clearing.
"""
Comment on lines +5317 to +5320
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider altering the name and docstring here to be more descriptive (of the "cache"). Perhaps something to the effect of

Suggested change
class TestPyROSCache(unittest.TestCase):
"""
Test PyROS cache creation and clearing.
"""
class TestPyROSCacheUncertaintySetBounds(unittest.TestCase):
"""
Test behavior of PyROS solver with respect to caching of
an uncertainty set's exact coordinate value bounds.
"""


def test_pyros_cache_creation(self):
"""
Check that PyROS creates a cache for storing computed exact parameter bounds.
"""
m = build_leyffer_two_cons()

# Define the uncertainty set
interval = CustomExactBoundsUncertaintySet(
bounds=[(0.25, 2)], sleep_time=0, cache=True
)

# Instantiate the PyROS solver
pyros_solver = SolverFactory("pyros")

# Define subsolvers utilized in the algorithm
local_subsolver = SolverFactory("ipopt")
global_subsolver = SolverFactory("ipopt")

# check cache exists
self.assertTrue(hasattr(interval, "_cache"))

# Call the PyROS solver
results = pyros_solver.solve(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since results is not used after this line:

Suggested change
results = pyros_solver.solve(
pyros_solver.solve(

model=m,
first_stage_variables=[m.x1],
second_stage_variables=[m.x2],
uncertain_params=[m.u],
uncertainty_set=interval,
local_solver=local_subsolver,
global_solver=global_subsolver,
options={
"objective_focus": ObjectiveType.worst_case,
"solve_master_globally": True,
},
)

# check cache has been cleared after solve
self.assertTrue(hasattr(interval, "_cache"))
self.assertEqual(
interval._cache, {}, msg="Did not clear uncertainty set cache after solve."
)

def test_pyros_cache_time(self):
"""
Check that caching improves solve time.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
Check that caching improves solve time.
Check that the PyROS solve time is improved when
the uncertainty set's exact coordinate value bounds are cached.

"""
m = build_leyffer_two_cons()

# Define the uncertainty set
interval_cache = CustomExactBoundsUncertaintySet(
bounds=[(0.25, 2)], sleep_time=0.1, cache=True
)
interval_no_cache = CustomExactBoundsUncertaintySet(
bounds=[(0.25, 2)], sleep_time=0.1, cache=False
)

# Instantiate the PyROS solver
pyros_solver = SolverFactory("pyros")

# Define subsolvers utilized in the algorithm
local_subsolver = SolverFactory("ipopt")
global_subsolver = SolverFactory("ipopt")

# Call the PyROS solver
results_cache = pyros_solver.solve(
model=m,
first_stage_variables=[m.x1],
second_stage_variables=[m.x2],
uncertain_params=[m.u],
uncertainty_set=interval_cache,
local_solver=local_subsolver,
global_solver=global_subsolver,
options={
"objective_focus": ObjectiveType.worst_case,
"solve_master_globally": True,
},
)

results_no_cache = pyros_solver.solve(
model=m,
first_stage_variables=[m.x1],
second_stage_variables=[m.x2],
uncertain_params=[m.u],
uncertainty_set=interval_no_cache,
local_solver=local_subsolver,
global_solver=global_subsolver,
options={
"objective_focus": ObjectiveType.worst_case,
"solve_master_globally": True,
},
)

# caching should always result in less time,
# as not caching reruns the slow solver multiple times
self.assertGreater(results_no_cache.time, results_cache.time)

def test_pyros_cache_solutions(self):
"""
Check that PyROS clears cache before/after and yields accurate results.
"""
Comment on lines +5418 to +5421
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test name and docstring here can be modified for consistency with what this test is checking. It seems this test checks that an AssertionError is raised by ContextDict.__enter__() if the cache is nonempty when PyROS is called.

Suggested change
def test_pyros_cache_solutions(self):
"""
Check that PyROS clears cache before/after and yields accurate results.
"""
def test_pyros_solve_assert_bounds_cache_empty(self):
"""
Check that calling PyROS on an uncertainty set
with a nonempty bounds cache results in an exception.
"""

m = build_leyffer_two_cons()

# Define the uncertainty set
interval = CustomExactBoundsUncertaintySet(
bounds=[(25, 200)], sleep_time=0, cache=True
)
self.assertEqual(interval.parameter_bounds, [(25, 200)])

# change set attributes, leading to outdated parameter bounds
interval.bounds = [(0.25, 2)]
self.assertEqual(interval.parameter_bounds, [(25, 200)])

# Instantiate the PyROS solver
pyros_solver = SolverFactory("pyros")

# Define subsolvers utilized in the algorithm
local_subsolver = SolverFactory("ipopt")
global_subsolver = SolverFactory("ipopt")

# Solve with PyROS
exc_str = r"Uncertainty set cache has been modified."
with self.assertRaisesRegex(AssertionError, exc_str):
results = pyros_solver.solve(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since results is not used after this line:

Suggested change
results = pyros_solver.solve(
pyros_solver.solve(

model=m,
first_stage_variables=[m.x1],
second_stage_variables=[m.x2],
uncertain_params=[m.u],
uncertainty_set=interval,
local_solver=local_subsolver,
global_solver=global_subsolver,
options={
"objective_focus": ObjectiveType.worst_case,
"solve_master_globally": True,
},
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You may also want to check here that state of the cache is unchanged by PyROS if the AssertionError is raised:

Suggested change
self.assertAlmostEqual(interval._cache[0, minimize], 25)
self.assertAlmostEqual(interval._cache[0, maximize], 200)

# modify the cache
interval._cache[0, minimize] = 25
interval._cache[0, maximize] = 200

self.assertEqual(interval.parameter_bounds, [(25, 200)])

# Solve with PyROS
exc_str = r"Uncertainty set cache has been modified."
with self.assertRaisesRegex(AssertionError, exc_str):
results = pyros_solver.solve(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since results is not used after this line:

Suggested change
results = pyros_solver.solve(
pyros_solver.solve(

model=m,
first_stage_variables=[m.x1],
second_stage_variables=[m.x2],
uncertain_params=[m.u],
uncertainty_set=interval,
local_solver=local_subsolver,
global_solver=global_subsolver,
options={
"objective_focus": ObjectiveType.worst_case,
"solve_master_globally": True,
},
)
Comment on lines +5458 to +5479
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems that the first two assignments here:

        interval._cache[0, minimize] = 25
        interval._cache[0, maximize] = 200

do not alter the state of interval._cache (from {(0, minimize): 25, (0, maximize): 200}) and that the AssertionError triggered by calling PyROS with a nonempty bounds cache was already tested for in the earlier lines. Consequently, are you sure that we need any of these additional checks?



if __name__ == "__main__":
unittest.main()
69 changes: 68 additions & 1 deletion pyomo/contrib/pyros/tests/test_uncertainty_sets.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
)

from pyomo.environ import SolverFactory
from pyomo.core.base import ConcreteModel, Param, Var
from pyomo.core.base import ConcreteModel, Param, Var, maximize, minimize, UnitInterval
from pyomo.core.expr import RangedExpression
from pyomo.core.expr.compare import assertExpressionsEqual

Expand Down Expand Up @@ -3736,6 +3736,37 @@ def parameter_bounds(self, val):
self._parameter_bounds = val


class CustomDomainUncertaintySet(CustomUncertaintySet):
"""
Test simple custom uncertainty set with specified uncertain parameter domains.
"""

def __init__(self, dim):
self._dim = dim
self._parameter_bounds = [(0, 1)] * self.dim

def set_as_constraint(self, uncertain_params=None, block=None):
blk, param_var_list, conlist, aux_vars = (
_setup_standard_uncertainty_set_constraint_block(
block=block,
uncertain_param_vars=uncertain_params,
dim=self.dim,
num_auxiliary_vars=None,
)
)
conlist.add(sum(param_var_list) <= 1)
for var in param_var_list:
conlist.add(0 <= var)
var.domain = UnitInterval

return UncertaintyQuantification(
block=blk,
uncertainty_cons=list(conlist.values()),
uncertain_param_vars=param_var_list,
auxiliary_vars=aux_vars,
)


class TestCustomUncertaintySet(unittest.TestCase):
"""
Test for a custom uncertainty set subclass.
Expand Down Expand Up @@ -3768,6 +3799,33 @@ def test_compute_exact_parameter_bounds(self):
custom_set._compute_exact_parameter_bounds(baron), [(-1, 1)] * 2
)

@unittest.skipUnless(baron_available, "BARON is not available")
def test_solve_exact_bounds_optimization(self):
"""
Test parameter bounds computations are cached.
"""
baron = SolverFactory("baron")
custom_set = CustomUncertaintySet(dim=2)

# check cache exists
self.assertTrue(hasattr(custom_set, "_cache"))

# check bounds calculation
self.assertEqual(
custom_set._solve_exact_bounds_optimization(
custom_set._create_bounding_model(), 0, minimize, baron
),
-1.0,
)

# check cache access
self.assertIs(
custom_set._solve_exact_bounds_optimization(
custom_set._create_bounding_model(), 0, minimize, baron
),
custom_set._cache[0, minimize],
)

@unittest.skipUnless(baron_available, "BARON is not available")
def test_solve_feasibility(self):
"""
Expand Down Expand Up @@ -3837,6 +3895,15 @@ def test_is_nonempty(self):
with self.assertRaisesRegex(ValueError, exc_str):
custom_set.is_nonempty(config=CONFIG)

def test_fbbt_values(self):
"""
Test that `_fbbt_parameter_bounds` returns correct values.
"""
CONFIG = pyros_config()
custom_set = CustomDomainUncertaintySet(dim=2)

self.assertEqual(custom_set._fbbt_parameter_bounds(config=CONFIG), [(0, 1)] * 2)

@unittest.skipUnless(baron_available, "BARON is not available")
def test_is_coordinate_fixed(self):
"""
Expand Down
Loading
Loading