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
133 changes: 119 additions & 14 deletions pyomo/repn/plugins/standard_form.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,9 @@ class LinearStandardFormInfo:

objectives : List[ObjectiveData]

The list of Pyomo objective objects corresponding to the active objectives
The list of Pyomo objective objects corresponding to the active
objectives whose expressions are purely linear (and thus appear
in `c`).

eliminated_vars: List[Tuple[VarData, NumericExpression]]

Expand All @@ -111,9 +113,35 @@ class LinearStandardFormInfo:
all variables appearing in the expression must either have
appeared in the standard form, or appear *earlier* in this list.

nonlinear_constraints : List[ConstraintData] or None

Constraints skipped because they contain nonlinear terms. ``None``
when ``allow_nonlinear=False`` (the default). When
``allow_nonlinear=True``, holds the list of constraints with nonlinear
terms that were omitted from the compiled matrices (may be empty).

nonlinear_objectives : List[ObjectiveData] or None

Objectives skipped because they contain nonlinear terms. ``None``
when ``allow_nonlinear=False`` (the default). When
``allow_nonlinear=True``, holds the list of objectives with nonlinear
terms that were omitted from the compiled matrices (may be empty).

"""

def __init__(self, c, c_offset, A, rhs, rows, columns, objectives, eliminated_vars):
def __init__(
self,
c,
c_offset,
A,
rhs,
rows,
columns,
objectives,
eliminated_vars,
nonlinear_constraints=None,
nonlinear_objectives=None,
):
self.c = c
self.c_offset = c_offset
self.A = A
Expand All @@ -122,6 +150,8 @@ def __init__(self, c, c_offset, A, rhs, rows, columns, objectives, eliminated_va
self.columns = columns
self.objectives = objectives
self.eliminated_vars = eliminated_vars
self.nonlinear_constraints = nonlinear_constraints
self.nonlinear_objectives = nonlinear_objectives

@property
def x(self):
Expand Down Expand Up @@ -188,6 +218,30 @@ class LinearStandardFormCompiler:
description='If not None, map all objectives to the specified sense.',
),
)
CONFIG.declare(
'allow_nonlinear',
ConfigValue(
default=False,
domain=bool,
description='If True, constraints and objectives containing nonlinear '
'terms are collected into ``LinearStandardFormInfo.nonlinear_constraints`` '
'and ``LinearStandardFormInfo.nonlinear_objectives`` rather than raising '
'an exception. The nonlinear components are omitted from the compiled '
'matrices.',
),
)
CONFIG.declare(
'ignore_ctypes',
ConfigValue(
default=[],
description='Additional component types that are permitted to appear '
'in the model without causing an error, but that are not processed by '
'the compiler. Use this when the model contains component types '
'(e.g., :class:`~pyomo.core.base.sos.SOSConstraint`) that are valid '
'for the calling solver but that the standard-form compiler does not '
'know how to handle.',
),
)
CONFIG.declare(
'show_section_timing',
ConfigValue(
Expand Down Expand Up @@ -309,7 +363,8 @@ def write(self, model):
RangeSet,
Port,
# TODO: Piecewise, Complementarity
},
}
| set(self.config.ignore_ctypes),
targets={Suffix, Objective},
)
if unknown:
Expand Down Expand Up @@ -364,6 +419,7 @@ def write(self, model):
# Process objective
#
set_sense = self.config.set_sense
allow_nonlinear = self.config.allow_nonlinear
objectives = []
for blk in component_map[Objective]:
objectives.extend(
Expand All @@ -376,29 +432,51 @@ def write(self, model):
obj_data = []
obj_index = []
obj_index_ptr = [0]
linear_objectives = []
nonlinear_objectives = []
for obj in objectives:
if hasattr(obj, 'template_expr'):
offset, linear_index, linear_data, lb, ub = (
template_visitor.expand_expression(obj, obj.template_expr())
)
try:
offset, linear_index, linear_data, lb, ub = (
template_visitor.expand_expression(obj, obj.template_expr())
)
except InvalidExpressionError:
if allow_nonlinear:
nonlinear_objectives.append(obj)
if with_debug_timing:
timer.toc(
'Objective %s (nonlinear)', obj, level=logging.DEBUG
)
continue
raise
assert lb is None and ub is None
N = len(linear_index)
obj_index.append(linear_index)
obj_data.append(linear_data)
obj_offset.append(offset)
linear_objectives.append(obj)
else:
repn = visitor.walk_expression(obj.expr)
N = len(repn.linear)
obj_index.append(map(var_recorder.var_order.__getitem__, repn.linear))
obj_data.append(repn.linear.values())
obj_offset.append(repn.constant)

if repn.nonlinear is not None:
if allow_nonlinear:
nonlinear_objectives.append(obj)
if with_debug_timing:
timer.toc(
'Objective %s (nonlinear)', obj, level=logging.DEBUG
)
continue
raise InvalidExpressionError(
f"Model objective ({obj.name}) contains nonlinear terms that "
"cannot be compiled to standard (linear) form."
)

N = len(repn.linear)
obj_index.append(map(var_recorder.var_order.__getitem__, repn.linear))
obj_data.append(repn.linear.values())
obj_offset.append(repn.constant)
linear_objectives.append(obj)

obj_nnz += N
if set_sense is not None and set_sense != obj.sense:
obj_data[-1] = -self._to_vector(obj_data[-1], float, N)
Expand All @@ -420,6 +498,7 @@ def write(self, model):
con_data = []
con_index = []
con_index_ptr = [0]
nonlinear_constraints = []
last_parent = None
for con in ordered_active_constraints(model, self.config):
if with_debug_timing and con._component is not last_parent:
Expand All @@ -428,9 +507,19 @@ def write(self, model):
last_parent = con._component

if hasattr(con, 'template_expr'):
offset, linear_index, linear_data, lb, ub = (
template_visitor.expand_expression(con, con.template_expr())
)
try:
offset, linear_index, linear_data, lb, ub = (
template_visitor.expand_expression(con, con.template_expr())
)
except InvalidExpressionError:
if allow_nonlinear:
nonlinear_constraints.append(con)
if with_debug_timing:
timer.toc(
'Constraint %s (nonlinear)', con, level=logging.DEBUG
)
continue
raise
N = len(linear_data)
else:
# Note: lb and ub could be a number, expression, or None.
Expand All @@ -442,6 +531,13 @@ def write(self, model):
ub = value(ub)
repn = visitor.walk_expression(body)
if repn.nonlinear is not None:
if allow_nonlinear:
nonlinear_constraints.append(con)
if with_debug_timing:
timer.toc(
'Constraint %s (nonlinear)', con, level=logging.DEBUG
)
continue
raise InvalidConstraintError(
f"Model constraint ({con.name}) contains nonlinear terms that "
"cannot be compiled to standard (linear) form."
Expand Down Expand Up @@ -613,7 +709,16 @@ def write(self, model):
eliminated_vars = []

info = LinearStandardFormInfo(
c, obj_offset, A, rhs, rows, columns, objectives, eliminated_vars
c,
obj_offset,
A,
rhs,
rows,
columns,
linear_objectives,
eliminated_vars,
nonlinear_constraints=nonlinear_constraints if allow_nonlinear else None,
nonlinear_objectives=nonlinear_objectives if allow_nonlinear else None,
)
timer.toc("Generated linear standard form representation", delta=False)
return info
Expand Down
107 changes: 107 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,113 @@ def test_alternative_forms(self):
self.assertTrue(np.all(repn.c == ref))
self._verify_solution(soln, repn, True)

def test_nonlinear_fields_none_when_not_allowed(self):
m = pyo.ConcreteModel()
m.x = pyo.Var()
m.c = pyo.Constraint(expr=m.x <= 1)
m.o = pyo.Objective(expr=m.x)

repn = LinearStandardFormCompiler().write(m, mixed_form=True)
self.assertIsNone(repn.nonlinear_constraints)
self.assertIsNone(repn.nonlinear_objectives)

def test_allow_nonlinear_constraints(self):
m = pyo.ConcreteModel()
m.x = pyo.Var()
m.y = pyo.Var()
m.c_lin = pyo.Constraint(expr=m.x + m.y <= 5)
m.c_nl = pyo.Constraint(expr=m.x**2 + m.y <= 3)
m.o = pyo.Objective(expr=m.x + m.y)

# Default (allow_nonlinear=False) must raise on the nonlinear constraint.
with self.assertRaises(Exception):
LinearStandardFormCompiler().write(m, mixed_form=True)

# allow_nonlinear=True: nonlinear constraint is collected separately;
# the linear constraint still appears in A.
repn = LinearStandardFormCompiler().write(
m, mixed_form=True, allow_nonlinear=True
)
self.assertEqual(repn.nonlinear_constraints, [m.c_nl])
self.assertEqual(repn.nonlinear_objectives, [])
# Only the linear constraint appears in A.
self.assertEqual(len(repn.rows), 1)
self.assertEqual(repn.rows[0].constraint, m.c_lin)
# Linear objective is still compiled into c.
self.assertEqual(repn.objectives, [m.o])
self.assertTrue(np.all(repn.c.toarray() != 0))

def test_allow_nonlinear_objective(self):
m = pyo.ConcreteModel()
m.x = pyo.Var()
m.y = pyo.Var()
m.c_lin = pyo.Constraint(expr=m.x + m.y <= 5)
m.o_nl = pyo.Objective(expr=m.x**2 + m.y)

# Default must raise on the nonlinear objective.
with self.assertRaises(Exception):
LinearStandardFormCompiler().write(m, mixed_form=True)

repn = LinearStandardFormCompiler().write(
m, mixed_form=True, allow_nonlinear=True
)
# Nonlinear objective is NOT compiled into c; it appears in nonlinear_objectives.
self.assertEqual(repn.nonlinear_objectives, [m.o_nl])
self.assertEqual(repn.objectives, [])
# c is empty (no linear objectives).
self.assertEqual(repn.c.shape[0], 0)
# The linear constraint is still compiled normally.
self.assertEqual(len(repn.rows), 1)
self.assertEqual(repn.rows[0].constraint, m.c_lin)

def test_allow_nonlinear_mixed(self):
"""Linear constraints/objectives compiled; nonlinear ones passed through."""
m = pyo.ConcreteModel()
m.x = pyo.Var()
m.y = pyo.Var()
m.c_lin = pyo.Constraint(expr=m.x + 2 * m.y >= 1)
m.c_nl = pyo.Constraint(expr=m.x * m.y <= 4)
m.o_lin = pyo.Objective(expr=m.x + m.y)

repn = LinearStandardFormCompiler().write(
m, mixed_form=True, allow_nonlinear=True
)

# Exactly one linear row, one nonlinear constraint.
self.assertEqual(len(repn.rows), 1)
self.assertEqual(repn.rows[0].constraint, m.c_lin)
self.assertEqual(repn.nonlinear_constraints, [m.c_nl])
# Linear objective compiles normally.
self.assertEqual(repn.objectives, [m.o_lin])
self.assertEqual(repn.nonlinear_objectives, [])
# Both variables appear as columns (referenced by the linear constraint).
col_ids = {id(v) for v in repn.columns}
self.assertIn(id(m.x), col_ids)
self.assertIn(id(m.y), col_ids)

def test_ignore_ctypes(self):
"""Component types in ignore_ctypes are permitted but not compiled."""
m = pyo.ConcreteModel()
m.x = pyo.Var([1, 2, 3])
m.y = pyo.Var()
m.obj = pyo.Objective(expr=m.y)
m.sos = pyo.SOSConstraint(var=m.x, sos=1)

# Without ignore_ctypes, LSFC raises on the SOSConstraint.
with self.assertRaises(ValueError):
LinearStandardFormCompiler().write(m, mixed_form=True)

# With ignore_ctypes, the SOSConstraint is silently skipped.
repn = LinearStandardFormCompiler().write(
m, mixed_form=True, ignore_ctypes=[pyo.SOSConstraint]
)
# Only m.y appears in the objective; m.x[i] are unreferenced by
# linear constraints/objectives so not included in repn.columns.
self.assertEqual(len(repn.rows), 0)
col_ids = {id(v) for v in repn.columns}
self.assertIn(id(m.y), col_ids)
self.assertNotIn(id(m.x[1]), col_ids)


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