Skip to content

Commit 466beb3

Browse files
committed
dual sign convention
1 parent 3013edc commit 466beb3

File tree

2 files changed

+113
-0
lines changed

2 files changed

+113
-0
lines changed

doc/OnlineDocs/explanation/experimental/solvers.rst

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -437,3 +437,12 @@ As a result, the signs of the duals change. The KKT conditions are
437437
\nu_i g_i(x) = 0 \\
438438
\delta_i h_i(x) = 0
439439
\end{align}
440+
441+
442+
Pyomo also supports "range constraints" which are inequalities with both upper and lower bounds, where the bounds are not equal. For example,
443+
444+
.. math::
445+
446+
-1 \leq x + y \leq 1
447+
448+
These are handled very similarly to variable bounds in terms of dual sign conventions. For these, at most one "side" of the inequality can be active at a time. If neither side is active, then the dual will be zero. If the dual is nonzero, then the dual corresponds to the side of the constraint that is active. The dual for the other side will be implicitly zero. When accessing duals, the keys are the constraints. As a result, there is only one key for a range constraint, even though it is really two constraints. Therefore, the dual for the inactive side will not be reported explicitly. Again, the sign convention is based on the ``(lower, body, upper)`` representation of the constraint. Therefore, the left side of this inequality belongs to :math:`\mathcal{L}` and the right side belongs to :math:`\mathcal{U}`.

