From 02daec1883c989eec51653d6b3c3cf78c16f8221 Mon Sep 17 00:00:00 2001 From: Michael Bynum Date: Tue, 13 Jan 2026 08:04:46 -0700 Subject: [PATCH 1/5] always separate linear parts in nonlinear to pwl transformation --- .../piecewise/transform/nonlinear_to_pwl.py | 43 ++++++++++++++++--- 1 file changed, 36 insertions(+), 7 deletions(-) diff --git a/pyomo/contrib/piecewise/transform/nonlinear_to_pwl.py b/pyomo/contrib/piecewise/transform/nonlinear_to_pwl.py index 26b4d533cbb..301cb044080 100644 --- a/pyomo/contrib/piecewise/transform/nonlinear_to_pwl.py +++ b/pyomo/contrib/piecewise/transform/nonlinear_to_pwl.py @@ -42,7 +42,7 @@ from pyomo.common.dependencies import numpy as np, packaging from pyomo.common.enums import IntEnum from pyomo.common.modeling import unique_component_name -from pyomo.core.expr.numeric_expr import SumExpression +from pyomo.core.expr.numeric_expr import SumExpression, mutable_expression from pyomo.core.expr import identify_variables from pyomo.core.expr import SumExpression from pyomo.core.util import target_list @@ -648,8 +648,9 @@ def _get_bounds_list(self, var_list, obj): bounds.append((v.bounds, v.is_integer())) return bounds - def _needs_approximating(self, expr, approximate_quadratic): - repn = self._quadratic_repn_visitor.walk_expression(expr) + def _needs_approximating(self, expr, approximate_quadratic, repn=None): + if repn is None: + repn = self._quadratic_repn_visitor.walk_expression(expr) if repn.nonlinear is None: if repn.quadratic is None: # Linear constraint. Always skip. @@ -660,24 +661,52 @@ def _needs_approximating(self, expr, approximate_quadratic): return ExprType.QUADRATIC, False return ExprType.QUADRATIC, True return ExprType.GENERAL, True + + def _separate_linear_parts(self, repn): + var_map = self._quadratic_repn_visitor.var_map + linear = 0 + nonlinear = 0 + if repn.nonlinear is not None: + nonlinear += repn.nonlinear + if repn.quadratic: + for (x1, x2), coef in repn.quadratic.items(): + if repn.multiplier_flag(coef): + if x1 == x2: + nonlinear += coef * var_map[x1] ** 2 + else: + nonlinear += coef * (var_map[x1] * var_map[x2]) + if repn.linear: + for vid, coef in repn.linear.items(): + if repn.multiplier_flag(coef): + linear += coef * var_map[vid] + if repn.constant_flag(repn.constant): + linear += repn.constant + if repn.multiplier_flag(repn.multiplier) != 1: + linear *= repn.multiplier + nonlinear *= repn.multiplier + + return linear, nonlinear def _approximate_expression( self, expr, obj, trans_block, config, approximate_quadratic ): + repn = self._quadratic_repn_visitor.walk_expression(expr) expr_type, needs_approximating = self._needs_approximating( - expr, approximate_quadratic + expr, approximate_quadratic, repn, ) if not needs_approximating: return None, expr_type + linear_part, nonlinear_part = self._separate_linear_parts(repn) + # Additively decompose expr and work on the pieces - pwl_summands = [] + pwl_summands = [linear_part] for k, subexpr in enumerate( _additively_decompose_expr( - expr, config.min_dimension_to_additively_decompose + nonlinear_part, config.min_dimension_to_additively_decompose ) if config.additively_decompose - else (expr,) + else (nonlinear_part,) ): # First check if this is a good idea expr_vars = list(identify_variables(subexpr, include_fixed=False)) From 7a521ab7c700cf23280d66a317849918debc88f0 Mon Sep 17 00:00:00 2001 From: Michael Bynum Date: Tue, 13 Jan 2026 08:05:20 -0700 Subject: [PATCH 2/5] always separate linear parts in nonlinear to pwl transformation --- pyomo/contrib/piecewise/transform/nonlinear_to_pwl.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyomo/contrib/piecewise/transform/nonlinear_to_pwl.py b/pyomo/contrib/piecewise/transform/nonlinear_to_pwl.py index 301cb044080..fc450f1b638 100644 --- a/pyomo/contrib/piecewise/transform/nonlinear_to_pwl.py +++ b/pyomo/contrib/piecewise/transform/nonlinear_to_pwl.py @@ -661,7 +661,7 @@ def _needs_approximating(self, expr, approximate_quadratic, repn=None): return ExprType.QUADRATIC, False return ExprType.QUADRATIC, True return ExprType.GENERAL, True - + def _separate_linear_parts(self, repn): var_map = self._quadratic_repn_visitor.var_map linear = 0 @@ -692,7 +692,7 @@ def _approximate_expression( ): repn = self._quadratic_repn_visitor.walk_expression(expr) expr_type, needs_approximating = self._needs_approximating( - expr, approximate_quadratic, repn, + expr, approximate_quadratic, repn ) if not needs_approximating: return None, expr_type From b16b0b6b16b8929f25e72a1553bb9fe329104620 Mon Sep 17 00:00:00 2001 From: Michael Bynum Date: Tue, 20 Jan 2026 08:25:20 -0700 Subject: [PATCH 3/5] fix piecewise tests --- .../piecewise/tests/test_nonlinear_to_pwl.py | 14 +++++++------- .../piecewise/transform/nonlinear_to_pwl.py | 15 +++++++++++++++ 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/pyomo/contrib/piecewise/tests/test_nonlinear_to_pwl.py b/pyomo/contrib/piecewise/tests/test_nonlinear_to_pwl.py index 491ba2f4069..ee378e45a46 100644 --- a/pyomo/contrib/piecewise/tests/test_nonlinear_to_pwl.py +++ b/pyomo/contrib/piecewise/tests/test_nonlinear_to_pwl.py @@ -294,7 +294,7 @@ def test_error_for_non_separable_exceeding_max_dimension(self): def test_do_not_additively_decompose_below_min_dimension(self): m = ConcreteModel() m.x = Var([0, 1, 2, 3, 4], bounds=(-4, 5)) - m.c = Constraint(expr=m.x[0] * m.x[1] + m.x[3] <= 4) + m.c = Constraint(expr=m.x[0] * m.x[1] + m.x[3]**3 <= 4) n_to_pwl = TransformationFactory('contrib.piecewise.nonlinear_to_pwl') n_to_pwl.apply_to( @@ -347,7 +347,7 @@ def test_uniform_sampling_discrete_vars(self): m = ConcreteModel() m.x = Var(['rocky', 'bullwinkle'], domain=Binary) m.y = Var(domain=Integers, bounds=(0, 5)) - m.c = Constraint(expr=m.x['rocky'] * m.x['bullwinkle'] + m.y <= 4) + m.c = Constraint(expr=m.x['rocky'] * m.x['bullwinkle'] + m.y**2 <= 4) n_to_pwl = TransformationFactory('contrib.piecewise.nonlinear_to_pwl') output = StringIO() @@ -380,7 +380,7 @@ def test_random_sampling_discrete_vars(self): m = ConcreteModel() m.x = Var(['rocky', 'bullwinkle'], domain=Binary) m.y = Var(domain=Integers, bounds=(0, 5)) - m.c = Constraint(expr=m.x['rocky'] * m.x['bullwinkle'] + m.y <= 4) + m.c = Constraint(expr=m.x['rocky'] * m.x['bullwinkle'] + m.y**2 <= 4) n_to_pwl = TransformationFactory('contrib.piecewise.nonlinear_to_pwl') output = StringIO() @@ -718,8 +718,8 @@ def test_transform_and_solve_additively_decomposes_model(self): # two terms self.assertIsInstance(new_obj.expr, SumExpression) self.assertEqual(len(new_obj.expr.args), 2) - first = new_obj.expr.args[0] - pwlf = first.expr.pw_linear_function + second = new_obj.expr.args[1] + pwlf = second.expr.pw_linear_function all_pwlf = list( xm.component_data_objects(PiecewiseLinearFunction, descend_into=True) ) @@ -727,8 +727,8 @@ def test_transform_and_solve_additively_decomposes_model(self): # It is on the active tree. self.assertIs(pwlf, all_pwlf[0]) - second = new_obj.expr.args[1] - assertExpressionsEqual(self, second, 5.04 * xm.x1) + first = new_obj.expr.args[0] + assertExpressionsEqual(self, first, 5.04 * xm.x1) objs = n_to_pwl.get_transformed_nonlinear_objectives(xm) self.assertEqual(len(objs), 0) diff --git a/pyomo/contrib/piecewise/transform/nonlinear_to_pwl.py b/pyomo/contrib/piecewise/transform/nonlinear_to_pwl.py index fc450f1b638..90ca8af22cf 100644 --- a/pyomo/contrib/piecewise/transform/nonlinear_to_pwl.py +++ b/pyomo/contrib/piecewise/transform/nonlinear_to_pwl.py @@ -663,6 +663,21 @@ def _needs_approximating(self, expr, approximate_quadratic, repn=None): return ExprType.GENERAL, True def _separate_linear_parts(self, repn): + """ + The idea here is to ensure that linear parts of constraints + always get separated from the nonlinear parts, even if + additively_decompose if False. The idea is that + + y >= exp(x) + x**3 + + should become + + y >= PWL(exp(x) + x**3) + + and not + + 0 >= PWL(exp(x) + x**3 - y) + """ var_map = self._quadratic_repn_visitor.var_map linear = 0 nonlinear = 0 From f3b8936c2c7999742ed914247b3cbb9fd4975436 Mon Sep 17 00:00:00 2001 From: Michael Bynum Date: Tue, 20 Jan 2026 08:25:38 -0700 Subject: [PATCH 4/5] run black --- pyomo/contrib/piecewise/tests/test_nonlinear_to_pwl.py | 2 +- pyomo/contrib/piecewise/transform/nonlinear_to_pwl.py | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/pyomo/contrib/piecewise/tests/test_nonlinear_to_pwl.py b/pyomo/contrib/piecewise/tests/test_nonlinear_to_pwl.py index ee378e45a46..9a674f8cd36 100644 --- a/pyomo/contrib/piecewise/tests/test_nonlinear_to_pwl.py +++ b/pyomo/contrib/piecewise/tests/test_nonlinear_to_pwl.py @@ -294,7 +294,7 @@ def test_error_for_non_separable_exceeding_max_dimension(self): def test_do_not_additively_decompose_below_min_dimension(self): m = ConcreteModel() m.x = Var([0, 1, 2, 3, 4], bounds=(-4, 5)) - m.c = Constraint(expr=m.x[0] * m.x[1] + m.x[3]**3 <= 4) + m.c = Constraint(expr=m.x[0] * m.x[1] + m.x[3] ** 3 <= 4) n_to_pwl = TransformationFactory('contrib.piecewise.nonlinear_to_pwl') n_to_pwl.apply_to( diff --git a/pyomo/contrib/piecewise/transform/nonlinear_to_pwl.py b/pyomo/contrib/piecewise/transform/nonlinear_to_pwl.py index 90ca8af22cf..65936eda109 100644 --- a/pyomo/contrib/piecewise/transform/nonlinear_to_pwl.py +++ b/pyomo/contrib/piecewise/transform/nonlinear_to_pwl.py @@ -665,16 +665,16 @@ def _needs_approximating(self, expr, approximate_quadratic, repn=None): def _separate_linear_parts(self, repn): """ The idea here is to ensure that linear parts of constraints - always get separated from the nonlinear parts, even if - additively_decompose if False. The idea is that + always get separated from the nonlinear parts, even if + additively_decompose if False. The idea is that y >= exp(x) + x**3 should become y >= PWL(exp(x) + x**3) - - and not + + and not 0 >= PWL(exp(x) + x**3 - y) """ From e2d6264edb1d90e28d9ce001447296a2b08a0498 Mon Sep 17 00:00:00 2001 From: Michael Bynum Date: Tue, 20 Jan 2026 08:55:04 -0700 Subject: [PATCH 5/5] add test to piecewise --- .../piecewise/tests/test_nonlinear_to_pwl.py | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/pyomo/contrib/piecewise/tests/test_nonlinear_to_pwl.py b/pyomo/contrib/piecewise/tests/test_nonlinear_to_pwl.py index 9a674f8cd36..bca8eaa31c3 100644 --- a/pyomo/contrib/piecewise/tests/test_nonlinear_to_pwl.py +++ b/pyomo/contrib/piecewise/tests/test_nonlinear_to_pwl.py @@ -11,6 +11,7 @@ from io import StringIO import logging +import math from pyomo.common.dependencies import attempt_import, scipy_available, numpy_available from pyomo.common.log import LoggingIntercept @@ -35,6 +36,7 @@ Constraint, Integers, TransformationFactory, + exp, log, Objective, Reals, @@ -407,6 +409,34 @@ def test_random_sampling_discrete_vars(self): for z in [0, 1, 5]: self.assertIn((x, y, z), points) + @unittest.skipUnless(numpy_available, "Numpy is not available") + @unittest.skipUnless(scipy_available, "Scipy is not available") + def test_do_not_pwl_linear_part(self): + m = ConcreteModel() + m.x = Var(bounds=(-3, 4)) + m.y = Var(initialize=7) + m.c = Constraint(expr=m.y >= exp(m.x) + m.x**3) + + # we want to make sure m.y does not show up in the PWL function + # even if additively_decompose is False + n_to_pwl = TransformationFactory('contrib.piecewise.nonlinear_to_pwl') + num_points = 5 + n_to_pwl.apply_to( + m, + num_points=num_points, + additively_decompose=False, + domain_partitioning_method=DomainPartitioningMethod.UNIFORM_GRID, + ) + + transformed_c = n_to_pwl.get_transformed_component(m.c) + self.assertIsInstance(transformed_c.body, SumExpression) + assertExpressionsEqual(self, transformed_c.body.args[0], -m.y) + pwlf = transformed_c.body.args[1].expr.pw_linear_function + for tup in pwlf._points: + self.assertEqual(len(tup), 1) + x = tup[0] + self.assertAlmostEqual(pwlf(x), math.exp(x) + x**3) + class TestNonlinearToPWL_2D(unittest.TestCase): def make_paraboloid_model(self):