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
44 changes: 37 additions & 7 deletions pyomo/contrib/piecewise/tests/test_nonlinear_to_pwl.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -35,6 +36,7 @@
Constraint,
Integers,
TransformationFactory,
exp,
log,
Objective,
Reals,
Expand Down Expand Up @@ -294,7 +296,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(
Expand Down Expand Up @@ -347,7 +349,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()
Expand Down Expand Up @@ -380,7 +382,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()
Expand All @@ -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):
Expand Down Expand Up @@ -718,17 +748,17 @@ 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)
)
self.assertEqual(len(all_pwlf), 1)
# 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)
Expand Down
58 changes: 51 additions & 7 deletions pyomo/contrib/piecewise/transform/nonlinear_to_pwl.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -661,23 +662,66 @@ def _needs_approximating(self, expr, approximate_quadratic):
return ExprType.QUADRATIC, True
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
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])
Comment on lines +689 to +692
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does separating these two cases matter? I mean, obviously you make a different expression tree, but do we need it?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think it matters to me.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm going to leave this just for consistency with QuadraticRepn.

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
Comment on lines +699 to +701
Copy link
Contributor

@emma58 emma58 Jan 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jsiirola, can this happen? I have something in the back of my mind telling me multiplier is sure to be 1 at this point?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just copied the to_expression code from the QuadraticRepn class and modified it slightly...

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

which is to say that I have no idea. I'll take a look, though.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, I can't read. This question was for @jsiirola. My bad.


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))
Expand Down