Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
106 changes: 89 additions & 17 deletions pyomo/repn/plugins/standard_form.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,15 +80,30 @@ class LinearStandardFormInfo:

rhs : numpy.ndarray

The constraint right-hand sides.
The constraint right-hand sides. For range rows (``bound_type
== 2``, only produced when ``keep_range_constraints=True``),
this holds the adjusted *upper* bound ``ub - offset``.

rhs_range : numpy.ndarray

Range widths for range rows: ``rhs_range[i] = ub - lb``. For
all other row types the value is ``0.0``. When
``keep_range_constraints=False`` (the default) this array
contains only zeros. The adjusted lower bound for a range row
can be recovered as ``rhs[i] - rhs_range[i]``.

rows : List[Tuple[ConstraintData, int]]

The list of Pyomo constraint objects corresponding to the rows
in `A`. Each element in the list is a 2-tuple of
(ConstraintData, row_multiplier). The `row_multiplier` will be
+/- 1 indicating if the row was multiplied by -1 (corresponding
to a constraint lower bound) or +1 (upper bound).
(ConstraintData, bound_type). ``bound_type`` values:

* ``+1`` – upper-bound row (``Ax ≤ rhs``);
* ``-1`` – lower-bound row (see mode-dependent sign conventions);
* ``0`` – equality row (``mixed_form`` only);
* ``+2`` – range row (``lb - offset ≤ Ax ≤ ub - offset``,
coefficients in the upper-bound sense; only produced when
``keep_range_constraints=True``).

columns : List[VarData]

Expand All @@ -111,11 +126,14 @@ class LinearStandardFormInfo:

