From 84afd9ae226aac9bdc74af0bbcf173a2fa9e037f Mon Sep 17 00:00:00 2001 From: Bernard Knueven Date: Fri, 20 Mar 2026 14:17:10 -0400 Subject: [PATCH 01/12] add keep_range_constraints to LinearStandardFormCompiler --- pyomo/repn/plugins/standard_form.py | 96 +++++++++++++++++++++----- pyomo/repn/tests/test_standard_form.py | 57 +++++++++++++++ 2 files changed, 136 insertions(+), 17 deletions(-) diff --git a/pyomo/repn/plugins/standard_form.py b/pyomo/repn/plugins/standard_form.py index 70b1b74b8cc..5a7c97943a1 100644 --- a/pyomo/repn/plugins/standard_form.py +++ b/pyomo/repn/plugins/standard_form.py @@ -82,15 +82,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] @@ -113,11 +128,12 @@ 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 @@ -180,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( @@ -412,10 +441,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 = [] @@ -473,6 +508,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) @@ -483,6 +529,7 @@ 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) @@ -490,6 +537,7 @@ def write(self, model): 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) @@ -520,26 +568,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 @@ -613,7 +675,7 @@ 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 diff --git a/pyomo/repn/tests/test_standard_form.py b/pyomo/repn/tests/test_standard_form.py index eaa515c21bc..cac49699640 100644 --- a/pyomo/repn/tests/test_standard_form.py +++ b/pyomo/repn/tests/test_standard_form.py @@ -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): From 28e3529f11bed5a8864dd4e0a661ef3e3a9c0d02 Mon Sep 17 00:00:00 2001 From: Bernard Knueven Date: Mon, 11 May 2026 12:28:33 -0600 Subject: [PATCH 02/12] NFC: formatting --- pyomo/repn/plugins/standard_form.py | 14 ++++++++++++-- pyomo/repn/tests/test_standard_form.py | 8 ++++---- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/pyomo/repn/plugins/standard_form.py b/pyomo/repn/plugins/standard_form.py index 5a7c97943a1..77c0289d0bf 100644 --- a/pyomo/repn/plugins/standard_form.py +++ b/pyomo/repn/plugins/standard_form.py @@ -128,7 +128,9 @@ class LinearStandardFormInfo: """ - def __init__(self, c, c_offset, A, rhs, rhs_range, 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 @@ -675,7 +677,15 @@ def write(self, model): eliminated_vars = [] info = LinearStandardFormInfo( - c, obj_offset, A, rhs, np.array(rhs_range), 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 diff --git a/pyomo/repn/tests/test_standard_form.py b/pyomo/repn/tests/test_standard_form.py index cac49699640..296889bb7e4 100644 --- a/pyomo/repn/tests/test_standard_form.py +++ b/pyomo/repn/tests/test_standard_form.py @@ -373,9 +373,7 @@ def test_keep_range_constraints(self): ) # 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]] - ) + 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]))) @@ -397,7 +395,9 @@ def test_keep_range_constraints(self): 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] + 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)) From aa9ce77d5362fa39df738c1bdd871394cd3fa391 Mon Sep 17 00:00:00 2001 From: Bernard Knueven Date: Mon, 11 May 2026 11:04:40 -0600 Subject: [PATCH 03/12] allow LinearStandardFormCompiler to return unhandled nonlinear constraints --- pyomo/repn/plugins/standard_form.py | 111 ++++++++++++++++++++++--- pyomo/repn/tests/test_standard_form.py | 74 +++++++++++++++++ 2 files changed, 173 insertions(+), 12 deletions(-) diff --git a/pyomo/repn/plugins/standard_form.py b/pyomo/repn/plugins/standard_form.py index 77c0289d0bf..1b46ed99ee2 100644 --- a/pyomo/repn/plugins/standard_form.py +++ b/pyomo/repn/plugins/standard_form.py @@ -114,7 +114,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]] @@ -126,10 +128,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] + + Constraints skipped because they contain nonlinear terms. Only + populated when ``allow_nonlinear=True`` is passed to + :meth:`~LinearStandardFormCompiler.write`; otherwise an + exception is raised for such constraints. + + nonlinear_objectives : List[ObjectiveData] + + Objectives skipped because they contain nonlinear terms. Only + populated when ``allow_nonlinear=True`` is passed to + :meth:`~LinearStandardFormCompiler.write`; otherwise an + exception is raised for such objectives. + """ def __init__( - self, c, c_offset, A, rhs, rhs_range, rows, columns, objectives, eliminated_vars + self, + c, + c_offset, + A, + rhs, + rhs_range, + rows, + columns, + objectives, + eliminated_vars, + nonlinear_constraints=None, + nonlinear_objectives=None, ): self.c = c self.c_offset = c_offset @@ -140,6 +167,8 @@ def __init__( self.columns = columns self.objectives = objectives self.eliminated_vars = eliminated_vars + self.nonlinear_constraints = nonlinear_constraints if nonlinear_constraints is not None else [] + self.nonlinear_objectives = nonlinear_objectives if nonlinear_objectives is not None else [] @property def x(self): @@ -219,6 +248,18 @@ 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( 'show_section_timing', ConfigValue( @@ -395,6 +436,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( @@ -407,29 +449,50 @@ 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()) - ) + if allow_nonlinear: + try: + offset, linear_index, linear_data, lb, ub = ( + template_visitor.expand_expression(obj, obj.template_expr()) + ) + except InvalidExpressionError: + nonlinear_objectives.append(obj) + if with_debug_timing: + timer.toc('Objective %s (nonlinear)', obj, level=logging.DEBUG) + continue + else: + offset, linear_index, linear_data, lb, ub = ( + template_visitor.expand_expression(obj, obj.template_expr()) + ) 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) @@ -437,6 +500,7 @@ def write(self, model): obj_index_ptr.append(obj_index_ptr[-1] + N) if with_debug_timing: timer.toc('Objective %s', obj, level=logging.DEBUG) + objectives = linear_objectives # # Tabulate constraints @@ -457,6 +521,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: @@ -465,9 +530,22 @@ 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()) - ) + if allow_nonlinear: + try: + offset, linear_index, linear_data, lb, ub = ( + template_visitor.expand_expression(con, con.template_expr()) + ) + except InvalidExpressionError: + nonlinear_constraints.append(con) + if with_debug_timing: + timer.toc( + 'Constraint %s (nonlinear)', con, level=logging.DEBUG + ) + continue + else: + offset, linear_index, linear_data, lb, ub = ( + template_visitor.expand_expression(con, con.template_expr()) + ) N = len(linear_data) else: # Note: lb and ub could be a number, expression, or None. @@ -479,6 +557,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." @@ -686,6 +771,8 @@ def write(self, model): columns, objectives, eliminated_vars, + nonlinear_constraints=nonlinear_constraints, + nonlinear_objectives=nonlinear_objectives, ) timer.toc("Generated linear standard form representation", delta=False) return info diff --git a/pyomo/repn/tests/test_standard_form.py b/pyomo/repn/tests/test_standard_form.py index 296889bb7e4..eed89a16e77 100644 --- a/pyomo/repn/tests/test_standard_form.py +++ b/pyomo/repn/tests/test_standard_form.py @@ -408,6 +408,80 @@ def test_keep_range_constraints(self): m, slack_form=True, keep_range_constraints=True ) + 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) + class TestTemplatedLinearStandardFormCompiler(TestLinearStandardFormCompiler): def setUp(self): From e330102eb35b564a9db2e2fc16621065af6c339e Mon Sep 17 00:00:00 2001 From: Bernard Knueven Date: Mon, 11 May 2026 11:15:28 -0600 Subject: [PATCH 04/12] cleanup range constraints --- pyomo/repn/plugins/standard_form.py | 33 +++++++++++++++++------------ 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/pyomo/repn/plugins/standard_form.py b/pyomo/repn/plugins/standard_form.py index 1b46ed99ee2..a37281e456b 100644 --- a/pyomo/repn/plugins/standard_form.py +++ b/pyomo/repn/plugins/standard_form.py @@ -86,13 +86,14 @@ class LinearStandardFormInfo: == 2``, only produced when ``keep_range_constraints=True``), this holds the adjusted *upper* bound ``ub - offset``. - rhs_range : numpy.ndarray + rhs_range : numpy.ndarray or None - 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]``. + Range widths for range rows: ``rhs_range[i] = ub - lb`` for + ``bound_type == 2`` rows. ``None`` when + ``keep_range_constraints=False`` (the default) or when the + model contains no range constraints. When not ``None``, the + adjusted lower bound for a range row can be recovered as + ``rhs[i] - rhs_range[i]``. rows : List[Tuple[ConstraintData, int]] @@ -516,7 +517,7 @@ def write(self, model): ) rows = [] rhs = [] - rhs_range = [] + rhs_range = [] if keep_range_constraints else None con_nnz = 0 con_data = [] con_index = [] @@ -595,7 +596,8 @@ def write(self, model): con_nnz += N rows.append(RowEntry(con, 0)) rhs.append(ub - offset) - rhs_range.append(0.0) + if keep_range_constraints: + rhs_range.append(0.0) con_data.append(linear_data) con_index.append(linear_index) con_index_ptr.append(con_nnz) @@ -616,7 +618,8 @@ def write(self, model): con_nnz += N rows.append(RowEntry(con, 1)) rhs.append(ub - offset) - rhs_range.append(0.0) + if keep_range_constraints: + rhs_range.append(0.0) con_data.append(linear_data) con_index.append(linear_index) con_index_ptr.append(con_nnz) @@ -624,7 +627,8 @@ def write(self, model): con_nnz += N rows.append(RowEntry(con, -1)) rhs.append(lb - offset) - rhs_range.append(0.0) + if keep_range_constraints: + rhs_range.append(0.0) con_data.append(linear_data) con_index.append(linear_index) con_index_ptr.append(con_nnz) @@ -655,7 +659,6 @@ 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) @@ -677,7 +680,8 @@ def write(self, model): con_nnz += N rows.append(RowEntry(con, 1)) rhs.append(ub - offset) - rhs_range.append(0.0) + if keep_range_constraints: + rhs_range.append(0.0) con_data.append(linear_data) con_index.append(linear_index) con_index_ptr.append(con_nnz) @@ -685,7 +689,8 @@ def write(self, model): con_nnz += N rows.append(RowEntry(con, -1)) rhs.append(offset - lb) - rhs_range.append(0.0) + if keep_range_constraints: + rhs_range.append(0.0) con_data.append(-np.array(list(linear_data))) con_index.append(linear_index) con_index_ptr.append(con_nnz) @@ -766,7 +771,7 @@ def write(self, model): obj_offset, A, rhs, - np.array(rhs_range), + np.array(rhs_range) if keep_range_constraints else None, rows, columns, objectives, From efc1c7472e42bbfb62dd76b2f66d98e38d4ca959 Mon Sep 17 00:00:00 2001 From: Bernard Knueven Date: Mon, 11 May 2026 11:22:20 -0600 Subject: [PATCH 05/12] update standard form test for better range constraint handling --- pyomo/repn/tests/test_standard_form.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pyomo/repn/tests/test_standard_form.py b/pyomo/repn/tests/test_standard_form.py index eed89a16e77..34b6506f3ad 100644 --- a/pyomo/repn/tests/test_standard_form.py +++ b/pyomo/repn/tests/test_standard_form.py @@ -377,7 +377,7 @@ def test_keep_range_constraints(self): 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 + # rhs_range: only m.e is a range row; 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 --- @@ -399,8 +399,8 @@ def test_keep_range_constraints(self): (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)) + # rhs_range is None when keep_range_constraints=False + self.assertIsNone(repn3.rhs_range) # --- slack_form + keep_range_constraints must raise --- with self.assertRaises(ValueError): From ea347364893a036fe61c2e4fb04fe91568568243 Mon Sep 17 00:00:00 2001 From: Bernard Knueven Date: Mon, 11 May 2026 11:23:18 -0600 Subject: [PATCH 06/12] NFC: black on standard_form.py --- pyomo/repn/plugins/standard_form.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/pyomo/repn/plugins/standard_form.py b/pyomo/repn/plugins/standard_form.py index a37281e456b..bda63fa145e 100644 --- a/pyomo/repn/plugins/standard_form.py +++ b/pyomo/repn/plugins/standard_form.py @@ -168,8 +168,12 @@ def __init__( self.columns = columns self.objectives = objectives self.eliminated_vars = eliminated_vars - self.nonlinear_constraints = nonlinear_constraints if nonlinear_constraints is not None else [] - self.nonlinear_objectives = nonlinear_objectives if nonlinear_objectives is not None else [] + self.nonlinear_constraints = ( + nonlinear_constraints if nonlinear_constraints is not None else [] + ) + self.nonlinear_objectives = ( + nonlinear_objectives if nonlinear_objectives is not None else [] + ) @property def x(self): @@ -462,7 +466,9 @@ def write(self, model): except InvalidExpressionError: nonlinear_objectives.append(obj) if with_debug_timing: - timer.toc('Objective %s (nonlinear)', obj, level=logging.DEBUG) + timer.toc( + 'Objective %s (nonlinear)', obj, level=logging.DEBUG + ) continue else: offset, linear_index, linear_data, lb, ub = ( @@ -481,7 +487,9 @@ def write(self, model): if allow_nonlinear: nonlinear_objectives.append(obj) if with_debug_timing: - timer.toc('Objective %s (nonlinear)', obj, level=logging.DEBUG) + timer.toc( + 'Objective %s (nonlinear)', obj, level=logging.DEBUG + ) continue raise InvalidExpressionError( f"Model objective ({obj.name}) contains nonlinear terms that " From 36953d10b9f46da97e142b20b7f70f1ffba61227 Mon Sep 17 00:00:00 2001 From: Bernard Knueven Date: Mon, 11 May 2026 12:10:47 -0600 Subject: [PATCH 07/12] allow SOS constraints (and other ctypes) to pass for standard form --- pyomo/repn/plugins/standard_form.py | 14 +++++++++++++- pyomo/repn/tests/test_standard_form.py | 23 +++++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/pyomo/repn/plugins/standard_form.py b/pyomo/repn/plugins/standard_form.py index bda63fa145e..bfec2545046 100644 --- a/pyomo/repn/plugins/standard_form.py +++ b/pyomo/repn/plugins/standard_form.py @@ -265,6 +265,18 @@ class LinearStandardFormCompiler: 'matrices.', ), ) + CONFIG.declare( + 'extra_valid_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( @@ -386,7 +398,7 @@ def write(self, model): RangeSet, Port, # TODO: Piecewise, Complementarity - }, + } | set(self.config.extra_valid_ctypes), targets={Suffix, Objective}, ) if unknown: diff --git a/pyomo/repn/tests/test_standard_form.py b/pyomo/repn/tests/test_standard_form.py index 34b6506f3ad..9fbe042d8e6 100644 --- a/pyomo/repn/tests/test_standard_form.py +++ b/pyomo/repn/tests/test_standard_form.py @@ -482,6 +482,29 @@ def test_allow_nonlinear_mixed(self): self.assertIn(id(m.x), col_ids) self.assertIn(id(m.y), col_ids) + def test_extra_valid_ctypes(self): + """Component types in extra_valid_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 extra_valid_ctypes, LSFC raises on the SOSConstraint. + with self.assertRaises(ValueError): + LinearStandardFormCompiler().write(m, mixed_form=True) + + # With extra_valid_ctypes, the SOSConstraint is silently skipped. + repn = LinearStandardFormCompiler().write( + m, mixed_form=True, extra_valid_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): From e431ec9cdd26354c00208821a4bf90a6e5a21db5 Mon Sep 17 00:00:00 2001 From: Bernard Knueven Date: Mon, 11 May 2026 12:26:16 -0600 Subject: [PATCH 08/12] NFC: black --- pyomo/repn/plugins/standard_form.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pyomo/repn/plugins/standard_form.py b/pyomo/repn/plugins/standard_form.py index bfec2545046..d64f202fcb8 100644 --- a/pyomo/repn/plugins/standard_form.py +++ b/pyomo/repn/plugins/standard_form.py @@ -398,7 +398,8 @@ def write(self, model): RangeSet, Port, # TODO: Piecewise, Complementarity - } | set(self.config.extra_valid_ctypes), + } + | set(self.config.extra_valid_ctypes), targets={Suffix, Objective}, ) if unknown: From a13c2e0bfb53f4c08e0e1a0f49af0c14af914787 Mon Sep 17 00:00:00 2001 From: Bernard Knueven Date: Mon, 11 May 2026 13:25:58 -0600 Subject: [PATCH 09/12] handle kernel range constraints in LSFC --- pyomo/repn/plugins/standard_form.py | 6 ++++ pyomo/repn/tests/test_standard_form.py | 44 ++++++++++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/pyomo/repn/plugins/standard_form.py b/pyomo/repn/plugins/standard_form.py index d64f202fcb8..097cbe880d4 100644 --- a/pyomo/repn/plugins/standard_form.py +++ b/pyomo/repn/plugins/standard_form.py @@ -577,6 +577,12 @@ def write(self, model): lb = value(lb) if ub.__class__ not in native_types: ub = value(ub) + # Normalize ±inf to None: kernel constraints may return + # ±inf instead of None for unbounded sides. + if lb == -float('inf'): + lb = None + if ub == float('inf'): + ub = None repn = visitor.walk_expression(body) if repn.nonlinear is not None: if allow_nonlinear: diff --git a/pyomo/repn/tests/test_standard_form.py b/pyomo/repn/tests/test_standard_form.py index 9fbe042d8e6..812675b1732 100644 --- a/pyomo/repn/tests/test_standard_form.py +++ b/pyomo/repn/tests/test_standard_form.py @@ -482,6 +482,50 @@ def test_allow_nonlinear_mixed(self): self.assertIn(id(m.x), col_ids) self.assertIn(id(m.y), col_ids) + def test_kernel_inf_bounds_normalized(self): + """Kernel constraints returning ±inf bounds are treated as unbounded (None). + + pyomo.kernel constraints store ±inf explicitly rather than None for + unbounded sides. LinearStandardFormCompiler must normalize these so + that a one-sided constraint is not misclassified as a range constraint. + """ + import pyomo.kernel as pmo + + m = pmo.block() + m.x = pmo.variable() + # lb=-inf (unbounded below) → should become a pure ≤ row, not a range row + m.c_ub = pmo.constraint(ub=2.0, body=m.x) + # ub=+inf (unbounded above) → should become a pure ≥ row, not a range row + m.c_lb = pmo.constraint(lb=-3.0, body=m.x) + # Explicit finite range → should still be a range row + m.c_rng = pmo.constraint((-1.0, m.x, 4.0)) + + repn = LinearStandardFormCompiler().write( + m, mixed_form=True, keep_range_constraints=True + ) + + by_con = {r.constraint: r.bound_type for r in repn.rows} + # ub-only: bound_type=1 (≤), not 2 (range) + self.assertEqual(by_con[m.c_ub], 1) + # lb-only: bound_type=-1 (≥), not 2 (range) + self.assertEqual(by_con[m.c_lb], -1) + # Finite range: bound_type=2 + self.assertEqual(by_con[m.c_rng], 2) + + # Verify rhs values + rhs_map = {r.constraint: repn.rhs[i] for i, r in enumerate(repn.rows)} + self.assertEqual(rhs_map[m.c_ub], 2.0) + self.assertEqual(rhs_map[m.c_lb], -3.0) + self.assertEqual(rhs_map[m.c_rng], 4.0) # ub of range row + + # rhs_range: only c_rng is a range row; range = 4 - (-1) = 5 + rhs_range_map = { + r.constraint: repn.rhs_range[i] for i, r in enumerate(repn.rows) + } + self.assertEqual(rhs_range_map[m.c_ub], 0.0) + self.assertEqual(rhs_range_map[m.c_lb], 0.0) + self.assertEqual(rhs_range_map[m.c_rng], 5.0) + def test_extra_valid_ctypes(self): """Component types in extra_valid_ctypes are permitted but not compiled.""" m = pyo.ConcreteModel() From c0741a8483d355ec25d2bae789fbf8f58f92c2b7 Mon Sep 17 00:00:00 2001 From: Bernard Knueven Date: Mon, 11 May 2026 13:48:40 -0600 Subject: [PATCH 10/12] bugfix: standard form normalizes constraints with +/-inf bounds --- pyomo/repn/plugins/standard_form.py | 14 ++--- pyomo/repn/tests/test_standard_form.py | 72 ++++++++++++++------------ 2 files changed, 48 insertions(+), 38 deletions(-) diff --git a/pyomo/repn/plugins/standard_form.py b/pyomo/repn/plugins/standard_form.py index 097cbe880d4..aab6a4742d8 100644 --- a/pyomo/repn/plugins/standard_form.py +++ b/pyomo/repn/plugins/standard_form.py @@ -577,12 +577,6 @@ def write(self, model): lb = value(lb) if ub.__class__ not in native_types: ub = value(ub) - # Normalize ±inf to None: kernel constraints may return - # ±inf instead of None for unbounded sides. - if lb == -float('inf'): - lb = None - if ub == float('inf'): - ub = None repn = visitor.walk_expression(body) if repn.nonlinear is not None: if allow_nonlinear: @@ -603,6 +597,14 @@ def write(self, model): linear_index = map(var_recorder.var_order.__getitem__, repn.linear) linear_data = repn.linear.values() + # Normalize ±inf to None: both kernel constraints and AML + # RangedExpressions can return ±inf instead of None for unbounded + # sides (e.g., `(-inf, x, 5)`). Treat them as unbounded. + if lb == -float('inf'): + lb = None + if ub == float('inf'): + ub = None + if lb is None and ub is None: # Note: you *cannot* output trivial (unbounded) # constraints in matrix format. I suppose we could add a diff --git a/pyomo/repn/tests/test_standard_form.py b/pyomo/repn/tests/test_standard_form.py index 812675b1732..f4f42979f55 100644 --- a/pyomo/repn/tests/test_standard_form.py +++ b/pyomo/repn/tests/test_standard_form.py @@ -482,49 +482,57 @@ def test_allow_nonlinear_mixed(self): self.assertIn(id(m.x), col_ids) self.assertIn(id(m.y), col_ids) - def test_kernel_inf_bounds_normalized(self): - """Kernel constraints returning ±inf bounds are treated as unbounded (None). - - pyomo.kernel constraints store ±inf explicitly rather than None for - unbounded sides. LinearStandardFormCompiler must normalize these so - that a one-sided constraint is not misclassified as a range constraint. + def test_inf_bounds_normalized(self): + """Constraints returning ±inf bounds are treated as unbounded (None). + + Both pyomo.kernel and AML constraints can return ±inf from + to_bounded_expression() when the user explicitly passes float('inf'). + LinearStandardFormCompiler must normalize these so that a one-sided + constraint is not misclassified as a range constraint, and a fully + unbounded constraint is skipped rather than emitted as a range row. """ import pyomo.kernel as pmo - m = pmo.block() - m.x = pmo.variable() + # --- kernel --- + mk = pmo.block() + mk.x = pmo.variable() # lb=-inf (unbounded below) → should become a pure ≤ row, not a range row - m.c_ub = pmo.constraint(ub=2.0, body=m.x) + mk.c_ub = pmo.constraint(ub=2.0, body=mk.x) # ub=+inf (unbounded above) → should become a pure ≥ row, not a range row - m.c_lb = pmo.constraint(lb=-3.0, body=m.x) + mk.c_lb = pmo.constraint(lb=-3.0, body=mk.x) # Explicit finite range → should still be a range row - m.c_rng = pmo.constraint((-1.0, m.x, 4.0)) + mk.c_rng = pmo.constraint((-1.0, mk.x, 4.0)) repn = LinearStandardFormCompiler().write( - m, mixed_form=True, keep_range_constraints=True + mk, mixed_form=True, keep_range_constraints=True ) - by_con = {r.constraint: r.bound_type for r in repn.rows} - # ub-only: bound_type=1 (≤), not 2 (range) - self.assertEqual(by_con[m.c_ub], 1) - # lb-only: bound_type=-1 (≥), not 2 (range) - self.assertEqual(by_con[m.c_lb], -1) - # Finite range: bound_type=2 - self.assertEqual(by_con[m.c_rng], 2) - - # Verify rhs values + self.assertEqual(by_con[mk.c_ub], 1) # ≤, not range + self.assertEqual(by_con[mk.c_lb], -1) # ≥, not range + self.assertEqual(by_con[mk.c_rng], 2) # finite range rhs_map = {r.constraint: repn.rhs[i] for i, r in enumerate(repn.rows)} - self.assertEqual(rhs_map[m.c_ub], 2.0) - self.assertEqual(rhs_map[m.c_lb], -3.0) - self.assertEqual(rhs_map[m.c_rng], 4.0) # ub of range row - - # rhs_range: only c_rng is a range row; range = 4 - (-1) = 5 - rhs_range_map = { - r.constraint: repn.rhs_range[i] for i, r in enumerate(repn.rows) - } - self.assertEqual(rhs_range_map[m.c_ub], 0.0) - self.assertEqual(rhs_range_map[m.c_lb], 0.0) - self.assertEqual(rhs_range_map[m.c_rng], 5.0) + self.assertEqual(rhs_map[mk.c_ub], 2.0) + self.assertEqual(rhs_map[mk.c_lb], -3.0) + self.assertEqual(rhs_map[mk.c_rng], 4.0) # ub of range row + rr_map = {r.constraint: repn.rhs_range[i] for i, r in enumerate(repn.rows)} + self.assertEqual(rr_map[mk.c_ub], 0.0) + self.assertEqual(rr_map[mk.c_lb], 0.0) + self.assertEqual(rr_map[mk.c_rng], 5.0) # 4 - (-1) + + # --- AML: explicit float('inf') in RangedExpression --- + m = pyo.ConcreteModel() + m.x = pyo.Var() + # One-sided: (-inf, x, 5) — lb should be treated as unbounded + m.c_ub = pyo.Constraint(rule=lambda m: (float('-inf'), m.x, 5)) + # Fully unbounded: (-inf, x, inf) — should be skipped entirely + m.c_skip = pyo.Constraint(rule=lambda m: (float('-inf'), m.x, float('inf'))) + + repn2 = LinearStandardFormCompiler().write( + m, mixed_form=True, keep_range_constraints=True + ) + by_con2 = {r.constraint: r.bound_type for r in repn2.rows} + self.assertEqual(by_con2[m.c_ub], 1) # ≤, not range + self.assertNotIn(m.c_skip, by_con2) # fully unbounded: skipped def test_extra_valid_ctypes(self): """Component types in extra_valid_ctypes are permitted but not compiled.""" From 6a2c69ae22b3316cc0df4226f8ac480d488f11a0 Mon Sep 17 00:00:00 2001 From: Bernard Knueven Date: Mon, 11 May 2026 13:19:01 -0600 Subject: [PATCH 11/12] fix bug pyomo.kernel variabls in TemplateVarRecorder --- pyomo/repn/tests/test_util.py | 17 +++++++++++++++++ pyomo/repn/util.py | 12 ++++++++---- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/pyomo/repn/tests/test_util.py b/pyomo/repn/tests/test_util.py index b8010d167f1..f26546a4186 100644 --- a/pyomo/repn/tests/test_util.py +++ b/pyomo/repn/tests/test_util.py @@ -878,6 +878,23 @@ def test_TemplateVarRecorder_user_varmap(self): vr.var_order, ) + def test_TemplateVarRecorder_kernel_variable(self): + """TemplateVarRecorder.add() must handle kernel variables, which do + not have a parent_component() method.""" + from pyomo.core.kernel.variable import variable + + v1 = variable() + v2 = variable() + + vm = {} + vr = TemplateVarRecorder(vm, SortComponents.deterministic) + vr.add(v1) + self.assertIn(id(v1), vm) + vr.add(v2) + self.assertIn(id(v2), vm) + self.assertEqual(len(vm), 2) + self.assertEqual({id(v1): 0, id(v2): 1}, vr.var_order) + if __name__ == "__main__": unittest.main() diff --git a/pyomo/repn/util.py b/pyomo/repn/util.py index 26d5ca20e1d..e80b162f29d 100644 --- a/pyomo/repn/util.py +++ b/pyomo/repn/util.py @@ -886,7 +886,12 @@ def add(self, var): # Note: the following is mostly a copy of # LinearBeforeChildDispatcher.record_var, but with extra # handling to update the env in the same loop - var_comp = var.parent_component() + try: + var_comp = var.parent_component() + except AttributeError: + # kernel variable objects do not have parent_component(); treat + # the variable itself as a scalar component. + var_comp = var # Double-check that the component has not already been processed # (through an individual var data) name = self.symbolmap.getSymbol(var_comp) @@ -903,9 +908,8 @@ def add(self, var): try: _iter = var_comp.items(self.sorter) except AttributeError: - # Note that this only works for the AML, as kernel does not - # provide a parent_component() - _iter = (None, var) + # kernel variables have no items(); treat as a scalar with index None + _iter = ((None, var),) if self._var_order is None: for i, (idx, v) in enumerate(_iter, start=len(vm)): vm[id(v)] = v From ca7f765e21af1b53284c05630641312a971cea30 Mon Sep 17 00:00:00 2001 From: Bernard Knueven Date: Mon, 11 May 2026 15:36:37 -0600 Subject: [PATCH 12/12] update LSFI to be self-consistent --- pyomo/repn/plugins/standard_form.py | 32 +++++++++++--------------- pyomo/repn/tests/test_standard_form.py | 10 ++++++++ 2 files changed, 24 insertions(+), 18 deletions(-) diff --git a/pyomo/repn/plugins/standard_form.py b/pyomo/repn/plugins/standard_form.py index aab6a4742d8..e565133b354 100644 --- a/pyomo/repn/plugins/standard_form.py +++ b/pyomo/repn/plugins/standard_form.py @@ -129,19 +129,19 @@ 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] + nonlinear_constraints : List[ConstraintData] or None - Constraints skipped because they contain nonlinear terms. Only - populated when ``allow_nonlinear=True`` is passed to - :meth:`~LinearStandardFormCompiler.write`; otherwise an - exception is raised for such constraints. + 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] + nonlinear_objectives : List[ObjectiveData] or None - Objectives skipped because they contain nonlinear terms. Only - populated when ``allow_nonlinear=True`` is passed to - :meth:`~LinearStandardFormCompiler.write`; otherwise an - exception is raised for such objectives. + 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). """ @@ -168,12 +168,8 @@ def __init__( self.columns = columns self.objectives = objectives self.eliminated_vars = eliminated_vars - self.nonlinear_constraints = ( - nonlinear_constraints if nonlinear_constraints is not None else [] - ) - self.nonlinear_objectives = ( - nonlinear_objectives if nonlinear_objectives is not None else [] - ) + self.nonlinear_constraints = nonlinear_constraints + self.nonlinear_objectives = nonlinear_objectives @property def x(self): @@ -805,8 +801,8 @@ def write(self, model): columns, objectives, eliminated_vars, - nonlinear_constraints=nonlinear_constraints, - nonlinear_objectives=nonlinear_objectives, + 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 diff --git a/pyomo/repn/tests/test_standard_form.py b/pyomo/repn/tests/test_standard_form.py index f4f42979f55..90de0b6075c 100644 --- a/pyomo/repn/tests/test_standard_form.py +++ b/pyomo/repn/tests/test_standard_form.py @@ -408,6 +408,16 @@ def test_keep_range_constraints(self): m, slack_form=True, keep_range_constraints=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()