From d864d2fc882c4c7104c542075aff49ec7ff924f4 Mon Sep 17 00:00:00 2001 From: John Siirola Date: Tue, 13 Aug 2024 00:32:46 -0600 Subject: [PATCH 01/12] Remove repeated code --- pyomo/core/base/constraint.py | 45 +++++++++++++---------------------- 1 file changed, 17 insertions(+), 28 deletions(-) diff --git a/pyomo/core/base/constraint.py b/pyomo/core/base/constraint.py index bc9a32f5404..8c7921b060f 100644 --- a/pyomo/core/base/constraint.py +++ b/pyomo/core/base/constraint.py @@ -236,6 +236,21 @@ def to_bounded_expression(self): return 0 if expr.__class__ is EqualityExpression else None, lhs - rhs, 0 return None, None, None + def _evaluate_bound(self, bound, is_lb): + if bound is None: + return None + if bound.__class__ not in native_numeric_types: + bound = float(value(bound)) + # Note that "bound != bound" catches float('nan') + if bound in _nonfinite_values or bound != bound: + if bound == (-_inf if is_lb else _inf): + return None + raise ValueError( + f"Constraint '{self.name}' created with an invalid non-finite " + f"{'lower' if is_lb else 'upper'} bound ({bound})." + ) + return bound + @property def body(self): """Access the body of a constraint expression.""" @@ -291,38 +306,12 @@ def upper(self): @property def lb(self): """Access the value of the lower bound of a constraint expression.""" - bound = self.to_bounded_expression()[0] - if bound is None: - return None - if bound.__class__ not in native_numeric_types: - bound = float(value(bound)) - # Note that "bound != bound" catches float('nan') - if bound in _nonfinite_values or bound != bound: - if bound == -_inf: - return None - raise ValueError( - f"Constraint '{self.name}' created with an invalid non-finite " - f"lower bound ({bound})." - ) - return bound + return self._evaluate_bound(self.to_bounded_expression()[0], True) @property def ub(self): """Access the value of the upper bound of a constraint expression.""" - bound = self.to_bounded_expression()[2] - if bound is None: - return None - if bound.__class__ not in native_numeric_types: - bound = float(value(bound)) - # Note that "bound != bound" catches float('nan') - if bound in _nonfinite_values or bound != bound: - if bound == _inf: - return None - raise ValueError( - f"Constraint '{self.name}' created with an invalid non-finite " - f"upper bound ({bound})." - ) - return bound + return self._evaluate_bound(self.to_bounded_expression()[2], False) @property def equality(self): From 58da384ff9853cc2f70ef1e1f6e6ca2b02365762 Mon Sep 17 00:00:00 2001 From: John Siirola Date: Tue, 13 Aug 2024 00:33:19 -0600 Subject: [PATCH 02/12] Add option to Constraint.to_bounded_expression() to evaluate the bounds --- pyomo/core/base/constraint.py | 29 +++++++++++++++++++++-------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/pyomo/core/base/constraint.py b/pyomo/core/base/constraint.py index 8c7921b060f..f0e020bcfd0 100644 --- a/pyomo/core/base/constraint.py +++ b/pyomo/core/base/constraint.py @@ -179,7 +179,7 @@ def __call__(self, exception=True): body = value(self.body, exception=exception) return body - def to_bounded_expression(self): + def to_bounded_expression(self, evaluate_bounds=False): """Convert this constraint to a tuple of 3 expressions (lb, body, ub) This method "standardizes" the expression into a 3-tuple of @@ -195,6 +195,13 @@ def to_bounded_expression(self): extension, the result) can change after fixing / unfixing :py:class:`Var` objects. + Parameters + ---------- + evaluate_bounds: bool + + If True, then the lower and upper bounds will be evaluated + to a finite numeric constant or None. + Raises ------ @@ -226,15 +233,21 @@ def to_bounded_expression(self): "variable upper bound. Cannot normalize the " "constraint or send it to a solver." ) - return ans - elif expr is not None: + elif expr is None: + ans = None, None, None + else: lhs, rhs = expr.args if rhs.__class__ in native_types or not rhs.is_potentially_variable(): - return rhs if expr.__class__ is EqualityExpression else None, lhs, rhs - if lhs.__class__ in native_types or not lhs.is_potentially_variable(): - return lhs, rhs, lhs if expr.__class__ is EqualityExpression else None - return 0 if expr.__class__ is EqualityExpression else None, lhs - rhs, 0 - return None, None, None + ans = rhs if expr.__class__ is EqualityExpression else None, lhs, rhs + elif lhs.__class__ in native_types or not lhs.is_potentially_variable(): + ans = lhs, rhs, lhs if expr.__class__ is EqualityExpression else None + else: + ans = 0 if expr.__class__ is EqualityExpression else None, lhs - rhs, 0 + + if evaluate_bounds: + lb, body, ub = ans + return self._evaluate_bound(lb, True), body, self._evaluate_bound(ub, False) + return ans def _evaluate_bound(self, bound, is_lb): if bound is None: From d29d3db3acd6a56918a7136fe14cc8d4e33534ee Mon Sep 17 00:00:00 2001 From: John Siirola Date: Tue, 13 Aug 2024 00:36:04 -0600 Subject: [PATCH 03/12] Update writers to use to_bounded_expression; recover performance loss from #3293 --- pyomo/repn/plugins/baron_writer.py | 49 ++++++++++++++++-------------- pyomo/repn/plugins/gams_writer.py | 15 ++++----- pyomo/repn/plugins/lp_writer.py | 10 +++--- pyomo/repn/plugins/nl_writer.py | 10 +++--- 4 files changed, 45 insertions(+), 39 deletions(-) diff --git a/pyomo/repn/plugins/baron_writer.py b/pyomo/repn/plugins/baron_writer.py index ab673b0c1c3..861735dc973 100644 --- a/pyomo/repn/plugins/baron_writer.py +++ b/pyomo/repn/plugins/baron_writer.py @@ -256,9 +256,9 @@ def _skip_trivial(constraint_data): suffix_gen = ( lambda b: pyomo.core.base.suffix.active_export_suffix_generator(b) ) - r_o_eqns = [] - c_eqns = [] - l_eqns = [] + r_o_eqns = {} + c_eqns = {} + l_eqns = {} branching_priorities_suffixes = [] for block in all_blocks_list: for name, suffix in suffix_gen(block): @@ -266,13 +266,14 @@ def _skip_trivial(constraint_data): branching_priorities_suffixes.append(suffix) elif name == 'constraint_types': for constraint_data, constraint_type in suffix.items(): + info = constraint_data.to_bounded_expression(True) if not _skip_trivial(constraint_data): if constraint_type.lower() == 'relaxationonly': - r_o_eqns.append(constraint_data) + r_o_eqns[constraint_data] = info elif constraint_type.lower() == 'convex': - c_eqns.append(constraint_data) + c_eqns[constraint_data] = info elif constraint_type.lower() == 'local': - l_eqns.append(constraint_data) + l_eqns[constraint_data] = info else: raise ValueError( "A suffix '%s' contained an invalid value: %s\n" @@ -294,7 +295,10 @@ def _skip_trivial(constraint_data): % (name, _location) ) - non_standard_eqns = r_o_eqns + c_eqns + l_eqns + non_standard_eqns = set() + non_standard_eqns.update(r_o_eqns) + non_standard_eqns.update(c_eqns) + non_standard_eqns.update(l_eqns) # # EQUATIONS @@ -304,7 +308,7 @@ def _skip_trivial(constraint_data): n_roeqns = len(r_o_eqns) n_ceqns = len(c_eqns) n_leqns = len(l_eqns) - eqns = [] + eqns = {} # Alias the constraints by declaration order since Baron does not # include the constraint names in the solution file. It is important @@ -321,14 +325,15 @@ def _skip_trivial(constraint_data): for constraint_data in block.component_data_objects( Constraint, active=True, sort=sorter, descend_into=False ): - if (not constraint_data.has_lb()) and (not constraint_data.has_ub()): + lb, body, ub = constraint_data.to_bounded_expression(True) + if lb is None and ub is None: assert not constraint_data.equality continue # non-binding, so skip if (not _skip_trivial(constraint_data)) and ( constraint_data not in non_standard_eqns ): - eqns.append(constraint_data) + eqns[constraint_data] = lb, body, ub con_symbol = symbol_map.createSymbol(constraint_data, c_labeler) assert not con_symbol.startswith('.') @@ -407,12 +412,12 @@ def mutable_param_gen(b): # Equation Definition output_file.write('c_e_FIX_ONE_VAR_CONST__: ONE_VAR_CONST__ == 1;\n') - for constraint_data in itertools.chain(eqns, r_o_eqns, c_eqns, l_eqns): + for constraint_data, (lb, body, ub) in itertools.chain( + eqns.items(), r_o_eqns.items(), c_eqns.items(), l_eqns.items() + ): variables = OrderedSet() # print(symbol_map.byObject.keys()) - eqn_body = expression_to_string( - constraint_data.body, variables, smap=symbol_map - ) + eqn_body = expression_to_string(body, variables, smap=symbol_map) # print(symbol_map.byObject.keys()) referenced_variable_ids.update(variables) @@ -439,22 +444,22 @@ def mutable_param_gen(b): # Equality constraint if constraint_data.equality: eqn_lhs = '' - eqn_rhs = ' == ' + ftoa(constraint_data.upper) + eqn_rhs = ' == ' + ftoa(ub) # Greater than constraint - elif not constraint_data.has_ub(): - eqn_rhs = ' >= ' + ftoa(constraint_data.lower) + elif ub is None: + eqn_rhs = ' >= ' + ftoa(lb) eqn_lhs = '' # Less than constraint - elif not constraint_data.has_lb(): - eqn_rhs = ' <= ' + ftoa(constraint_data.upper) + elif lb is None: + eqn_rhs = ' <= ' + ftoa(ub) eqn_lhs = '' # Double-sided constraint - elif constraint_data.has_lb() and constraint_data.has_ub(): - eqn_lhs = ftoa(constraint_data.lower) + ' <= ' - eqn_rhs = ' <= ' + ftoa(constraint_data.upper) + elif lb is not None and ub is not None: + eqn_lhs = ftoa(lb) + ' <= ' + eqn_rhs = ' <= ' + ftoa(ub) eqn_string = eqn_lhs + eqn_body + eqn_rhs + ';\n' output_file.write(eqn_string) diff --git a/pyomo/repn/plugins/gams_writer.py b/pyomo/repn/plugins/gams_writer.py index a0f407d7952..f0a9eb7afef 100644 --- a/pyomo/repn/plugins/gams_writer.py +++ b/pyomo/repn/plugins/gams_writer.py @@ -619,11 +619,12 @@ def _write_model( # encountered will be added to the var_list due to the labeler # defined above. for con in model.component_data_objects(Constraint, active=True, sort=sort): - if not con.has_lb() and not con.has_ub(): + lb, body, ub = con.to_bounded_expression(True) + if lb is None and ub is None: assert not con.equality continue # non-binding, so skip - con_body = as_numeric(con.body) + con_body = as_numeric(body) if skip_trivial_constraints and con_body.is_fixed(): continue if linear: @@ -642,20 +643,20 @@ def _write_model( constraint_names.append('%s' % cName) ConstraintIO.write( '%s.. %s =e= %s ;\n' - % (constraint_names[-1], con_body_str, ftoa(con.upper, False)) + % (constraint_names[-1], con_body_str, ftoa(ub, False)) ) else: - if con.has_lb(): + if lb is not None: constraint_names.append('%s_lo' % cName) ConstraintIO.write( '%s.. %s =l= %s ;\n' - % (constraint_names[-1], ftoa(con.lower, False), con_body_str) + % (constraint_names[-1], ftoa(lb, False), con_body_str) ) - if con.has_ub(): + if ub is not None: constraint_names.append('%s_hi' % cName) ConstraintIO.write( '%s.. %s =l= %s ;\n' - % (constraint_names[-1], con_body_str, ftoa(con.upper, False)) + % (constraint_names[-1], con_body_str, ftoa(ub, False)) ) obj = list(model.component_data_objects(Objective, active=True, sort=sort)) diff --git a/pyomo/repn/plugins/lp_writer.py b/pyomo/repn/plugins/lp_writer.py index 814f79a4eb9..2fbdae3571d 100644 --- a/pyomo/repn/plugins/lp_writer.py +++ b/pyomo/repn/plugins/lp_writer.py @@ -408,10 +408,10 @@ def write(self, model): if with_debug_timing and con.parent_component() is not last_parent: timer.toc('Constraint %s', last_parent, level=logging.DEBUG) last_parent = con.parent_component() - # Note: Constraint.lb/ub guarantee a return value that is - # either a (finite) native_numeric_type, or None - lb = con.lb - ub = con.ub + # Note: Constraint.to_bounded_expression(evaluate_bounds=True) + # guarantee a return value that is either a (finite) + # native_numeric_type, or None + lb, body, ub = con.to_bounded_expression(True) if lb is None and ub is None: # Note: you *cannot* output trivial (unbounded) @@ -419,7 +419,7 @@ def write(self, model): # slack variable if skip_trivial_constraints is False, # but that seems rather silly. continue - repn = constraint_visitor.walk_expression(con.body) + repn = constraint_visitor.walk_expression(body) if repn.nonlinear is not None: raise ValueError( f"Model constraint ({con.name}) contains nonlinear terms that " diff --git a/pyomo/repn/plugins/nl_writer.py b/pyomo/repn/plugins/nl_writer.py index 8fc82d21d30..ca7786ce167 100644 --- a/pyomo/repn/plugins/nl_writer.py +++ b/pyomo/repn/plugins/nl_writer.py @@ -723,14 +723,14 @@ def write(self, model): timer.toc('Constraint %s', last_parent, level=logging.DEBUG) last_parent = con.parent_component() scale = scaling_factor(con) - expr_info = visitor.walk_expression((con.body, con, 0, scale)) + # Note: Constraint.to_bounded_expression(evaluate_bounds=True) + # guarantee a return value that is either a (finite) + # native_numeric_type, or None + lb, body, ub = con.to_bounded_expression(True) + expr_info = visitor.walk_expression((body, con, 0, scale)) if expr_info.named_exprs: self._record_named_expression_usage(expr_info.named_exprs, con, 0) - # Note: Constraint.lb/ub guarantee a return value that is - # either a (finite) native_numeric_type, or None - lb = con.lb - ub = con.ub if lb is None and ub is None: # and self.config.skip_trivial_constraints: continue if scale != 1: From 9f59794537d147e1d7aaa91d3e15e851be5035e6 Mon Sep 17 00:00:00 2001 From: John Siirola Date: Tue, 13 Aug 2024 01:04:53 -0600 Subject: [PATCH 04/12] Fix kernel incompatibility --- pyomo/core/kernel/constraint.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/pyomo/core/kernel/constraint.py b/pyomo/core/kernel/constraint.py index fe8eb8b2c1f..ed877e8af92 100644 --- a/pyomo/core/kernel/constraint.py +++ b/pyomo/core/kernel/constraint.py @@ -160,6 +160,17 @@ def has_ub(self): ub = self.ub return (ub is not None) and (value(ub) != float('inf')) + def to_bounded_expression(self, evaluate_bounds=False): + if evaluate_bounds: + lb = self.lb + if lb == -float('inf'): + lb = None + ub = self.ub + if ub == float('inf'): + ub = None + return lb, self.body, ub + return self.lower, self.body, self.upper + class _MutableBoundsConstraintMixin(object): """ @@ -177,9 +188,6 @@ class _MutableBoundsConstraintMixin(object): # Define some of the IConstraint abstract methods # - def to_bounded_expression(self): - return self.lower, self.body, self.upper - @property def lower(self): """The expression for the lower bound of the constraint""" From 3f6bc13fdb6e9f1f772ca6401b59ab4e08be180d Mon Sep 17 00:00:00 2001 From: John Siirola Date: Tue, 13 Aug 2024 08:22:45 -0600 Subject: [PATCH 05/12] Add to_bounded_expression() to LinearMatrixConstraint --- pyomo/repn/beta/matrix.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pyomo/repn/beta/matrix.py b/pyomo/repn/beta/matrix.py index 0201c46eb18..992e1810fec 100644 --- a/pyomo/repn/beta/matrix.py +++ b/pyomo/repn/beta/matrix.py @@ -587,6 +587,11 @@ def constant(self): # Abstract Interface (ConstraintData) # + def to_bounded_expression(self, evaluate_bounds=False): + """Access this constraint as a single expression.""" + # Note that the bounds are always going to be floats... + return self.lower, self.body, self.upper + @property def body(self): """Access the body of a constraint expression.""" From 46e5bfb16b8ffc86c19be2314b46233e64c6f0d1 Mon Sep 17 00:00:00 2001 From: Emma Johnson Date: Tue, 13 Aug 2024 09:52:36 -0600 Subject: [PATCH 06/12] Adding linear-tree to optional dependencies --- setup.py | 1 + 1 file changed, 1 insertion(+) diff --git a/setup.py b/setup.py index 6d28e4d184b..4e2b8bd6042 100644 --- a/setup.py +++ b/setup.py @@ -262,6 +262,7 @@ def __ne__(self, other): 'optional': [ 'dill', # No direct use, but improves lambda pickle 'ipython', # contrib.viewer + 'linear-tree', # contrib.piecewise # Note: matplotlib 3.6.1 has bug #24127, which breaks # seaborn's histplot (triggering parmest failures) # Note: minimum version from community_detection use of From fc5ebc86cf940caf0579577a870f34866f64d0a2 Mon Sep 17 00:00:00 2001 From: Emma Johnson Date: Tue, 13 Aug 2024 09:58:56 -0600 Subject: [PATCH 07/12] Black being black --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 4e2b8bd6042..b1f4a60c4c6 100644 --- a/setup.py +++ b/setup.py @@ -262,7 +262,7 @@ def __ne__(self, other): 'optional': [ 'dill', # No direct use, but improves lambda pickle 'ipython', # contrib.viewer - 'linear-tree', # contrib.piecewise + 'linear-tree', # contrib.piecewise # Note: matplotlib 3.6.1 has bug #24127, which breaks # seaborn's histplot (triggering parmest failures) # Note: minimum version from community_detection use of From 844b09acad540ee914f866b662a2959ddfac46a7 Mon Sep 17 00:00:00 2001 From: Emma Johnson Date: Tue, 13 Aug 2024 10:16:27 -0600 Subject: [PATCH 08/12] Adding (failing) test for sampling discrete domains --- .../piecewise/tests/test_nonlinear_to_pwl.py | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/pyomo/contrib/piecewise/tests/test_nonlinear_to_pwl.py b/pyomo/contrib/piecewise/tests/test_nonlinear_to_pwl.py index a42846c2802..1428fa4a810 100644 --- a/pyomo/contrib/piecewise/tests/test_nonlinear_to_pwl.py +++ b/pyomo/contrib/piecewise/tests/test_nonlinear_to_pwl.py @@ -9,7 +9,11 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ +from io import StringIO +import logging + from pyomo.common.dependencies import attempt_import, scipy_available, numpy_available +from pyomo.common.log import LoggingIntercept import pyomo.common.unittest as unittest from pyomo.contrib.piecewise import PiecewiseLinearFunction from pyomo.contrib.piecewise.transform.nonlinear_to_pwl import ( @@ -23,9 +27,11 @@ ) from pyomo.core.expr.numeric_expr import SumExpression from pyomo.environ import ( + Binary, ConcreteModel, Var, Constraint, + Integers, TransformationFactory, log, Objective, @@ -303,6 +309,24 @@ def test_do_not_additively_decompose_below_min_dimension(self): # This is only approximated by one pwlf: self.assertIsInstance(transformed_c.body, _ExpressionData) + @unittest.skipUnless(numpy_available, "Numpy is not available") + 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) + + n_to_pwl = TransformationFactory('contrib.piecewise.nonlinear_to_pwl') + output = StringIO() + with LoggingIntercept(output, 'pyomo.contrib.piecewise', logging.WARNING): + n_to_pwl.apply_to( + m, + num_points=3, + additively_decompose=False, + domain_partitioning_method=DomainPartitioningMethod.UNIFORM_GRID, + ) + self.assertEqual(output.getvalue().strip()) + class TestNonlinearToPWL_2D(unittest.TestCase): def make_paraboloid_model(self): From 11326eb3fb73c43514c8be73b45f7a923dd46a23 Mon Sep 17 00:00:00 2001 From: Emma Johnson Date: Tue, 13 Aug 2024 10:23:21 -0600 Subject: [PATCH 09/12] Only use linear-tree for pypi, it appears to not be on conda --- .github/workflows/test_pr_and_main.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test_pr_and_main.yml b/.github/workflows/test_pr_and_main.yml index cc9760cbe5d..765e50826d0 100644 --- a/.github/workflows/test_pr_and_main.yml +++ b/.github/workflows/test_pr_and_main.yml @@ -29,7 +29,7 @@ defaults: env: PYTHONWARNINGS: ignore::UserWarning PYTHON_CORE_PKGS: wheel - PYPI_ONLY: z3-solver + PYPI_ONLY: z3-solver linear-tree PYPY_EXCLUDE: scipy numdifftools seaborn statsmodels CACHE_VER: v221013.1 NEOS_EMAIL: tests@pyomo.org From 3c233e13a2a20db4b5fece30792fe3d82b536498 Mon Sep 17 00:00:00 2001 From: Emma Johnson Date: Tue, 13 Aug 2024 12:09:42 -0600 Subject: [PATCH 10/12] Whoops, actually intercepting the logging stream I want to make sure is empty... --- pyomo/contrib/piecewise/tests/test_nonlinear_to_pwl.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyomo/contrib/piecewise/tests/test_nonlinear_to_pwl.py b/pyomo/contrib/piecewise/tests/test_nonlinear_to_pwl.py index 1428fa4a810..76246688cdf 100644 --- a/pyomo/contrib/piecewise/tests/test_nonlinear_to_pwl.py +++ b/pyomo/contrib/piecewise/tests/test_nonlinear_to_pwl.py @@ -318,14 +318,14 @@ def test_uniform_sampling_discrete_vars(self): n_to_pwl = TransformationFactory('contrib.piecewise.nonlinear_to_pwl') output = StringIO() - with LoggingIntercept(output, 'pyomo.contrib.piecewise', logging.WARNING): + with LoggingIntercept(output, 'pyomo.core', logging.WARNING): n_to_pwl.apply_to( m, num_points=3, additively_decompose=False, domain_partitioning_method=DomainPartitioningMethod.UNIFORM_GRID, ) - self.assertEqual(output.getvalue().strip()) + self.assertEqual(output.getvalue().strip(), "") class TestNonlinearToPWL_2D(unittest.TestCase): From e890a254bae81b0f9fb0a67a0d0b1d72c97e3ee0 Mon Sep 17 00:00:00 2001 From: Emma Johnson Date: Tue, 13 Aug 2024 17:44:11 -0600 Subject: [PATCH 11/12] Not sampling outside of discrete domains in uniform_grid and random_grid --- .../piecewise/tests/test_nonlinear_to_pwl.py | 78 +++++++++++++++++++ .../piecewise/transform/nonlinear_to_pwl.py | 40 ++++++---- 2 files changed, 103 insertions(+), 15 deletions(-) diff --git a/pyomo/contrib/piecewise/tests/test_nonlinear_to_pwl.py b/pyomo/contrib/piecewise/tests/test_nonlinear_to_pwl.py index 76246688cdf..07a0f090cd6 100644 --- a/pyomo/contrib/piecewise/tests/test_nonlinear_to_pwl.py +++ b/pyomo/contrib/piecewise/tests/test_nonlinear_to_pwl.py @@ -325,8 +325,86 @@ def test_uniform_sampling_discrete_vars(self): additively_decompose=False, domain_partitioning_method=DomainPartitioningMethod.UNIFORM_GRID, ) + # No warnings (this is to check that we aren't emitting a bunch of + # warnings about setting variables outside of their domains) self.assertEqual(output.getvalue().strip(), "") + transformed_c = n_to_pwl.get_transformed_component(m.c) + pwlf = transformed_c.body.expr.pw_linear_function + + # should sample 0, 1 for th m.x's + # should sample 0, 2, 5 for m.y (because of half to even rounding (*sigh*)) + points = set(pwlf._points) + self.assertEqual(len(points), 12) + for x in [0, 1]: + for y in [0, 1]: + for z in [0, 2, 5]: + self.assertIn((x, y, z), points) + + @unittest.skipUnless(numpy_available, "Numpy is not available") + 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) + + n_to_pwl = TransformationFactory('contrib.piecewise.nonlinear_to_pwl') + output = StringIO() + with LoggingIntercept(output, 'pyomo.core', logging.WARNING): + n_to_pwl.apply_to( + m, + num_points=3, + additively_decompose=False, + domain_partitioning_method=DomainPartitioningMethod.UNIFORM_GRID, + ) + # No warnings (this is to check that we aren't emitting a bunch of + # warnings about setting variables outside of their domains) + self.assertEqual(output.getvalue().strip(), "") + + transformed_c = n_to_pwl.get_transformed_component(m.c) + pwlf = transformed_c.body.expr.pw_linear_function + + # should sample 0, 1 for th m.x's + # should sample 0, 2, 5 for m.y (because of half to even rounding (*sigh*)) + points = set(pwlf._points) + self.assertEqual(len(points), 12) + for x in [0, 1]: + for y in [0, 1]: + for z in [0, 2, 5]: + self.assertIn((x, y, z), points) + + @unittest.skipUnless(numpy_available, "Numpy is not available") + 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) + + n_to_pwl = TransformationFactory('contrib.piecewise.nonlinear_to_pwl') + output = StringIO() + with LoggingIntercept(output, 'pyomo.core', logging.WARNING): + n_to_pwl.apply_to( + m, + num_points=3, + additively_decompose=False, + domain_partitioning_method=DomainPartitioningMethod.RANDOM_GRID, + ) + # No warnings (this is to check that we aren't emitting a bunch of + # warnings about setting variables outside of their domains) + self.assertEqual(output.getvalue().strip(), "") + + transformed_c = n_to_pwl.get_transformed_component(m.c) + pwlf = transformed_c.body.expr.pw_linear_function + + # should sample 0, 1 for th m.x's + # Happen to get 0, 1, 5 for m.y + points = set(pwlf._points) + self.assertEqual(len(points), 12) + for x in [0, 1]: + for y in [0, 1]: + for z in [0, 1, 5]: + self.assertIn((x, y, z), points) + class TestNonlinearToPWL_2D(unittest.TestCase): def make_paraboloid_model(self): diff --git a/pyomo/contrib/piecewise/transform/nonlinear_to_pwl.py b/pyomo/contrib/piecewise/transform/nonlinear_to_pwl.py index c4d7c801ba2..03f006b66bc 100644 --- a/pyomo/contrib/piecewise/transform/nonlinear_to_pwl.py +++ b/pyomo/contrib/piecewise/transform/nonlinear_to_pwl.py @@ -93,19 +93,29 @@ def __init__(self): def _get_random_point_grid(bounds, n, func, config, seed=42): # Generate randomized grid of points linspaces = [] - for lb, ub in bounds: - np.random.seed(seed) - linspaces.append(np.random.uniform(lb, ub, n)) + np.random.seed(seed) + for (lb, ub), is_integer in bounds: + if not is_integer: + linspaces.append(np.random.uniform(lb, ub, n)) + else: + size = min(n, ub - lb + 1) + linspaces.append(np.random.choice(range(lb, ub + 1), size=size, + replace=False)) return list(itertools.product(*linspaces)) def _get_uniform_point_grid(bounds, n, func, config): # Generate non-randomized grid of points linspaces = [] - for lb, ub in bounds: - # Issues happen when exactly using the boundary - nudge = (ub - lb) * 1e-4 - linspaces.append(np.linspace(lb + nudge, ub - nudge, n)) + for (lb, ub), is_integer in bounds: + if not is_integer: + # Issues happen when exactly using the boundary + nudge = (ub - lb) * 1e-4 + linspaces.append(np.linspace(lb + nudge, ub - nudge, n)) + else: + size = min(n, ub - lb + 1) + pts = np.linspace(lb, ub, size) + linspaces.append(np.array([round(i) for i in pts])) return list(itertools.product(*linspaces)) @@ -159,8 +169,8 @@ def _get_pwl_function_approximation(func, config, bounds): func: function to approximate config: ConfigDict for transformation, specifying domain_partitioning_method, num_points, and max_depth (if using linear trees) - bounds: list of tuples giving upper and lower bounds for each of func's - arguments + bounds: list of tuples giving upper and lower bounds and a boolean indicating + if the variable's domain is discrete or not, for each of func's arguments """ method = config.domain_partitioning_method n = config.num_points @@ -195,8 +205,8 @@ def _generate_bound_points(leaves, bounds): for pt in [lower_corner_list, upper_corner_list]: for i in range(len(pt)): # clamp within bounds range - pt[i] = max(pt[i], bounds[i][0]) - pt[i] = min(pt[i], bounds[i][1]) + pt[i] = max(pt[i], bounds[i][0][0]) + pt[i] = min(pt[i], bounds[i][0][1]) if tuple(lower_corner_list) not in bound_points: bound_points.append(tuple(lower_corner_list)) @@ -206,7 +216,7 @@ def _generate_bound_points(leaves, bounds): # This process should have gotten every interior bound point. However, all # but two of the corners of the overall bounding box should have been # missed. Let's fix that now. - for outer_corner in itertools.product(*bounds): + for outer_corner in itertools.product(*[b[0] for b in bounds]): if outer_corner not in bound_points: bound_points.append(outer_corner) return bound_points @@ -296,9 +306,9 @@ def _reassign_none_bounds(leaves, input_bounds): for l in L: for f in features: if leaves[l]['bounds'][f][0] == None: - leaves[l]['bounds'][f][0] = input_bounds[f][0] + leaves[l]['bounds'][f][0] = input_bounds[f][0][0] if leaves[l]['bounds'][f][1] == None: - leaves[l]['bounds'][f][1] = input_bounds[f][1] + leaves[l]['bounds'][f][1] = input_bounds[f][0][1] return leaves @@ -615,7 +625,7 @@ def _get_bounds_list(self, var_list, obj): "at least one bound" % (v.name, obj.name) ) else: - bounds.append(v.bounds) + bounds.append((v.bounds, v.is_integer())) return bounds def _needs_approximating(self, expr, approximate_quadratic): From 10b902603511dba62e93d03286304dab170cd541 Mon Sep 17 00:00:00 2001 From: Emma Johnson Date: Tue, 13 Aug 2024 17:44:33 -0600 Subject: [PATCH 12/12] black --- pyomo/contrib/piecewise/tests/test_nonlinear_to_pwl.py | 2 +- pyomo/contrib/piecewise/transform/nonlinear_to_pwl.py | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/pyomo/contrib/piecewise/tests/test_nonlinear_to_pwl.py b/pyomo/contrib/piecewise/tests/test_nonlinear_to_pwl.py index 07a0f090cd6..bc8d7a40027 100644 --- a/pyomo/contrib/piecewise/tests/test_nonlinear_to_pwl.py +++ b/pyomo/contrib/piecewise/tests/test_nonlinear_to_pwl.py @@ -371,7 +371,7 @@ def test_uniform_sampling_discrete_vars(self): for x in [0, 1]: for y in [0, 1]: for z in [0, 2, 5]: - self.assertIn((x, y, z), points) + self.assertIn((x, y, z), points) @unittest.skipUnless(numpy_available, "Numpy is not available") def test_random_sampling_discrete_vars(self): diff --git a/pyomo/contrib/piecewise/transform/nonlinear_to_pwl.py b/pyomo/contrib/piecewise/transform/nonlinear_to_pwl.py index 03f006b66bc..a35231dd890 100644 --- a/pyomo/contrib/piecewise/transform/nonlinear_to_pwl.py +++ b/pyomo/contrib/piecewise/transform/nonlinear_to_pwl.py @@ -99,8 +99,9 @@ def _get_random_point_grid(bounds, n, func, config, seed=42): linspaces.append(np.random.uniform(lb, ub, n)) else: size = min(n, ub - lb + 1) - linspaces.append(np.random.choice(range(lb, ub + 1), size=size, - replace=False)) + linspaces.append( + np.random.choice(range(lb, ub + 1), size=size, replace=False) + ) return list(itertools.product(*linspaces)) @@ -169,7 +170,7 @@ def _get_pwl_function_approximation(func, config, bounds): func: function to approximate config: ConfigDict for transformation, specifying domain_partitioning_method, num_points, and max_depth (if using linear trees) - bounds: list of tuples giving upper and lower bounds and a boolean indicating + bounds: list of tuples giving upper and lower bounds and a boolean indicating if the variable's domain is discrete or not, for each of func's arguments """ method = config.domain_partitioning_method