pyomo/contrib/solver/tests/solvers/test_solvers.py

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,58 @@ def test_bounds(self, name: str, opt_class: Type[SolverBase], use_presolve: bool
246246
rc = res.solution_loader.get_reduced_costs()
247247
self.assertAlmostEqual(rc[m.x], -1)
248248

249+
@parameterized.expand(input=_load_tests(all_solvers))
250+
def test_range(self, name: str, opt_class: Type[SolverBase], use_presolve: bool):
251+
opt: SolverBase = opt_class()
252+
if not opt.available():
253+
raise unittest.SkipTest(f'Solver {opt.name} not available.')
254+
255+
# for now, we don't support getting duals if linear_presolve = True
256+
if any(name.startswith(i) for i in nl_solvers_set):
257+
if use_presolve:
258+
raise unittest.SkipTest(
259+
f'cannot yet get duals if NLWriter presolve is on'
260+
)
261+
else:
262+
opt.config.writer_config.linear_presolve = False
263+
264+
m = pyo.ConcreteModel()
265+
m.x = pyo.Var()
266+
m.y = pyo.Var()
267+
m.c1 = pyo.Constraint(expr=(-1, m.x + m.y, 1))
268+
m.c2 = pyo.Constraint(expr=m.y - m.x >= -1)
269+
m.obj = pyo.Objective(expr=m.y)
270+
res = opt.solve(m)
271+
self.assertEqual(res.solution_status, SolutionStatus.optimal)
272+
self.assertAlmostEqual(m.x.value, 0)
273+
self.assertAlmostEqual(m.y.value, -1)
274+
duals = res.solution_loader.get_duals()
275+
# the sign convention is based on the (lower, body, upper) representation of the constraint,
276+
# so we need to make sure the constraint body is what we expect
277+
assertExpressionsEqual(self, m.c1.body, m.x + m.y)
278+
assertExpressionsEqual(self, m.c2.body, m.y - m.x)
279+
self.assertAlmostEqual(duals[m.c1], 0.5)
280+
self.assertAlmostEqual(duals[m.c2], 0.5)
281+
282+
# now test the other side of the range constraint
283+
m = pyo.ConcreteModel()
284+
m.x = pyo.Var()
285+
m.y = pyo.Var()
286+
m.c1 = pyo.Constraint(expr=(-1, m.x + m.y, 1))
287+
m.c2 = pyo.Constraint(expr=m.y - m.x <= 1)
288+
m.obj = pyo.Objective(expr=-m.y)
289+
res = opt.solve(m)
290+
self.assertEqual(res.solution_status, SolutionStatus.optimal)
291+
self.assertAlmostEqual(m.x.value, 0)
292+
self.assertAlmostEqual(m.y.value, 1)
293+
duals = res.solution_loader.get_duals()
294+
# the sign convention is based on the (lower, body, upper) representation of the constraint,
295+
# so we need to make sure the constraint body is what we expect
296+
assertExpressionsEqual(self, m.c1.body, m.x + m.y)
297+
assertExpressionsEqual(self, m.c2.body, m.y - m.x)
298+
self.assertAlmostEqual(duals[m.c1], -0.5)
299+
self.assertAlmostEqual(duals[m.c2], -0.5)
300+
249301
@parameterized.expand(input=_load_tests(all_solvers))
250302
def test_equality_max(self, name: str, opt_class: Type[SolverBase], use_presolve: bool):
251303
opt: SolverBase = opt_class()
@@ -415,6 +467,58 @@ def test_bounds_max(self, name: str, opt_class: Type[SolverBase], use_presolve:
415467
rc = res.solution_loader.get_reduced_costs()
416468
self.assertAlmostEqual(rc[m.x], 1)
417469

470+
@parameterized.expand(input=_load_tests(all_solvers))
471+
def test_range_max(self, name: str, opt_class: Type[SolverBase], use_presolve: bool):
472+
opt: SolverBase = opt_class()
473+
if not opt.available():
474+
raise unittest.SkipTest(f'Solver {opt.name} not available.')
475+
476+
# for now, we don't support getting duals if linear_presolve = True
477+
if any(name.startswith(i) for i in nl_solvers_set):
478+
if use_presolve:
479+
raise unittest.SkipTest(
480+
f'cannot yet get duals if NLWriter presolve is on'
481+
)
482+
else:
483+
opt.config.writer_config.linear_presolve = False
484+
485+
m = pyo.ConcreteModel()
486+
m.x = pyo.Var()
487+
m.y = pyo.Var()
488+
m.c1 = pyo.Constraint(expr=(-1, m.x + m.y, 1))
489+
m.c2 = pyo.Constraint(expr=m.y - m.x >= -1)
490+
m.obj = pyo.Objective(expr=-m.y, sense=pyo.maximize)
491+
res = opt.solve(m)
492+
self.assertEqual(res.solution_status, SolutionStatus.optimal)
493+
self.assertAlmostEqual(m.x.value, 0)
494+
self.assertAlmostEqual(m.y.value, -1)
495+
duals = res.solution_loader.get_duals()
496+
# the sign convention is based on the (lower, body, upper) representation of the constraint,
497+
# so we need to make sure the constraint body is what we expect
498+
assertExpressionsEqual(self, m.c1.body, m.x + m.y)
499+
assertExpressionsEqual(self, m.c2.body, m.y - m.x)
500+
self.assertAlmostEqual(duals[m.c1], -0.5)
501+
self.assertAlmostEqual(duals[m.c2], -0.5)
502+
503+
# now test the other side of the range constraint
504+
m = pyo.ConcreteModel()
505+
m.x = pyo.Var()
506+
m.y = pyo.Var()
507+
m.c1 = pyo.Constraint(expr=(-1, m.x + m.y, 1))
508+
m.c2 = pyo.Constraint(expr=m.y - m.x <= 1)
509+
m.obj = pyo.Objective(expr=m.y, sense=pyo.maximize)
510+
res = opt.solve(m)
511+
self.assertEqual(res.solution_status, SolutionStatus.optimal)
512+
self.assertAlmostEqual(m.x.value, 0)
513+
self.assertAlmostEqual(m.y.value, 1)
514+
duals = res.solution_loader.get_duals()
515+
# the sign convention is based on the (lower, body, upper) representation of the constraint,
516+
# so we need to make sure the constraint body is what we expect
517+
assertExpressionsEqual(self, m.c1.body, m.x + m.y)
518+
assertExpressionsEqual(self, m.c2.body, m.y - m.x)
519+
self.assertAlmostEqual(duals[m.c1], 0.5)
520+
self.assertAlmostEqual(duals[m.c2], 0.5)
521+
418522

419523
@unittest.skipUnless(numpy_available, 'numpy is not available')
420524
class TestSolvers(unittest.TestCase):

0 commit comments

Comments
 (0)