"""

def __init__(self, c, c_offset, A, rhs, rows, columns, objectives, eliminated_vars):
def __init__(
self, c, c_offset, A, rhs, rhs_range, rows, columns, objectives, eliminated_vars
):
self.c = c
self.c_offset = c_offset
self.A = A
self.rhs = rhs
self.rhs_range = rhs_range
self.rows = rows
self.columns = columns
self.objectives = objectives
Expand Down Expand Up @@ -178,6 +196,19 @@ class LinearStandardFormCompiler:
'mix of <=, ==, and >=)',
),
)
CONFIG.declare(
'keep_range_constraints',
ConfigValue(
default=False,
domain=bool,
description='Emit range constraints (finite lb ≠ ub) as a single '
'row with bound_type=2 rather than splitting them into separate '
'upper- and lower-bound rows. The rhs entry for such a row is the '
'adjusted upper bound (ub - offset); the range width (ub - lb) is '
'stored in the rhs_range array of the returned '
'LinearStandardFormInfo. Cannot be combined with slack_form.',
),
)
CONFIG.declare(
'set_sense',
ConfigValue(
Expand Down Expand Up @@ -409,10 +440,16 @@ def write(self, model):
#
slack_form = self.config.slack_form
mixed_form = self.config.mixed_form
keep_range_constraints = self.config.keep_range_constraints
if slack_form and mixed_form:
raise ValueError("cannot specify both slack_form and mixed_form")
if slack_form and keep_range_constraints:
raise ValueError(
"cannot specify both slack_form and keep_range_constraints"
)
rows = []
rhs = []
rhs_range = []
con_nnz = 0
con_data = []
con_index = []
Expand Down Expand Up @@ -469,6 +506,17 @@ def write(self, model):
con_nnz += N
rows.append(RowEntry(con, 0))
rhs.append(ub - offset)
rhs_range.append(0.0)
con_data.append(linear_data)
con_index.append(linear_index)
con_index_ptr.append(con_nnz)
elif lb is not None and ub is not None and keep_range_constraints:
# Range constraint: single row, coefficients in the upper-
# bound sense (not negated), bound_type=2.
con_nnz += N
rows.append(RowEntry(con, 2))
rhs.append(ub - offset)
rhs_range.append(ub - lb)
con_data.append(linear_data)
con_index.append(linear_index)
con_index_ptr.append(con_nnz)
Expand All @@ -479,13 +527,15 @@ def write(self, model):
con_nnz += N
rows.append(RowEntry(con, 1))
rhs.append(ub - offset)
rhs_range.append(0.0)
con_data.append(linear_data)
con_index.append(linear_index)
con_index_ptr.append(con_nnz)
if lb is not None:
con_nnz += N
rows.append(RowEntry(con, -1))
rhs.append(lb - offset)
rhs_range.append(0.0)
con_data.append(linear_data)
con_index.append(linear_index)
con_index_ptr.append(con_nnz)
Expand Down Expand Up @@ -516,26 +566,40 @@ def write(self, model):
linear_index.append(slack_col)
con_nnz += N
rows.append(RowEntry(con, 1))
rhs_range.append(0.0)
con_data.append(linear_data)
con_index.append(linear_index)
con_index_ptr.append(con_nnz)
else:
if ub is not None:
if lb is not None:
linear_index = list(linear_index)
is_range = lb is not None and ub is not None and lb != ub
if is_range and keep_range_constraints:
# Range constraint: single row, bound_type=2.
con_nnz += N
rows.append(RowEntry(con, 1))
rows.append(RowEntry(con, 2))
rhs.append(ub - offset)
rhs_range.append(ub - lb)
con_data.append(linear_data)
con_index.append(linear_index)
con_index_ptr.append(con_nnz)
if lb is not None:
con_nnz += N
rows.append(RowEntry(con, -1))
rhs.append(offset - lb)
con_data.append(-np.array(list(linear_data)))
con_index.append(linear_index)
con_index_ptr.append(con_nnz)
else:
if ub is not None:
if lb is not None:
linear_index = list(linear_index)
con_nnz += N
rows.append(RowEntry(con, 1))
rhs.append(ub - offset)
rhs_range.append(0.0)
con_data.append(linear_data)
con_index.append(linear_index)
con_index_ptr.append(con_nnz)
if lb is not None:
con_nnz += N
rows.append(RowEntry(con, -1))
rhs.append(offset - lb)
rhs_range.append(0.0)
con_data.append(-np.array(list(linear_data)))
con_index.append(linear_index)
con_index_ptr.append(con_nnz)

if with_debug_timing:
# report the last constraint
Expand Down Expand Up @@ -609,7 +673,15 @@ def write(self, model):
eliminated_vars = []

info = LinearStandardFormInfo(
c, obj_offset, A, rhs, rows, columns, objectives, eliminated_vars
c,
obj_offset,
A,
rhs,
np.array(rhs_range),
rows,
columns,
objectives,
eliminated_vars,
)
timer.toc("Generated linear standard form representation", delta=False)
return info
Expand Down
57 changes: 57 additions & 0 deletions pyomo/repn/tests/test_standard_form.py
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,63 @@ def test_alternative_forms(self):
self.assertTrue(np.all(repn.c == ref))
self._verify_solution(soln, repn, True)

def test_keep_range_constraints(self):
m = pyo.ConcreteModel()
m.x = pyo.Var()
m.y = pyo.Var([0, 1, 3], bounds=lambda m, i: (0, 10))
# Pure lower-bound constraint
m.c = pyo.Constraint(expr=m.x + 2 * m.y[1] >= 3)
# Pure upper-bound constraint
m.d = pyo.Constraint(expr=m.y[1] + 4 * m.y[3] <= 5)
# Range constraint: -2 <= y[0] + 1 + 6*y[1] <= 7 → -3 <= y[0] + 6*y[1] <= 6
m.e = pyo.Constraint(expr=pyo.inequality(-2, m.y[0] + 1 + 6 * m.y[1], 7))
# Equality
m.f = pyo.Constraint(expr=m.x + m.y[0] == 8)
m.o = pyo.Objective(expr=5 * m.x)

col_order = [m.x, m.y[0], m.y[1], m.y[3]]

# --- mixed_form + keep_range_constraints ---
repn = LinearStandardFormCompiler().write(
m, mixed_form=True, keep_range_constraints=True, column_order=col_order
)
# m.e: single range row (bound_type=2); all others are normal mixed rows
self.assertEqual(repn.rows, [(m.c, -1), (m.d, 1), (m.e, 2), (m.f, 0)])
ref_A = np.array([[1, 0, 2, 0], [0, 0, 1, 4], [0, 1, 6, 0], [1, 1, 0, 0]])
self.assertTrue(np.all(repn.A.toarray() == ref_A))
# m.e: rhs = ub - offset = 7 - 1 = 6
self.assertTrue(np.all(repn.rhs == np.array([3, 5, 6, 8])))
# rhs_range: only m.e is nonzero; range = 7 - (-2) = 9
self.assertTrue(np.all(repn.rhs_range == np.array([0.0, 0.0, 9.0, 0.0])))

# --- default form + keep_range_constraints ---
repn2 = LinearStandardFormCompiler().write(
m, keep_range_constraints=True, column_order=col_order
)
# lb-only (m.c) → negated ≤ row; ub-only (m.d) → ≤ row;
# range (m.e) → single row; equality (m.f) → two rows (ub + negated lb)
self.assertEqual(
repn2.rows, [(m.c, -1), (m.d, 1), (m.e, 2), (m.f, 1), (m.f, -1)]
)
self.assertTrue(np.all(repn2.rhs_range == np.array([0.0, 0.0, 9.0, 0.0, 0.0])))

# --- without keep_range_constraints m.e still splits into two rows ---
repn3 = LinearStandardFormCompiler().write(
m, mixed_form=True, column_order=col_order
)
e_rows = [
(r.constraint, r.bound_type) for r in repn3.rows if r.constraint is m.e
]
self.assertEqual(e_rows, [(m.e, 1), (m.e, -1)])
# rhs_range is all-zeros when keep_range_constraints=False
self.assertTrue(np.all(repn3.rhs_range == 0.0))

# --- slack_form + keep_range_constraints must raise ---
with self.assertRaises(ValueError):
LinearStandardFormCompiler().write(
m, slack_form=True, keep_range_constraints=True
)


class TestTemplatedLinearStandardFormCompiler(TestLinearStandardFormCompiler):
def setUp(self):
Expand Down
Loading
Loading