Skip to content

Commit 1fd537d

Browse files
authored
Merge branch 'main' into indexed-components-as-numeric
2 parents 631b2a7 + 43b925e commit 1fd537d

File tree

5 files changed

+191
-37
lines changed

5 files changed

+191
-37
lines changed

pyomo/contrib/fbbt/tests/test_fbbt.py

+4-4
Original file line numberDiff line numberDiff line change
@@ -469,8 +469,8 @@ def test_pow4(self):
469469
else:
470470
xu = m.x.ub
471471
_x = np.exp(np.log(y) / _exp_val)
472-
self.assertTrue(np.all(xl <= _x))
473-
self.assertTrue(np.all(xu >= _x))
472+
self.assertTrue(np.all(xl - 1e-14 <= _x))
473+
self.assertTrue(np.all(xu + 1e-14 >= _x))
474474

475475
def test_sqrt(self):
476476
m = pyo.ConcreteModel()
@@ -640,8 +640,8 @@ def test_log(self):
640640
else:
641641
xu = pyo.value(m.x.ub)
642642
x = np.exp(z)
643-
self.assertTrue(np.all(xl <= x))
644-
self.assertTrue(np.all(xu >= x))
643+
self.assertTrue(np.all(xl - 1e-14 <= x))
644+
self.assertTrue(np.all(xu + 1e-14 >= x))
645645

646646
def test_log10(self):
647647
if not numpy_available:

pyomo/contrib/pyros/CHANGELOG.txt

+6
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@
22
PyROS CHANGELOG
33
===============
44

5+
-------------------------------------------------------------------------------
6+
PyROS 1.1.2 31 May 2022
7+
-------------------------------------------------------------------------------
8+
- Fixes to PyROS ellipsoidal sets.
9+
10+
511
-------------------------------------------------------------------------------
612
PyROS 1.1.1 25 Apr 2022
713
-------------------------------------------------------------------------------

pyomo/contrib/pyros/pyros.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@
4545
from pyomo.contrib.pyros.uncertainty_sets import uncertainty_sets
4646
from pyomo.core.base import Constraint
4747

48-
__version__ = "1.1.1"
48+
__version__ = "1.1.2"
4949

5050
def NonNegIntOrMinusOne(obj):
5151
'''

pyomo/contrib/pyros/tests/test_grcs.py

+123
Original file line numberDiff line numberDiff line change
@@ -673,6 +673,33 @@ def test_add_bounds_on_uncertain_parameters(self):
673673
self.assertNotEqual(m.util.uncertain_param_vars[1].ub, None,
674674
"Bounds not added correctly for EllipsoidalSet")
675675

676+
def test_ellipsoidal_set_bounds(self):
677+
"""Check `EllipsoidalSet` parameter bounds method correct."""
678+
cov = [[2, 1], [1, 2]]
679+
scales=[0.5, 2]
680+
mean = [1, 1]
681+
682+
for scale in scales:
683+
ell = EllipsoidalSet(center=mean, shape_matrix=cov, scale=scale)
684+
bounds = ell.parameter_bounds
685+
actual_bounds = list()
686+
for idx, val in enumerate(mean):
687+
diff = (cov[idx][idx] * scale) ** 0.5
688+
actual_bounds.append((val - diff, val + diff))
689+
self.assertTrue(
690+
np.allclose(
691+
np.array(bounds),
692+
np.array(actual_bounds),
693+
),
694+
msg=(
695+
f"EllipsoidalSet bounds {bounds} do not match their actual"
696+
f" values {actual_bounds} (for scale {scale}"
697+
f" and shape matrix {cov})."
698+
" Check the `parameter_bounds`"
699+
" method for the EllipsoidalSet."
700+
),
701+
)
702+
676703
class testAxisAlignedEllipsoidalUncertaintySetClass(unittest.TestCase):
677704
'''
678705
Axis aligned ellipsoidal uncertainty sets. Required inputs are half-lengths, nominal point, and right-hand side.
@@ -747,6 +774,102 @@ def test_add_bounds_on_uncertain_parameters(self):
747774
self.assertNotEqual(m.util.uncertain_param_vars[1].lb, None, "Bounds not added correctly for AxisAlignedEllipsoidalSet")
748775
self.assertNotEqual(m.util.uncertain_param_vars[1].ub, None, "Bounds not added correctly for AxisAlignedEllipsoidalSet")
749776

777+
def test_set_with_zero_half_lengths(self):
778+
# construct ellipsoid
779+
half_lengths = [1, 0, 2, 0]
780+
center = [1, 1, 1, 1]
781+
ell = AxisAlignedEllipsoidalSet(center, half_lengths)
782+
783+
# construct model
784+
m = ConcreteModel()
785+
m.v1 = Var()
786+
m.v2 = Var([1, 2])
787+
m.v3 = Var()
788+
789+
# test constraints
790+
conlist = ell.set_as_constraint([m.v1, m.v2, m.v3])
791+
eq_cons = [con for con in conlist.values() if con.equality]
792+
793+
self.assertEqual(
794+
len(conlist),
795+
3,
796+
msg=(
797+
"Constraint list for this `AxisAlignedEllipsoidalSet` should"
798+
f" be of length 3, but is of length {len(conlist)}"
799+
),
800+
)
801+
self.assertEqual(
802+
len(eq_cons),
803+
2,
804+
msg=(
805+
"Number of equality constraints for this"
806+
"`AxisAlignedEllipsoidalSet` should be 2,"
807+
f" there are {len(eq_cons)} such constraints"
808+
),
809+
)
810+
811+
@unittest.skipUnless(SolverFactory('baron').license_is_valid(),
812+
"Global NLP solver is not available and licensed.")
813+
def test_two_stg_mod_with_axis_aligned_set(self):
814+
"""
815+
Test two-stage model with `AxisAlignedEllipsoidalSet`
816+
as the uncertainty set.
817+
"""
818+
# define model
819+
m = ConcreteModel()
820+
m.x1 = Var(initialize=0, bounds=(0, None))
821+
m.x2 = Var(initialize=0, bounds=(0, None))
822+
m.x3 = Var(initialize=0, bounds=(None, None))
823+
m.u1 = Param(initialize=1.125, mutable=True)
824+
m.u2 = Param(initialize=1, mutable=True)
825+
826+
m.con1 = Constraint(expr=m.x1 * m.u1**(0.5) - m.x2 * m.u1 <= 2)
827+
m.con2 = Constraint(expr=m.x1 ** 2 - m.x2 ** 2 * m.u1 == m.x3)
828+
829+
m.obj = Objective(expr=(m.x1 - 4) ** 2 + (m.x2 - m.u2) ** 2)
830+
831+
# Define the uncertainty set
832+
# we take the parameter `u2` to be 'fixed'
833+
ellipsoid = AxisAlignedEllipsoidalSet(
834+
center=[1.125, 1],
835+
half_lengths=[1, 0],
836+
)
837+
838+
# Instantiate the PyROS solver
839+
pyros_solver = SolverFactory("pyros")
840+
841+
# Define subsolvers utilized in the algorithm
842+
local_subsolver = SolverFactory('baron')
843+
global_subsolver = SolverFactory("baron")
844+
845+
# Call the PyROS solver
846+
results = pyros_solver.solve(
847+
model=m,
848+
first_stage_variables=[m.x1, m.x2],
849+
second_stage_variables=[],
850+
uncertain_params=[m.u1, m.u2],
851+
uncertainty_set=ellipsoid,
852+
local_solver=local_subsolver,
853+
global_solver=global_subsolver,
854+
options={
855+
"objective_focus": ObjectiveType.worst_case,
856+
"solve_master_globally": True,
857+
}
858+
)
859+
860+
# check successful termination
861+
self.assertEqual(
862+
results.pyros_termination_condition,
863+
pyrosTerminationCondition.robust_optimal,
864+
msg="Did not identify robust optimal solution to problem instance."
865+
)
866+
self.assertGreater(
867+
results.iterations,
868+
0,
869+
msg="Robust infeasible model terminated in 0 iterations (nominal case)."
870+
)
871+
872+
750873
class testPolyhedralUncertaintySetClass(unittest.TestCase):
751874
'''
752875
Polyhedral uncertainty sets. Required inputs are matrix A, right-hand-side b, and list of uncertain params.

pyomo/contrib/pyros/uncertainty_sets.py

+57-32
Original file line numberDiff line numberDiff line change
@@ -734,8 +734,8 @@ def __init__(self, center, half_lengths):
734734
raise AttributeError("Vector of half-lengths must be real-valued and numeric.")
735735
if not all(isinstance(elem, (int, float)) for elem in center):
736736
raise AttributeError("Vector center must be real-valued and numeric.")
737-
if any(elem <= 0 for elem in half_lengths):
738-
raise AttributeError("Half length values must be > 0.")
737+
if any(elem < 0 for elem in half_lengths):
738+
raise AttributeError("Half length values must be nonnegative.")
739739
# === Valid variance dimensions
740740
if not len(center) == len(half_lengths):
741741
raise AttributeError("Half lengths and center of ellipsoid must have same dimensions.")
@@ -765,33 +765,52 @@ def parameter_bounds(self):
765765
parameter_bounds = [(nom_value[i] - half_length[i], nom_value[i] + half_length[i]) for i in range(len(nom_value))]
766766
return parameter_bounds
767767

768-
def set_as_constraint(self, uncertain_params, **kwargs):
768+
def set_as_constraint(self, uncertain_params, model=None, config=None):
769769
"""
770-
Function to generate constraints for the AxisAlignedEllipsoid uncertainty set.
770+
Generate constraint(s) for the `AxisAlignedEllipsoidSet`
771+
class.
771772
772773
Args:
773-
uncertain_params: uncertain parameter objects for writing constraint objects
774-
"""
775-
if len(uncertain_params) != len(self.center):
776-
raise AttributeError("Center of ellipsoid must be same dimensions as vector of uncertain parameters.")
777-
# square and invert half lengths
778-
inverse_squared_half_lengths = list(1.0/(a**2) for a in self.half_lengths)
779-
# Calculate row vector of differences
780-
diff_squared = []
781-
# === Assume VarList uncertain_param_vars
782-
for idx, i in enumerate(uncertain_params):
783-
if uncertain_params[idx].is_indexed():
784-
for index in uncertain_params[idx]:
785-
diff_squared.append((uncertain_params[idx][index] - self.center[idx])**2)
786-
else:
787-
diff_squared.append((uncertain_params[idx] - self.center[idx])**2)
788-
789-
# Calculate inner product of difference vector and variance matrix
790-
constraint = sum([x * y for x, y in zip(inverse_squared_half_lengths, diff_squared)])
791-
774+
uncertain_params: uncertain parameter objects for writing
775+
constraint objects. Indexed parameters are accepted, and
776+
are unpacked for constraint generation.
777+
"""
778+
all_params = list()
779+
780+
# expand all uncertain parameters to a list.
781+
# this accounts for the cases in which `uncertain_params`
782+
# consists of indexed model components,
783+
# or is itself a single indexed component
784+
if not isinstance(uncertain_params, (tuple, list)):
785+
uncertain_params = [uncertain_params]
786+
787+
all_params = []
788+
for uparam in uncertain_params:
789+
all_params.extend(uparam.values())
790+
791+
if len(all_params) != len(self.center):
792+
raise AttributeError(
793+
f"Center of ellipsoid is of dimension {len(self.center)},"
794+
f" but vector of uncertain parameters is of dimension"
795+
f" {len(all_params)}"
796+
)
797+
798+
zip_all = zip(all_params, self.center, self.half_lengths)
799+
diffs_squared = list()
800+
801+
# now construct the constraints
792802
conlist = ConstraintList()
793803
conlist.construct()
794-
conlist.add(constraint <= 1)
804+
for param, ctr, half_len in zip_all:
805+
if half_len > 0:
806+
diffs_squared.append((param - ctr) ** 2 / (half_len) ** 2)
807+
else:
808+
# equality constraints for parameters corresponding to
809+
# half-lengths of zero
810+
conlist.add(param == ctr)
811+
812+
conlist.add(sum(diffs_squared) <= 1)
813+
795814
return conlist
796815

797816

@@ -802,13 +821,19 @@ class EllipsoidalSet(UncertaintySet):
802821

803822
def __init__(self, center, shape_matrix, scale=1):
804823
"""
805-
EllipsoidalSet constructor
824+
EllipsoidalSet constructor.
806825
807-
Args:
808-
center: Vector (``list``) of uncertain parameter values around which deviations are restrained.
809-
shape_matrix: Positive semi-definite matrix, effectively a covariance matrix for
810-
constraint and bounds determination.
811-
scale: Right-hand side value for the ellipsoid.
826+
Parameters
827+
----------
828+
center : (N,) array-like
829+
Center of the ellipsoid.
830+
shape_matrix : (N, N) array-like
831+
A positive definite matrix characterizing the shape
832+
and orientation of the ellipsoid.
833+
scale : float
834+
Square of the factor by which to scale the semi-axes
835+
of the ellipsoid (i.e. the eigenvectors of the covariance
836+
matrix).
812837
"""
813838

814839
# === Valid data in lists/matrixes
@@ -875,8 +900,8 @@ def parameter_bounds(self):
875900
scale = self.scale
876901
nom_value = self.center
877902
P = self.shape_matrix
878-
parameter_bounds = [(nom_value[i] - np.power(P[i][i], 0.5) * scale,
879-
nom_value[i] + np.power(P[i][i], 0.5) * scale) for i in range(self.dim)]
903+
parameter_bounds = [(nom_value[i] - np.power(P[i][i] * scale, 0.5),
904+
nom_value[i] + np.power(P[i][i] * scale, 0.5)) for i in range(self.dim)]
880905
return parameter_bounds
881906

882907
def set_as_constraint(self, uncertain_params, **kwargs):

0 commit comments

Comments
 (0)