From 653f7a666c4758a41ad094baf3e198ba1da43632 Mon Sep 17 00:00:00 2001 From: Emma Johnson Date: Tue, 7 Jan 2025 13:48:31 -0700 Subject: [PATCH 001/103] Initial (not working) draft of Gurobi direct MINLP walker --- pyomo/contrib/gurobi_minlp/__init__.py | 0 pyomo/contrib/gurobi_minlp/repn/__init__.py | 0 .../gurobi_minlp/repn/gurobi_direct_minlp.py | 406 ++++++++++++++++++ pyomo/contrib/gurobi_minlp/tests/__init__.py | 0 .../tests/test_gurobi_minlp_writer.py | 240 +++++++++++ 5 files changed, 646 insertions(+) create mode 100644 pyomo/contrib/gurobi_minlp/__init__.py create mode 100644 pyomo/contrib/gurobi_minlp/repn/__init__.py create mode 100755 pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py create mode 100644 pyomo/contrib/gurobi_minlp/tests/__init__.py create mode 100755 pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_writer.py diff --git a/pyomo/contrib/gurobi_minlp/__init__.py b/pyomo/contrib/gurobi_minlp/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/pyomo/contrib/gurobi_minlp/repn/__init__.py b/pyomo/contrib/gurobi_minlp/repn/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py b/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py new file mode 100755 index 00000000000..8b593ce916f --- /dev/null +++ b/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py @@ -0,0 +1,406 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2024 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + +from pyomo.common.dependencies import attempt_import +from pyomo.common.collections import ComponentMap +from pyomo.common.config import ConfigDict, ConfigValue +from pyomo.common.numeric_types import native_complex_types + +# ESJ TODO: We should move this somewhere sensible +from pyomo.contrib.cp.repn.docplex_writer import collect_valid_components + +from pyomo.core.base import ( + Binary, + Block, + Constraint, + Expression, + Integers, + NonNegativeIntegers, + NonNegativeReals, + NonPositiveIntegers, + NonPositiveReals, + Objective, + Param, + Reals, + SortComponents, + Suffix, + Var, +) +import pyomo.core.expr as EXPR +from pyomo.core.expr.numeric_expr import ( + NegationExpression, + ProductExpression, + DivisionExpression, + PowExpression, + AbsExpression, + UnaryFunctionExpression, + Expr_ifExpression, + LinearExpression, + MonomialTermExpression, + SumExpression, +) +from pyomo.core.expr.visitor import StreamBasedExpressionVisitor, _EvaluationVisitor + +from pyomo.opt import SolverFactory, WriterFactory +from pyomo.repn.util import ( + apply_node_operation, + BeforeChildDispatcher, + complex_number_error, + ExitNodeDispatcher, + initialize_exit_node_dispatcher, +) + +gurobipy, gurobipy_available = attempt_import('gurobipy') +if gurobipy_available: + from gurobipy import GRB + +### FIXME: Remove the following as soon as non-active components no +### longer report active==True +from pyomo.network import Port +from pyomo.core.base import RangeSet, Set +### + + +_domain_map = ComponentMap(( + (Binary, (GRB.BINARY, -float('inf'), float('inf'))), + (Integers, (GRB.INTEGER, -float('inf'), float('inf'))), + (NonNegativeIntegers, (GRB.INTEGER, 0, float('inf'))), + (NonPositiveIntegers, (GRB.INTEGER, -float('inf'), 0)), + (NonNegativeReals, (GRB.CONTINUOUS, 0, float('inf'))), + (NonPositiveReals, (GRB.CONTINUOUS, -float('inf'), 0)), + (Reals, (GRB.CONTINUOUS, -float('inf'), float('inf'))), +)) + + +def _create_grb_var(visitor, pyomo_var, name=None): + pyo_domain = pyomo_var.domain + if pyo_domain in _domain_map: + domain, domain_lb, domain_ub = _domain_map[pyo_domain] + else: + raise ValueError( + "Unsupported domain for Var '%s': %s" % (pyomo_var.name, pyo_domain) + ) + lb = max(domain_lb, pyomo_var.lb) if pyomo_var.lb is not None else domain_lb + ub = min(domain_ub, pyomo_var.ub) if pyomo_var.ub is not None else domain_ub + return visitor.grb_model.addVar( + lb=lb, + ub=ub, + vtype=domain, + name=name + ) + + +class GurobiMINLPBeforeChildDispatcher(BeforeChildDispatcher): + @staticmethod + def _before_var(visitor, child): + _id = id(child) + if _id not in visitor.var_map: + if child.fixed: + # ESJ TODO: I want the linear walker implementation of + # check_constant... Could it be in the base class or something? + return False, visitor.check_constant(child.value, child) + grb_var = _create_grb_var( + visitor, + child, name=child.name if visitor.symbolic_solver_labels else None + ) + visitor.var_map[_id] = grb_var + return False, visitor.var_map[_id] + + +def _handle_sum(visitor, node, *args): + return sum(arg for arg in args) + + +def _handle_negation(visitor, node, arg): + return -arg + + +def _handle_product(visitor, node, arg1, arg2): + return arg1 * arg2 + + +def _handle_division(visitor, node, arg1, arg2): + # ESJ TODO: Not 100% sure that this is the right operator overloading in grbpy + return arg1 / arg2 + + +def _handle_pow(visitor, node, arg1, arg2): + return arg1 ** arg2 + + +def _handle_unary(visitor, node, arg): + ans = apply_node_operation(node, (arg[1],)) + # Unary includes sqrt() which can return complex numbers + if ans.__class__ in native_complex_types: + ans = complex_number_error(ans, visitor, node) + return ans + + +def _handle_abs(visitor, node, arg): + # TODO + pass + + +def _handle_named_expression(visitor, node, arg): + # TODO + pass + + +def _handle_expr_if(visitor, node, arg1, arg2, arg3): + # TODO + pass + + +# TODO: We have to handle relational expression if we support Expr_If :( + + +def define_exit_node_handlers(_exit_node_handlers=None): + if _exit_node_handlers is None: + _exit_node_handlers = {} + _exit_node_handlers[NegationExpression] = {None: _handle_negation} + _exit_node_handlers[SumExpression] = {None: _handle_sum} + _exit_node_handlers[LinearExpression] = {None: _handle_sum} + _exit_node_handlers[ProductExpression] = {None: _handle_product} + _exit_node_handlers[MonomialTermExpression] = {None: _handle_product} + _exit_node_handlers[DivisionExpression] = {None: _handle_division} + _exit_node_handlers[PowExpression] = {None: _handle_pow} + _exit_node_handlers[UnaryFunctionExpression] = {None: _handle_unary} + _exit_node_handlers[AbsExpression] = {None: _handle_abs} + _exit_node_handlers[Expression] = {None: _handle_named_expression} + _exit_node_handlers[Expr_ifExpression] = {None: _handle_expr_if} + + return _exit_node_handlers + + +class GurobiMINLPVisitor(StreamBasedExpressionVisitor): + before_child_dispatcher = GurobiMINLPBeforeChildDispatcher() + exit_node_dispatcher = ExitNodeDispatcher( + initialize_exit_node_dispatcher(define_exit_node_handlers()) + ) + + def __init__(self, grb_model, symbolic_solver_labels=False): + super().__init__() + self.grb_model = grb_model + self.symbolic_solver_labels = symbolic_solver_labels + self.var_map = {} + self._named_expressions = {} + self._eval_expr_visitor = _EvaluationVisitor(True) + self.evaluate = self._eval_expr_visitor.dfs_postorder_stack + + def initializeWalker(self, expr): + expr, src, src_index = expr + walk, result = self.beforeChild(None, expr, 0) + if not walk: + return False, self.finalizeResult(result) + return True, expr + + def beforeChild(self, node, child, child_idx): + # Return native types + if child.__class__ in EXPR.native_types: + return False, child + + return self.before_child_dispatcher[child.__class__](self, child) + + def exitNode(self, node, data): + return self.exit_node_dispatcher[node.__class__](self, node, *data) + + def finalizeResult(self, result): + self.grb_model.update() + return result + + # ESJ TODO: THIS IS COPIED FROM THE LINEAR WALKER--CAN WE PUT IT IN UTIL OR + # SOMETHING? + def check_constant(self, ans, obj): + if ans.__class__ not in EXPR.native_numeric_types: + # None can be returned from uninitialized Var/Param objects + if ans is None: + return InvalidNumber( + None, f"'{obj}' evaluated to a nonnumeric value '{ans}'" + ) + if ans.__class__ is InvalidNumber: + return ans + elif ans.__class__ in native_complex_types: + return complex_number_error(ans, self, obj) + else: + # It is possible to get other non-numeric types. Most + # common are bool and 1-element numpy.array(). We will + # attempt to convert the value to a float before + # proceeding. + # + # TODO: we should check bool and warn/error (while bool is + # convertible to float in Python, they have very + # different semantic meanings in Pyomo). + try: + ans = float(ans) + except: + return InvalidNumber( + ans, f"'{obj}' evaluated to a nonnumeric value '{ans}'" + ) + if ans != ans: + return InvalidNumber( + nan, f"'{obj}' evaluated to a nonnumeric value '{ans}'" + ) + return ans + + +@WriterFactory.register( + 'gurobi_minlp', + 'Direct interface to Gurobi that allows for general nonlinear expressions' +) +class GurobiMINLPWriter(object): + CONFIG = ConfigDict('gurobi_minlp_writer') + CONFIG.declare( + 'symbolic_solver_labels', + ConfigValue( + default=False, + domain=bool, + description='Write Pyomo Var and Constraint names to Gurobi model', + ), + ) + + def __init__(self): + self.config = self.CONFIG() + + def write(self, model, **options): + config = options.pop('config', self.config)(options) + + components, unknown = collect_valid_components( + model, + active=True, + sort=SortComponents.deterministic, + valid={ + Block, + Objective, + Constraint, + Var, + Param, + Suffix, + # FIXME: Non-active components should not report as Active + Set, + RangeSet, + Port, + }, + targets={ + Objective, + Constraint, + }, + ) + if unknown: + raise ValueError( + "The model ('%s') contains the following active components " + "that the Gurobi MINLP writer does not know how to " + "process:\n\t%s" + % ( + model.name, + "\n\t".join( + "%s:\n\t\t%s" % (k, "\n\t\t".join(map(attrgetter('name'), v))) + for k, v in unknown.items() + ), + ) + ) + + grb_model = grb.model() + visitor = GurobiMINLPVisitor( + grb_model, symbolic_solver_labels=config.symbolic_solver_labels + ) + + active_objs = components[Objective] + if len(active_objs) > 1: + raise ValueError( + "More than one active objective defined for " + "input model '%s': Cannot write to gurobipy." % model.name + ) + elif len(active_objs) == 1: + obj = active_objs[0] + obj_expr = visitor.walk_expression((obj.expr, obj, 0)) + if obj.sense is minimize: + # TODO + pass + else: + # TODO + pass + else: + # TODO: We have no objective--we should put in a dummy, consistent + # with the other writers? + pass + + # write constraints + for cons in components[Constraint]: + expr = visitor.walk_expression((cons.body, cons, 0)) + # TODO + + return grb_model, visitor.pyomo_to_gurobipy + +# ESJ TODO: We should probably not do this and actually tack this on to another +# solver? But I'm not sure. In any case, it should probably at least inerhit +# from another direct interface to Gurobi since all the handling of licenses and +# termination conditions and things should be common. +@SolverFactory.register('gurobi_direct_minlp', + doc='Direct interface to Gurobi version 12 and up ' + 'supporting general nonlinear expressions') +class GurobiMINLPSolver(object): + CONFIG = ConfigDict("gurobi_minlp_solver") + CONFIG.declare( + 'symbolic_solver_labels', + ConfigValue( + default=False, + domain=bool, + description='Write Pyomo Var and Constraint names to gurobipy model', + ), + ) + CONFIG.declare( + 'tee', + ConfigValue( + default=False, domain=bool, description="Stream solver output to terminal." + ), + ) + CONFIG.declare( + 'options', ConfigValue(default={}, description="Dictionary of solver options.") + ) + + def __init__(self, **kwds): + self.config = self.CONFIG() + self.config.set_value(kwds) + # TODO termination conditions and things + + # Support use as a context manager under current solver API + def __enter__(self): + return self + + def __exit__(self, t, v, traceback): + pass + + def available(self, exception_flag=True): + # TODO + pass + + def license_is_valid(self): + # TODO + pass + + def solve(self, model, **kwds): + """Solve the model. + + Args: + model (Block): a Pyomo model or Block to be solved + """ + config = self.config() + config.set_value(kwds) + + writer = GurobiMINLPWriter() + grb_model, var_map = writer.write( + model, symbolic_solver_labels=config.symbolic_solver_labels + ) + # TODO: Is this right?? + grbsol = grb_model.optimize(**self.options) + + # TODO: handle results status + #return results diff --git a/pyomo/contrib/gurobi_minlp/tests/__init__.py b/pyomo/contrib/gurobi_minlp/tests/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_writer.py b/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_writer.py new file mode 100755 index 00000000000..72ffd333e20 --- /dev/null +++ b/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_writer.py @@ -0,0 +1,240 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2024 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + +from pyomo.common.dependencies import attempt_import +import pyomo.common.unittest as unittest +from pyomo.contrib.gurobi_minlp.repn.gurobi_direct_minlp import ( + GurobiMINLPVisitor, +) +from pyomo.environ import ( + Binary, + ConcreteModel, + Constraint, + Integers, + log, + NonNegativeIntegers, + NonNegativeReals, + NonPositiveIntegers, + NonPositiveReals, + Param, + Reals, + sqrt, + Var, +) + +# TODO: Need to check major version >=12 too. +gurobipy, gurobipy_available = attempt_import('gurobipy') + +if gurobipy_available: + from gurobipy import GRB + +## DEBUG +from pytest import set_trace + + +class CommonTest(unittest.TestCase): + def get_model(self): + m = ConcreteModel() + m.x1 = Var(domain=NonNegativeReals) + m.x2 = Var(domain=Reals) + m.x3 = Var(domain=NonPositiveReals) + m.y1 = Var(domain=Integers) + m.y2 = Var(domain=NonNegativeIntegers) + m.y3 = Var(domain=NonPositiveIntegers) + m.z1 = Var(domain=Binary) + + return m + + def get_visitor(self): + grb_model = gurobipy.Model() + return GurobiMINLPVisitor(grb_model, symbolic_solver_labels=True) + + +@unittest.skipUnless(gurobipy_available, "gurobipy is not available") +class TestGurobiMINLPWalker(CommonTest): + def test_var_domains(self): + m = self.get_model() + e = m.x1 + m.x2 + m.x3 + m.y1 + m.y2 + m.y3 + m.z1 + visitor = self.get_visitor() + expr = visitor.walk_expression((e, e, 0)) + + x1 = visitor.var_map[id(m.x1)] + x2 = visitor.var_map[id(m.x2)] + x3 = visitor.var_map[id(m.x3)] + y1 = visitor.var_map[id(m.y1)] + y2 = visitor.var_map[id(m.y2)] + y3 = visitor.var_map[id(m.y3)] + z1 = visitor.var_map[id(m.z1)] + + self.assertEqual(x1.lb, 0) + self.assertEqual(x1.ub, float('inf')) + self.assertEqual(x1.vtype, GRB.CONTINUOUS) + + self.assertEqual(x2.lb, -float('inf')) + self.assertEqual(x2.ub, float('inf')) + self.assertEqual(x2.vtype, GRB.CONTINUOUS) + + self.assertEqual(x3.lb, -float('inf')) + self.assertEqual(x3.ub, 0) + self.assertEqual(x3.vtype, GRB.CONTINUOUS) + + self.assertEqual(y1.lb, -float('inf')) + self.assertEqual(y1.ub, float('inf')) + self.assertEqual(y1.vtype, GRB.INTEGER) + + self.assertEqual(y2.lb, 0) + self.assertEqual(y2.ub, float('inf')) + self.assertEqual(y2.vtype, GRB.INTEGER) + + self.assertEqual(y3.lb, -float('inf')) + self.assertEqual(y3.ub, 0) + self.assertEqual(y3.vtype, GRB.INTEGER) + + self.assertEqual(z1.vtype, GRB.BINARY) + + def test_var_bounds(self): + m = self.get_model() + m.x2.setlb(-34) + m.x2.setub(45) + m.x3.setub(5) + m.y1.setlb(-2) + m.y1.setub(3) + m.y2.setlb(-5) + m.z1.setub(4) + m.z1.setlb(-3) + + e = m.x1 + m.x2 + m.x3 + m.y1 + m.y2 + m.y3 + m.z1 + visitor = self.get_visitor() + expr = visitor.walk_expression((e, e, 0)) + + x2 = visitor.var_map[id(m.x2)] + x3 = visitor.var_map[id(m.x3)] + y1 = visitor.var_map[id(m.y1)] + y2 = visitor.var_map[id(m.y2)] + z1 = visitor.var_map[id(m.z1)] + + self.assertEqual(x2.lb, -34) + self.assertEqual(x2.ub, 45) + self.assertEqual(x2.vtype, GRB.CONTINUOUS) + + self.assertEqual(x3.lb, -float('inf')) + self.assertEqual(x3.ub, 0) + self.assertEqual(x3.vtype, GRB.CONTINUOUS) + + self.assertEqual(y1.lb, -2) + self.assertEqual(y1.ub, 3) + self.assertEqual(y1.vtype, GRB.INTEGER) + + self.assertEqual(y2.lb, 0) + self.assertEqual(y2.ub, float('inf')) + self.assertEqual(y2.vtype, GRB.INTEGER) + + self.assertEqual(z1.vtype, GRB.BINARY) + + def test_write_addition(self): + m = self.get_model() + m.c = Constraint(expr=m.x1 + m.x2 >= 3) + visitor = self.get_visitor() + expr = visitor.walk_expression((m.c.body, m.c, 0)) + + # TODO + + def test_write_subtraction(self): + m = self.get_model() + m.c = Constraint(expr=m.x1 - m.x2 >= 3) + visitor = self.get_visitor() + expr = visitor.walk_expression((m.c.body, m.c, 0)) + + # TODO + + def test_write_product(self): + m = self.get_model() + m.c = Constraint(expr=m.x1 * m.x2 >= 3) + visitor = self.get_visitor() + expr = visitor.walk_expression((m.c.body, m.c, 0)) + + # TODO + + def test_write_power_expression_var_const(self): + m = self.get_model() + m.c = Constraint(expr=m.x1 ** 2 >= 3) + visitor = self.get_visitor() + expr = visitor.walk_expression((m.c.body, m.c, 0)) + + # TODO + + def test_write_power_expression_var_var(self): + m = self.get_model() + m.c = Constraint(expr=m.x1 ** m.x2 >= 3) + visitor = self.get_visitor() + expr = visitor.walk_expression((m.c.body, m.c, 0)) + + # TODO + + def test_write_power_expression_const_var(self): + m = self.get_model() + m.c = Constraint(expr=2 ** m.x2 >= 3) + visitor = self.get_visitor() + expr = visitor.walk_expression((m.c.body, m.c, 0)) + + # TODO + + def test_write_absolute_value_expression(self): + m = self.get_model() + m.c = Constraint(expr=abs(m.x1) >= 3) + visitor = self.get_visitor() + expr = visitor.walk_expression((m.c.body, m.c, 0)) + + # TODO + + def test_write_expression_with_mutable_param(self): + m = self.get_model() + m.p = Param(initialize=4, mutable=True) + m.c = Constraint(expr=m.p ** m.x2 >= 3) + visitor = self.get_visitor() + expr = visitor.walk_expression((m.c.body, m.c, 0)) + + # TODO + + def test_monomial_expression(self): + m = self.get_model() + m.p = Param(initialize=4, mutable=True) + + const_expr = 3 * m.x1 + nested_expr = (1 / m.p) * m.x1 + pow_expr = (m.p ** (0.5)) * m.x1 + + visitor = self.get_visitor() + expr = visitor.walk_expression((const_expr, const_expr, 0)) + expr = visitor.walk_expression((nested_expr, nested_expr, 0)) + expr = visitor.walk_expression((pow_expr, pow_expr, 0)) + + # TODO + + def test_log_expression(self): + m = self.get_model() + m.c = Constraint(expr=log(m.x1) >= 3) + visitor = self.get_visitor() + expr = visitor.walk_expression((m.c.body, m.c, 0)) + + # TODO + + # TODO: what other unary expressions? + + def test_handle_complex_number_sqrt(self): + m = self.get_model() + m.p = Param(initialize=3, mutable=True) + m.c = Constraint(expr=sqrt(-m.p) + m.x1 >= 3) + visitor = self.get_visitor() + expr = visitor.walk_expression((m.c.body, m.c, 0)) + + # TODO + From d2edb217dbae7bd421c6121f14c635747e9f6378 Mon Sep 17 00:00:00 2001 From: Emma Johnson Date: Wed, 8 Jan 2025 07:10:59 -0700 Subject: [PATCH 002/103] Adding minimum gurobipy version --- pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py | 3 ++- pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_writer.py | 3 +-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py b/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py index 8b593ce916f..5d74d54eec7 100755 --- a/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py +++ b/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py @@ -58,7 +58,8 @@ initialize_exit_node_dispatcher, ) -gurobipy, gurobipy_available = attempt_import('gurobipy') + +gurobipy, gurobipy_available = attempt_import('gurobipy', minimum_version='12.0.0') if gurobipy_available: from gurobipy import GRB diff --git a/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_writer.py b/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_writer.py index 72ffd333e20..fe196322103 100755 --- a/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_writer.py +++ b/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_writer.py @@ -30,8 +30,7 @@ Var, ) -# TODO: Need to check major version >=12 too. -gurobipy, gurobipy_available = attempt_import('gurobipy') +gurobipy, gurobipy_available = attempt_import('gurobipy', minimum_version='12.0.0') if gurobipy_available: from gurobipy import GRB From 33ce4330d6875dadebef3b8f3f57a95f370f8d96 Mon Sep 17 00:00:00 2001 From: Emma Johnson Date: Wed, 8 Jan 2025 07:14:27 -0700 Subject: [PATCH 003/103] blackify --- .../gurobi_minlp/repn/gurobi_direct_minlp.py | 51 +++++++++---------- .../tests/test_gurobi_minlp_writer.py | 19 +++---- 2 files changed, 33 insertions(+), 37 deletions(-) diff --git a/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py b/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py index 5d74d54eec7..5d4ddcc312e 100755 --- a/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py +++ b/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py @@ -67,18 +67,21 @@ ### longer report active==True from pyomo.network import Port from pyomo.core.base import RangeSet, Set + ### -_domain_map = ComponentMap(( - (Binary, (GRB.BINARY, -float('inf'), float('inf'))), - (Integers, (GRB.INTEGER, -float('inf'), float('inf'))), - (NonNegativeIntegers, (GRB.INTEGER, 0, float('inf'))), - (NonPositiveIntegers, (GRB.INTEGER, -float('inf'), 0)), - (NonNegativeReals, (GRB.CONTINUOUS, 0, float('inf'))), - (NonPositiveReals, (GRB.CONTINUOUS, -float('inf'), 0)), - (Reals, (GRB.CONTINUOUS, -float('inf'), float('inf'))), -)) +_domain_map = ComponentMap( + ( + (Binary, (GRB.BINARY, -float('inf'), float('inf'))), + (Integers, (GRB.INTEGER, -float('inf'), float('inf'))), + (NonNegativeIntegers, (GRB.INTEGER, 0, float('inf'))), + (NonPositiveIntegers, (GRB.INTEGER, -float('inf'), 0)), + (NonNegativeReals, (GRB.CONTINUOUS, 0, float('inf'))), + (NonPositiveReals, (GRB.CONTINUOUS, -float('inf'), 0)), + (Reals, (GRB.CONTINUOUS, -float('inf'), float('inf'))), + ) +) def _create_grb_var(visitor, pyomo_var, name=None): @@ -91,12 +94,7 @@ def _create_grb_var(visitor, pyomo_var, name=None): ) lb = max(domain_lb, pyomo_var.lb) if pyomo_var.lb is not None else domain_lb ub = min(domain_ub, pyomo_var.ub) if pyomo_var.ub is not None else domain_ub - return visitor.grb_model.addVar( - lb=lb, - ub=ub, - vtype=domain, - name=name - ) + return visitor.grb_model.addVar(lb=lb, ub=ub, vtype=domain, name=name) class GurobiMINLPBeforeChildDispatcher(BeforeChildDispatcher): @@ -110,7 +108,8 @@ def _before_var(visitor, child): return False, visitor.check_constant(child.value, child) grb_var = _create_grb_var( visitor, - child, name=child.name if visitor.symbolic_solver_labels else None + child, + name=child.name if visitor.symbolic_solver_labels else None, ) visitor.var_map[_id] = grb_var return False, visitor.var_map[_id] @@ -134,7 +133,7 @@ def _handle_division(visitor, node, arg1, arg2): def _handle_pow(visitor, node, arg1, arg2): - return arg1 ** arg2 + return arg1**arg2 def _handle_unary(visitor, node, arg): @@ -254,7 +253,7 @@ def check_constant(self, ans, obj): @WriterFactory.register( 'gurobi_minlp', - 'Direct interface to Gurobi that allows for general nonlinear expressions' + 'Direct interface to Gurobi that allows for general nonlinear expressions', ) class GurobiMINLPWriter(object): CONFIG = ConfigDict('gurobi_minlp_writer') @@ -289,10 +288,7 @@ def write(self, model, **options): RangeSet, Port, }, - targets={ - Objective, - Constraint, - }, + targets={Objective, Constraint}, ) if unknown: raise ValueError( @@ -340,13 +336,16 @@ def write(self, model, **options): return grb_model, visitor.pyomo_to_gurobipy + # ESJ TODO: We should probably not do this and actually tack this on to another # solver? But I'm not sure. In any case, it should probably at least inerhit # from another direct interface to Gurobi since all the handling of licenses and # termination conditions and things should be common. -@SolverFactory.register('gurobi_direct_minlp', - doc='Direct interface to Gurobi version 12 and up ' - 'supporting general nonlinear expressions') +@SolverFactory.register( + 'gurobi_direct_minlp', + doc='Direct interface to Gurobi version 12 and up ' + 'supporting general nonlinear expressions', +) class GurobiMINLPSolver(object): CONFIG = ConfigDict("gurobi_minlp_solver") CONFIG.declare( @@ -404,4 +403,4 @@ def solve(self, model, **kwds): grbsol = grb_model.optimize(**self.options) # TODO: handle results status - #return results + # return results diff --git a/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_writer.py b/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_writer.py index fe196322103..915503f7f0d 100755 --- a/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_writer.py +++ b/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_writer.py @@ -11,9 +11,7 @@ from pyomo.common.dependencies import attempt_import import pyomo.common.unittest as unittest -from pyomo.contrib.gurobi_minlp.repn.gurobi_direct_minlp import ( - GurobiMINLPVisitor, -) +from pyomo.contrib.gurobi_minlp.repn.gurobi_direct_minlp import GurobiMINLPVisitor from pyomo.environ import ( Binary, ConcreteModel, @@ -80,7 +78,7 @@ def test_var_domains(self): self.assertEqual(x2.lb, -float('inf')) self.assertEqual(x2.ub, float('inf')) self.assertEqual(x2.vtype, GRB.CONTINUOUS) - + self.assertEqual(x3.lb, -float('inf')) self.assertEqual(x3.ub, 0) self.assertEqual(x3.vtype, GRB.CONTINUOUS) @@ -123,7 +121,7 @@ def test_var_bounds(self): self.assertEqual(x2.lb, -34) self.assertEqual(x2.ub, 45) self.assertEqual(x2.vtype, GRB.CONTINUOUS) - + self.assertEqual(x3.lb, -float('inf')) self.assertEqual(x3.ub, 0) self.assertEqual(x3.vtype, GRB.CONTINUOUS) @@ -160,11 +158,11 @@ def test_write_product(self): visitor = self.get_visitor() expr = visitor.walk_expression((m.c.body, m.c, 0)) - # TODO + # TODO def test_write_power_expression_var_const(self): m = self.get_model() - m.c = Constraint(expr=m.x1 ** 2 >= 3) + m.c = Constraint(expr=m.x1**2 >= 3) visitor = self.get_visitor() expr = visitor.walk_expression((m.c.body, m.c, 0)) @@ -172,7 +170,7 @@ def test_write_power_expression_var_const(self): def test_write_power_expression_var_var(self): m = self.get_model() - m.c = Constraint(expr=m.x1 ** m.x2 >= 3) + m.c = Constraint(expr=m.x1**m.x2 >= 3) visitor = self.get_visitor() expr = visitor.walk_expression((m.c.body, m.c, 0)) @@ -180,7 +178,7 @@ def test_write_power_expression_var_var(self): def test_write_power_expression_const_var(self): m = self.get_model() - m.c = Constraint(expr=2 ** m.x2 >= 3) + m.c = Constraint(expr=2**m.x2 >= 3) visitor = self.get_visitor() expr = visitor.walk_expression((m.c.body, m.c, 0)) @@ -197,7 +195,7 @@ def test_write_absolute_value_expression(self): def test_write_expression_with_mutable_param(self): m = self.get_model() m.p = Param(initialize=4, mutable=True) - m.c = Constraint(expr=m.p ** m.x2 >= 3) + m.c = Constraint(expr=m.p**m.x2 >= 3) visitor = self.get_visitor() expr = visitor.walk_expression((m.c.body, m.c, 0)) @@ -236,4 +234,3 @@ def test_handle_complex_number_sqrt(self): expr = visitor.walk_expression((m.c.body, m.c, 0)) # TODO - From ad3c8a3322694e20c1862958b2183a90c8e4976b Mon Sep 17 00:00:00 2001 From: Emma Johnson Date: Wed, 8 Jan 2025 13:41:02 -0700 Subject: [PATCH 004/103] Notes on how we're actually going to have to implement it--building on linear walker --- .../gurobi_minlp/repn/gurobi_direct_minlp.py | 96 +++++++++++++++---- 1 file changed, 76 insertions(+), 20 deletions(-) diff --git a/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py b/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py index 5d4ddcc312e..203ecba32b5 100755 --- a/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py +++ b/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py @@ -14,6 +14,28 @@ from pyomo.common.config import ConfigDict, ConfigValue from pyomo.common.numeric_types import native_complex_types +""" +Even in Gurobi 12: + +If you have f(x) == 0, you must write it as z == f(x) and then write z == 0. +Basically, you must introduce auxiliary variables for all the general nonlinear +parts. (And no worries about additively separable or anything--they do that +under the hood). + +Radhakrishna thinks we should replace the *entire* LHS of the constraint with the +auxiliary variable rather than just the nonlinear part. Otherwise we would really +need to keep track of what nonlinear subexpressions we had already replaced and make +sure to use the same auxiliary variables. + +Conclusion: So I think I should actually build on top of the linear walker and then +replace anything that has a nonlinear part... + +Model.addConstr() doesn't have the three-arg version anymore. + +Let's not use the '.nl' attribute at all for now--seems like the exception rather than +the rule that you would want to specifically tell Gurobi *not* to expand the expression. +""" + # ESJ TODO: We should move this somewhere sensible from pyomo.contrib.cp.repn.docplex_writer import collect_valid_components @@ -162,29 +184,51 @@ def _handle_expr_if(visitor, node, arg1, arg2, arg3): # TODO: We have to handle relational expression if we support Expr_If :( -def define_exit_node_handlers(_exit_node_handlers=None): - if _exit_node_handlers is None: - _exit_node_handlers = {} - _exit_node_handlers[NegationExpression] = {None: _handle_negation} - _exit_node_handlers[SumExpression] = {None: _handle_sum} - _exit_node_handlers[LinearExpression] = {None: _handle_sum} - _exit_node_handlers[ProductExpression] = {None: _handle_product} - _exit_node_handlers[MonomialTermExpression] = {None: _handle_product} - _exit_node_handlers[DivisionExpression] = {None: _handle_division} - _exit_node_handlers[PowExpression] = {None: _handle_pow} - _exit_node_handlers[UnaryFunctionExpression] = {None: _handle_unary} - _exit_node_handlers[AbsExpression] = {None: _handle_abs} - _exit_node_handlers[Expression] = {None: _handle_named_expression} - _exit_node_handlers[Expr_ifExpression] = {None: _handle_expr_if} - - return _exit_node_handlers +# def define_exit_node_handlers(_exit_node_handlers=None): +# if _exit_node_handlers is None: +# _exit_node_handlers = {} +# _exit_node_handlers[NegationExpression] = {None: _handle_negation} +# _exit_node_handlers[SumExpression] = {None: _handle_sum} +# _exit_node_handlers[LinearExpression] = {None: _handle_sum} +# _exit_node_handlers[ProductExpression] = {None: _handle_product} +# _exit_node_handlers[MonomialTermExpression] = {None: _handle_product} +# _exit_node_handlers[DivisionExpression] = {None: _handle_division} +# _exit_node_handlers[PowExpression] = {None: _handle_pow} +# _exit_node_handlers[UnaryFunctionExpression] = {None: _handle_unary} +# _exit_node_handlers[AbsExpression] = {None: _handle_abs} +# _exit_node_handlers[Expression] = {None: _handle_named_expression} +# _exit_node_handlers[Expr_ifExpression] = {None: _handle_expr_if} + +# return _exit_node_handlers + + +# _function_map = { +# 'exp': sympy.exp, +# 'log': sympy.log, +# 'log10': lambda x: sympy.log(x) / sympy.log(10), +# 'sin': sympy.sin, +# 'asin': sympy.asin, +# 'sinh': sympy.sinh, +# 'asinh': sympy.asinh, +# 'cos': sympy.cos, +# 'acos': sympy.acos, +# 'cosh': sympy.cosh, +# 'acosh': sympy.acosh, +# 'tan': sympy.tan, +# 'atan': sympy.atan, +# 'tanh': sympy.tanh, +# 'atanh': sympy.atanh, +# 'ceil': sympy.ceiling, +# 'floor': sympy.floor, +# 'sqrt': sympy.sqrt, +# } class GurobiMINLPVisitor(StreamBasedExpressionVisitor): before_child_dispatcher = GurobiMINLPBeforeChildDispatcher() - exit_node_dispatcher = ExitNodeDispatcher( - initialize_exit_node_dispatcher(define_exit_node_handlers()) - ) + # exit_node_dispatcher = ExitNodeDispatcher( + # initialize_exit_node_dispatcher(define_exit_node_handlers()) + # ) def __init__(self, grb_model, symbolic_solver_labels=False): super().__init__() @@ -210,7 +254,19 @@ def beforeChild(self, node, child, child_idx): return self.before_child_dispatcher[child.__class__](self, child) def exitNode(self, node, data): - return self.exit_node_dispatcher[node.__class__](self, node, *data) + return self._eval_expr_visitor.visit(node, data) + + # if node.__class__ is UnaryFunctionExpression: + # ans = apply_node_operation(node, (data[1],)) + # # Unary includes sqrt() which can return complex numbers + # if ans.__class__ in native_complex_types: + # ans = complex_number_error(ans, visitor, node) + # return ans + # _op = _pyomo_operator_map.get(node.__class__, None) + # if _op is None: + # return node._apply_operation(values) + # else: + # return _op(*tuple(values)) def finalizeResult(self, result): self.grb_model.update() From 7ad17942b47d26b630178c162df183f35a356ec9 Mon Sep 17 00:00:00 2001 From: Emma Johnson <12833636+emma58@users.noreply.github.com> Date: Wed, 8 Jan 2025 16:57:38 -0700 Subject: [PATCH 005/103] Switching onto quadratic walker to try to write constraints correctly from Gurobi's perspective. Starting to write the gurobi walker modeled after the sympy one --- .../gurobi_minlp/repn/gurobi_direct_minlp.py | 265 ++++++++---------- .../tests/test_gurobi_minlp_writer.py | 1 + 2 files changed, 122 insertions(+), 144 deletions(-) diff --git a/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py b/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py index 203ecba32b5..3ccff8a487d 100755 --- a/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py +++ b/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py @@ -14,28 +14,6 @@ from pyomo.common.config import ConfigDict, ConfigValue from pyomo.common.numeric_types import native_complex_types -""" -Even in Gurobi 12: - -If you have f(x) == 0, you must write it as z == f(x) and then write z == 0. -Basically, you must introduce auxiliary variables for all the general nonlinear -parts. (And no worries about additively separable or anything--they do that -under the hood). - -Radhakrishna thinks we should replace the *entire* LHS of the constraint with the -auxiliary variable rather than just the nonlinear part. Otherwise we would really -need to keep track of what nonlinear subexpressions we had already replaced and make -sure to use the same auxiliary variables. - -Conclusion: So I think I should actually build on top of the linear walker and then -replace anything that has a nonlinear part... - -Model.addConstr() doesn't have the three-arg version anymore. - -Let's not use the '.nl' attribute at all for now--seems like the exception rather than -the rule that you would want to specifically tell Gurobi *not* to expand the expression. -""" - # ESJ TODO: We should move this somewhere sensible from pyomo.contrib.cp.repn.docplex_writer import collect_valid_components @@ -45,6 +23,8 @@ Constraint, Expression, Integers, + minimize, + maximize, NonNegativeIntegers, NonNegativeReals, NonPositiveIntegers, @@ -72,18 +52,63 @@ from pyomo.core.expr.visitor import StreamBasedExpressionVisitor, _EvaluationVisitor from pyomo.opt import SolverFactory, WriterFactory +from pyomo.repn.quadratic import QuadraticRepnVisitor from pyomo.repn.util import ( apply_node_operation, BeforeChildDispatcher, complex_number_error, - ExitNodeDispatcher, - initialize_exit_node_dispatcher, + OrderedVarRecorder, ) +""" +Even in Gurobi 12: + +If you have f(x) == 0, you must write it as z == f(x) and then write z == 0. +Basically, you must introduce auxiliary variables for all the general nonlinear +parts. (And no worries about additively separable or anything--they do that +under the hood). + +Radhakrishna thinks we should replace the *entire* LHS of the constraint with the +auxiliary variable rather than just the nonlinear part. Otherwise we would really +need to keep track of what nonlinear subexpressions we had already replaced and make +sure to use the same auxiliary variables. + +Conclusion: So I think I should actually build on top of the linear walker and then +replace anything that has a nonlinear part... + +Model.addConstr() doesn't have the three-arg version anymore. + +Let's not use the '.nl' attribute at all for now--seems like the exception rather than +the rule that you would want to specifically tell Gurobi *not* to expand the expression. +""" + gurobipy, gurobipy_available = attempt_import('gurobipy', minimum_version='12.0.0') if gurobipy_available: - from gurobipy import GRB + from gurobipy import GRB, nlfunc + _function_map.update( + { + 'exp': nlfunc.exp, + 'log': nlfunc.log, + 'log10': nlfunc.log10, + 'sin': nlfunc.sin, + # TODO you are here + 'asin': sympy.asin, + 'sinh': sympy.sinh, + 'asinh': sympy.asinh, + 'cos': sympy.cos, + 'acos': sympy.acos, + 'cosh': sympy.cosh, + 'acosh': sympy.acosh, + 'tan': sympy.tan, + 'atan': sympy.atan, + 'tanh': sympy.tanh, + 'atanh': sympy.atanh, + 'ceil': sympy.ceiling, + 'floor': sympy.floor, + 'sqrt': sympy.sqrt, + } + ) ### FIXME: Remove the following as soon as non-active components no ### longer report active==True @@ -106,7 +131,7 @@ ) -def _create_grb_var(visitor, pyomo_var, name=None): +def _create_grb_var(visitor, pyomo_var, name=""): pyo_domain = pyomo_var.domain if pyo_domain in _domain_map: domain, domain_lb, domain_ub = _domain_map[pyo_domain] @@ -116,6 +141,7 @@ def _create_grb_var(visitor, pyomo_var, name=None): ) lb = max(domain_lb, pyomo_var.lb) if pyomo_var.lb is not None else domain_lb ub = min(domain_ub, pyomo_var.ub) if pyomo_var.ub is not None else domain_ub + print(f"Trying to add Var:\n\tlb={lb}\n\tub={ub}\n\tvtype={domain}\n\tname={name}") return visitor.grb_model.addVar(lb=lb, ub=ub, vtype=domain, name=name) @@ -131,104 +157,14 @@ def _before_var(visitor, child): grb_var = _create_grb_var( visitor, child, - name=child.name if visitor.symbolic_solver_labels else None, + name=child.name if visitor.symbolic_solver_labels else "", ) visitor.var_map[_id] = grb_var return False, visitor.var_map[_id] -def _handle_sum(visitor, node, *args): - return sum(arg for arg in args) - - -def _handle_negation(visitor, node, arg): - return -arg - - -def _handle_product(visitor, node, arg1, arg2): - return arg1 * arg2 - - -def _handle_division(visitor, node, arg1, arg2): - # ESJ TODO: Not 100% sure that this is the right operator overloading in grbpy - return arg1 / arg2 - - -def _handle_pow(visitor, node, arg1, arg2): - return arg1**arg2 - - -def _handle_unary(visitor, node, arg): - ans = apply_node_operation(node, (arg[1],)) - # Unary includes sqrt() which can return complex numbers - if ans.__class__ in native_complex_types: - ans = complex_number_error(ans, visitor, node) - return ans - - -def _handle_abs(visitor, node, arg): - # TODO - pass - - -def _handle_named_expression(visitor, node, arg): - # TODO - pass - - -def _handle_expr_if(visitor, node, arg1, arg2, arg3): - # TODO - pass - - -# TODO: We have to handle relational expression if we support Expr_If :( - - -# def define_exit_node_handlers(_exit_node_handlers=None): -# if _exit_node_handlers is None: -# _exit_node_handlers = {} -# _exit_node_handlers[NegationExpression] = {None: _handle_negation} -# _exit_node_handlers[SumExpression] = {None: _handle_sum} -# _exit_node_handlers[LinearExpression] = {None: _handle_sum} -# _exit_node_handlers[ProductExpression] = {None: _handle_product} -# _exit_node_handlers[MonomialTermExpression] = {None: _handle_product} -# _exit_node_handlers[DivisionExpression] = {None: _handle_division} -# _exit_node_handlers[PowExpression] = {None: _handle_pow} -# _exit_node_handlers[UnaryFunctionExpression] = {None: _handle_unary} -# _exit_node_handlers[AbsExpression] = {None: _handle_abs} -# _exit_node_handlers[Expression] = {None: _handle_named_expression} -# _exit_node_handlers[Expr_ifExpression] = {None: _handle_expr_if} - -# return _exit_node_handlers - - -# _function_map = { -# 'exp': sympy.exp, -# 'log': sympy.log, -# 'log10': lambda x: sympy.log(x) / sympy.log(10), -# 'sin': sympy.sin, -# 'asin': sympy.asin, -# 'sinh': sympy.sinh, -# 'asinh': sympy.asinh, -# 'cos': sympy.cos, -# 'acos': sympy.acos, -# 'cosh': sympy.cosh, -# 'acosh': sympy.acosh, -# 'tan': sympy.tan, -# 'atan': sympy.atan, -# 'tanh': sympy.tanh, -# 'atanh': sympy.atanh, -# 'ceil': sympy.ceiling, -# 'floor': sympy.floor, -# 'sqrt': sympy.sqrt, -# } - - class GurobiMINLPVisitor(StreamBasedExpressionVisitor): before_child_dispatcher = GurobiMINLPBeforeChildDispatcher() - # exit_node_dispatcher = ExitNodeDispatcher( - # initialize_exit_node_dispatcher(define_exit_node_handlers()) - # ) def __init__(self, grb_model, symbolic_solver_labels=False): super().__init__() @@ -237,7 +173,7 @@ def __init__(self, grb_model, symbolic_solver_labels=False): self.var_map = {} self._named_expressions = {} self._eval_expr_visitor = _EvaluationVisitor(True) - self.evaluate = self._eval_expr_visitor.dfs_postorder_stack + #self.evaluate = self._eval_expr_visitor.dfs_postorder_stack def initializeWalker(self, expr): expr, src, src_index = expr @@ -254,20 +190,12 @@ def beforeChild(self, node, child, child_idx): return self.before_child_dispatcher[child.__class__](self, child) def exitNode(self, node, data): + if node.__class__ is EXPR.UnaryFunctionExpression: + import pdb + pdb.set_trace() + return apply_node_operation((node, data[1],)) return self._eval_expr_visitor.visit(node, data) - # if node.__class__ is UnaryFunctionExpression: - # ans = apply_node_operation(node, (data[1],)) - # # Unary includes sqrt() which can return complex numbers - # if ans.__class__ in native_complex_types: - # ans = complex_number_error(ans, visitor, node) - # return ans - # _op = _pyomo_operator_map.get(node.__class__, None) - # if _op is None: - # return node._apply_operation(values) - # else: - # return _op(*tuple(values)) - def finalizeResult(self, result): self.grb_model.update() return result @@ -325,6 +253,23 @@ class GurobiMINLPWriter(object): def __init__(self): self.config = self.CONFIG() + def _create_gurobi_expression(self, expr, src, src_index, grb_model, + quadratic_visitor, grb_visitor): + """ + Uses the quadratic walker to determine if the expression is a general + nonlinear (non-quadratic) expression, and returns a gurobipy representation + of the expression + """ + repn = quadratic_visitor.walk_expression((expr, src, src_index)) + if repn.nonlinear is None: + grb_expr = grb_visitor.walk_expression((expr, src, src_index)) + return grb_expr, False, None + else: + # It's general nonlinear + grb_expr = grb_visitor.walk_expression((expr, src, src_index)) + aux = grb_model.addVar() + return grb_expr, True, aux + def write(self, model, **options): config = options.pop('config', self.config)(options) @@ -360,7 +305,14 @@ def write(self, model, **options): ) ) - grb_model = grb.model() + # Get a quadratic walker instance + quadratic_visitor = QuadraticRepnVisitor( + subexpression_cache={}, + var_recorder=OrderedVarRecorder({}, {}, None), + ) + + # create Gurobi model + grb_model = gurobipy.Model() visitor = GurobiMINLPVisitor( grb_model, symbolic_solver_labels=config.symbolic_solver_labels ) @@ -373,23 +325,48 @@ def write(self, model, **options): ) elif len(active_objs) == 1: obj = active_objs[0] - obj_expr = visitor.walk_expression((obj.expr, obj, 0)) if obj.sense is minimize: - # TODO - pass + sense = GRB.MINIMIZE else: - # TODO - pass - else: - # TODO: We have no objective--we should put in a dummy, consistent - # with the other writers? - pass - + sense = GRB.MAXIMIZE + obj_expr, nonlinear, aux = self._create_gurobi_expression( + obj.expr, + obj, + 0, + grb_model, + quadratic_visitor, + visitor + ) + if nonlinear: + # The objective must be linear or quadratic, so we move the nonlinear + # one to the constraints + grb_model.setObjective(aux, sense=sense) + grb_model.addConstr(aux == obj_expr) + else: + grb_model.setObjective(obj_expr, sense=sense) + # else it's fine--Gurobi doesn't require us to give an objective, so we don't + # write constraints for cons in components[Constraint]: - expr = visitor.walk_expression((cons.body, cons, 0)) - # TODO - + expr, nonlinear, aux = self._create_gurobi_expression( + cons.body, + cons, + 0, + grb_model, + quadratic_visitor, + visitor + ) + if nonlinear: + grb_model.addConstr(aux == expr) + expr = aux + if cons.equality: + grb_model.addConstr(cons.lower == expr) + else: + if cons.lower is not None: + grb_model.addConstr(cons.lower <= expr) + if cons.upper is not None: + grb_model.addConstr(cons.upper >= expr) + return grb_model, visitor.pyomo_to_gurobipy diff --git a/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_writer.py b/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_writer.py index 915503f7f0d..b1e9076dad0 100755 --- a/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_writer.py +++ b/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_writer.py @@ -234,3 +234,4 @@ def test_handle_complex_number_sqrt(self): expr = visitor.walk_expression((m.c.body, m.c, 0)) # TODO + From 4dcb73ef2e3651ee4e722d1b51308f9d5e6f925f Mon Sep 17 00:00:00 2001 From: Emma Johnson <12833636+emma58@users.noreply.github.com> Date: Thu, 9 Jan 2025 15:16:45 -0700 Subject: [PATCH 006/103] Adding start of integration test --- .../tests/test_gurobi_minlp_solver.py | 58 +++++++++++++++++++ .../tests/test_gurobi_minlp_writer.py | 0 2 files changed, 58 insertions(+) create mode 100644 pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_solver.py mode change 100755 => 100644 pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_writer.py diff --git a/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_solver.py b/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_solver.py new file mode 100644 index 00000000000..10ebf73715f --- /dev/null +++ b/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_solver.py @@ -0,0 +1,58 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2024 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + +from pyomo.common.dependencies import attempt_import +from pyomo.environ import ( + Constraint, + Objective, + log +) +from pyomo.opt import WriterFactory +from pyomo.contrib.gurobi_minlp.repn.gurobi_direct_minlp import ( + GurobiMINLPVisitor +) +from pyomo.contrib.gurobi_minlp.tests.test_gurobi_minlp_writer import ( + CommonTest +) + +gurobipy, gurobipy_available = attempt_import('gurobipy', minimum_version='12.0.0') + +def make_model(): + m = ConcreteModel() + m.x1 = Var(domain=NonNegativeReals, bounds=(0, 10)) + m.x2 = Var(domain=Reals, bounds=(-3, 4)) + m.x3 = Var(domain=NonPositiveReals, bounds=(-13, 0)) + m.y1 = Var(domain=Integers, bounds=(4, 14)) + m.y2 = Var(domain=NonNegativeIntegers, bounds=(5, 16)) + m.y3 = Var(domain=NonPositiveIntegers, bounds=(-13, 0)) + m.z1 = Var(domain=Binary) + + m.c1 = Constraint(expr=2 ** m.x2 >= m.x3) + m.c2 = Constraint(expr=m.y1 ** 2 <= 7) + m.c3 = Constraint(expr=m.y2 + m.y3 + 5 * m.z1 >= 17) + + m.obj = Objective(expr=log(m.x1)) + + return m + +class TestGurobiMINLPWriter(CommonTest): + def test_small_model(self): + grb_model = gurobipy.Model() + visitor = GurobiMINLPVisitor(grb_model, symbolic_solver_labels=True) + + m = make_model() + + grb_model, varmap = WriterFactory('gurobi_minlp').write(m) + grb_model.optimize() + + # TODO: assert something! :P + +# ESJ: Note: It appears they don't allow x1 ** x2...? diff --git a/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_writer.py b/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_writer.py old mode 100755 new mode 100644 From 162ab75dc646dfc09db23b6849b64c8e352057ea Mon Sep 17 00:00:00 2001 From: Emma Johnson <12833636+emma58@users.noreply.github.com> Date: Thu, 9 Jan 2025 15:18:20 -0700 Subject: [PATCH 007/103] Making the test file names make sense --- .../tests/test_gurobi_minlp_solver.py | 58 ---- .../tests/test_gurobi_minlp_walker.py | 237 ++++++++++++++ .../tests/test_gurobi_minlp_writer.py | 295 ++++-------------- 3 files changed, 295 insertions(+), 295 deletions(-) delete mode 100644 pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_solver.py create mode 100644 pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_walker.py diff --git a/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_solver.py b/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_solver.py deleted file mode 100644 index 10ebf73715f..00000000000 --- a/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_solver.py +++ /dev/null @@ -1,58 +0,0 @@ -# ___________________________________________________________________________ -# -# Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 -# National Technology and Engineering Solutions of Sandia, LLC -# Under the terms of Contract DE-NA0003525 with National Technology and -# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain -# rights in this software. -# This software is distributed under the 3-clause BSD License. -# ___________________________________________________________________________ - -from pyomo.common.dependencies import attempt_import -from pyomo.environ import ( - Constraint, - Objective, - log -) -from pyomo.opt import WriterFactory -from pyomo.contrib.gurobi_minlp.repn.gurobi_direct_minlp import ( - GurobiMINLPVisitor -) -from pyomo.contrib.gurobi_minlp.tests.test_gurobi_minlp_writer import ( - CommonTest -) - -gurobipy, gurobipy_available = attempt_import('gurobipy', minimum_version='12.0.0') - -def make_model(): - m = ConcreteModel() - m.x1 = Var(domain=NonNegativeReals, bounds=(0, 10)) - m.x2 = Var(domain=Reals, bounds=(-3, 4)) - m.x3 = Var(domain=NonPositiveReals, bounds=(-13, 0)) - m.y1 = Var(domain=Integers, bounds=(4, 14)) - m.y2 = Var(domain=NonNegativeIntegers, bounds=(5, 16)) - m.y3 = Var(domain=NonPositiveIntegers, bounds=(-13, 0)) - m.z1 = Var(domain=Binary) - - m.c1 = Constraint(expr=2 ** m.x2 >= m.x3) - m.c2 = Constraint(expr=m.y1 ** 2 <= 7) - m.c3 = Constraint(expr=m.y2 + m.y3 + 5 * m.z1 >= 17) - - m.obj = Objective(expr=log(m.x1)) - - return m - -class TestGurobiMINLPWriter(CommonTest): - def test_small_model(self): - grb_model = gurobipy.Model() - visitor = GurobiMINLPVisitor(grb_model, symbolic_solver_labels=True) - - m = make_model() - - grb_model, varmap = WriterFactory('gurobi_minlp').write(m) - grb_model.optimize() - - # TODO: assert something! :P - -# ESJ: Note: It appears they don't allow x1 ** x2...? diff --git a/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_walker.py b/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_walker.py new file mode 100644 index 00000000000..b1e9076dad0 --- /dev/null +++ b/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_walker.py @@ -0,0 +1,237 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2024 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + +from pyomo.common.dependencies import attempt_import +import pyomo.common.unittest as unittest +from pyomo.contrib.gurobi_minlp.repn.gurobi_direct_minlp import GurobiMINLPVisitor +from pyomo.environ import ( + Binary, + ConcreteModel, + Constraint, + Integers, + log, + NonNegativeIntegers, + NonNegativeReals, + NonPositiveIntegers, + NonPositiveReals, + Param, + Reals, + sqrt, + Var, +) + +gurobipy, gurobipy_available = attempt_import('gurobipy', minimum_version='12.0.0') + +if gurobipy_available: + from gurobipy import GRB + +## DEBUG +from pytest import set_trace + + +class CommonTest(unittest.TestCase): + def get_model(self): + m = ConcreteModel() + m.x1 = Var(domain=NonNegativeReals) + m.x2 = Var(domain=Reals) + m.x3 = Var(domain=NonPositiveReals) + m.y1 = Var(domain=Integers) + m.y2 = Var(domain=NonNegativeIntegers) + m.y3 = Var(domain=NonPositiveIntegers) + m.z1 = Var(domain=Binary) + + return m + + def get_visitor(self): + grb_model = gurobipy.Model() + return GurobiMINLPVisitor(grb_model, symbolic_solver_labels=True) + + +@unittest.skipUnless(gurobipy_available, "gurobipy is not available") +class TestGurobiMINLPWalker(CommonTest): + def test_var_domains(self): + m = self.get_model() + e = m.x1 + m.x2 + m.x3 + m.y1 + m.y2 + m.y3 + m.z1 + visitor = self.get_visitor() + expr = visitor.walk_expression((e, e, 0)) + + x1 = visitor.var_map[id(m.x1)] + x2 = visitor.var_map[id(m.x2)] + x3 = visitor.var_map[id(m.x3)] + y1 = visitor.var_map[id(m.y1)] + y2 = visitor.var_map[id(m.y2)] + y3 = visitor.var_map[id(m.y3)] + z1 = visitor.var_map[id(m.z1)] + + self.assertEqual(x1.lb, 0) + self.assertEqual(x1.ub, float('inf')) + self.assertEqual(x1.vtype, GRB.CONTINUOUS) + + self.assertEqual(x2.lb, -float('inf')) + self.assertEqual(x2.ub, float('inf')) + self.assertEqual(x2.vtype, GRB.CONTINUOUS) + + self.assertEqual(x3.lb, -float('inf')) + self.assertEqual(x3.ub, 0) + self.assertEqual(x3.vtype, GRB.CONTINUOUS) + + self.assertEqual(y1.lb, -float('inf')) + self.assertEqual(y1.ub, float('inf')) + self.assertEqual(y1.vtype, GRB.INTEGER) + + self.assertEqual(y2.lb, 0) + self.assertEqual(y2.ub, float('inf')) + self.assertEqual(y2.vtype, GRB.INTEGER) + + self.assertEqual(y3.lb, -float('inf')) + self.assertEqual(y3.ub, 0) + self.assertEqual(y3.vtype, GRB.INTEGER) + + self.assertEqual(z1.vtype, GRB.BINARY) + + def test_var_bounds(self): + m = self.get_model() + m.x2.setlb(-34) + m.x2.setub(45) + m.x3.setub(5) + m.y1.setlb(-2) + m.y1.setub(3) + m.y2.setlb(-5) + m.z1.setub(4) + m.z1.setlb(-3) + + e = m.x1 + m.x2 + m.x3 + m.y1 + m.y2 + m.y3 + m.z1 + visitor = self.get_visitor() + expr = visitor.walk_expression((e, e, 0)) + + x2 = visitor.var_map[id(m.x2)] + x3 = visitor.var_map[id(m.x3)] + y1 = visitor.var_map[id(m.y1)] + y2 = visitor.var_map[id(m.y2)] + z1 = visitor.var_map[id(m.z1)] + + self.assertEqual(x2.lb, -34) + self.assertEqual(x2.ub, 45) + self.assertEqual(x2.vtype, GRB.CONTINUOUS) + + self.assertEqual(x3.lb, -float('inf')) + self.assertEqual(x3.ub, 0) + self.assertEqual(x3.vtype, GRB.CONTINUOUS) + + self.assertEqual(y1.lb, -2) + self.assertEqual(y1.ub, 3) + self.assertEqual(y1.vtype, GRB.INTEGER) + + self.assertEqual(y2.lb, 0) + self.assertEqual(y2.ub, float('inf')) + self.assertEqual(y2.vtype, GRB.INTEGER) + + self.assertEqual(z1.vtype, GRB.BINARY) + + def test_write_addition(self): + m = self.get_model() + m.c = Constraint(expr=m.x1 + m.x2 >= 3) + visitor = self.get_visitor() + expr = visitor.walk_expression((m.c.body, m.c, 0)) + + # TODO + + def test_write_subtraction(self): + m = self.get_model() + m.c = Constraint(expr=m.x1 - m.x2 >= 3) + visitor = self.get_visitor() + expr = visitor.walk_expression((m.c.body, m.c, 0)) + + # TODO + + def test_write_product(self): + m = self.get_model() + m.c = Constraint(expr=m.x1 * m.x2 >= 3) + visitor = self.get_visitor() + expr = visitor.walk_expression((m.c.body, m.c, 0)) + + # TODO + + def test_write_power_expression_var_const(self): + m = self.get_model() + m.c = Constraint(expr=m.x1**2 >= 3) + visitor = self.get_visitor() + expr = visitor.walk_expression((m.c.body, m.c, 0)) + + # TODO + + def test_write_power_expression_var_var(self): + m = self.get_model() + m.c = Constraint(expr=m.x1**m.x2 >= 3) + visitor = self.get_visitor() + expr = visitor.walk_expression((m.c.body, m.c, 0)) + + # TODO + + def test_write_power_expression_const_var(self): + m = self.get_model() + m.c = Constraint(expr=2**m.x2 >= 3) + visitor = self.get_visitor() + expr = visitor.walk_expression((m.c.body, m.c, 0)) + + # TODO + + def test_write_absolute_value_expression(self): + m = self.get_model() + m.c = Constraint(expr=abs(m.x1) >= 3) + visitor = self.get_visitor() + expr = visitor.walk_expression((m.c.body, m.c, 0)) + + # TODO + + def test_write_expression_with_mutable_param(self): + m = self.get_model() + m.p = Param(initialize=4, mutable=True) + m.c = Constraint(expr=m.p**m.x2 >= 3) + visitor = self.get_visitor() + expr = visitor.walk_expression((m.c.body, m.c, 0)) + + # TODO + + def test_monomial_expression(self): + m = self.get_model() + m.p = Param(initialize=4, mutable=True) + + const_expr = 3 * m.x1 + nested_expr = (1 / m.p) * m.x1 + pow_expr = (m.p ** (0.5)) * m.x1 + + visitor = self.get_visitor() + expr = visitor.walk_expression((const_expr, const_expr, 0)) + expr = visitor.walk_expression((nested_expr, nested_expr, 0)) + expr = visitor.walk_expression((pow_expr, pow_expr, 0)) + + # TODO + + def test_log_expression(self): + m = self.get_model() + m.c = Constraint(expr=log(m.x1) >= 3) + visitor = self.get_visitor() + expr = visitor.walk_expression((m.c.body, m.c, 0)) + + # TODO + + # TODO: what other unary expressions? + + def test_handle_complex_number_sqrt(self): + m = self.get_model() + m.p = Param(initialize=3, mutable=True) + m.c = Constraint(expr=sqrt(-m.p) + m.x1 >= 3) + visitor = self.get_visitor() + expr = visitor.walk_expression((m.c.body, m.c, 0)) + + # TODO + diff --git a/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_writer.py b/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_writer.py index b1e9076dad0..93e12577e75 100644 --- a/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_writer.py +++ b/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_writer.py @@ -1,237 +1,58 @@ -# ___________________________________________________________________________ -# -# Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 -# National Technology and Engineering Solutions of Sandia, LLC -# Under the terms of Contract DE-NA0003525 with National Technology and -# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain -# rights in this software. -# This software is distributed under the 3-clause BSD License. -# ___________________________________________________________________________ - -from pyomo.common.dependencies import attempt_import -import pyomo.common.unittest as unittest -from pyomo.contrib.gurobi_minlp.repn.gurobi_direct_minlp import GurobiMINLPVisitor -from pyomo.environ import ( - Binary, - ConcreteModel, - Constraint, - Integers, - log, - NonNegativeIntegers, - NonNegativeReals, - NonPositiveIntegers, - NonPositiveReals, - Param, - Reals, - sqrt, - Var, -) - -gurobipy, gurobipy_available = attempt_import('gurobipy', minimum_version='12.0.0') - -if gurobipy_available: - from gurobipy import GRB - -## DEBUG -from pytest import set_trace - - -class CommonTest(unittest.TestCase): - def get_model(self): - m = ConcreteModel() - m.x1 = Var(domain=NonNegativeReals) - m.x2 = Var(domain=Reals) - m.x3 = Var(domain=NonPositiveReals) - m.y1 = Var(domain=Integers) - m.y2 = Var(domain=NonNegativeIntegers) - m.y3 = Var(domain=NonPositiveIntegers) - m.z1 = Var(domain=Binary) - - return m - - def get_visitor(self): - grb_model = gurobipy.Model() - return GurobiMINLPVisitor(grb_model, symbolic_solver_labels=True) - - -@unittest.skipUnless(gurobipy_available, "gurobipy is not available") -class TestGurobiMINLPWalker(CommonTest): - def test_var_domains(self): - m = self.get_model() - e = m.x1 + m.x2 + m.x3 + m.y1 + m.y2 + m.y3 + m.z1 - visitor = self.get_visitor() - expr = visitor.walk_expression((e, e, 0)) - - x1 = visitor.var_map[id(m.x1)] - x2 = visitor.var_map[id(m.x2)] - x3 = visitor.var_map[id(m.x3)] - y1 = visitor.var_map[id(m.y1)] - y2 = visitor.var_map[id(m.y2)] - y3 = visitor.var_map[id(m.y3)] - z1 = visitor.var_map[id(m.z1)] - - self.assertEqual(x1.lb, 0) - self.assertEqual(x1.ub, float('inf')) - self.assertEqual(x1.vtype, GRB.CONTINUOUS) - - self.assertEqual(x2.lb, -float('inf')) - self.assertEqual(x2.ub, float('inf')) - self.assertEqual(x2.vtype, GRB.CONTINUOUS) - - self.assertEqual(x3.lb, -float('inf')) - self.assertEqual(x3.ub, 0) - self.assertEqual(x3.vtype, GRB.CONTINUOUS) - - self.assertEqual(y1.lb, -float('inf')) - self.assertEqual(y1.ub, float('inf')) - self.assertEqual(y1.vtype, GRB.INTEGER) - - self.assertEqual(y2.lb, 0) - self.assertEqual(y2.ub, float('inf')) - self.assertEqual(y2.vtype, GRB.INTEGER) - - self.assertEqual(y3.lb, -float('inf')) - self.assertEqual(y3.ub, 0) - self.assertEqual(y3.vtype, GRB.INTEGER) - - self.assertEqual(z1.vtype, GRB.BINARY) - - def test_var_bounds(self): - m = self.get_model() - m.x2.setlb(-34) - m.x2.setub(45) - m.x3.setub(5) - m.y1.setlb(-2) - m.y1.setub(3) - m.y2.setlb(-5) - m.z1.setub(4) - m.z1.setlb(-3) - - e = m.x1 + m.x2 + m.x3 + m.y1 + m.y2 + m.y3 + m.z1 - visitor = self.get_visitor() - expr = visitor.walk_expression((e, e, 0)) - - x2 = visitor.var_map[id(m.x2)] - x3 = visitor.var_map[id(m.x3)] - y1 = visitor.var_map[id(m.y1)] - y2 = visitor.var_map[id(m.y2)] - z1 = visitor.var_map[id(m.z1)] - - self.assertEqual(x2.lb, -34) - self.assertEqual(x2.ub, 45) - self.assertEqual(x2.vtype, GRB.CONTINUOUS) - - self.assertEqual(x3.lb, -float('inf')) - self.assertEqual(x3.ub, 0) - self.assertEqual(x3.vtype, GRB.CONTINUOUS) - - self.assertEqual(y1.lb, -2) - self.assertEqual(y1.ub, 3) - self.assertEqual(y1.vtype, GRB.INTEGER) - - self.assertEqual(y2.lb, 0) - self.assertEqual(y2.ub, float('inf')) - self.assertEqual(y2.vtype, GRB.INTEGER) - - self.assertEqual(z1.vtype, GRB.BINARY) - - def test_write_addition(self): - m = self.get_model() - m.c = Constraint(expr=m.x1 + m.x2 >= 3) - visitor = self.get_visitor() - expr = visitor.walk_expression((m.c.body, m.c, 0)) - - # TODO - - def test_write_subtraction(self): - m = self.get_model() - m.c = Constraint(expr=m.x1 - m.x2 >= 3) - visitor = self.get_visitor() - expr = visitor.walk_expression((m.c.body, m.c, 0)) - - # TODO - - def test_write_product(self): - m = self.get_model() - m.c = Constraint(expr=m.x1 * m.x2 >= 3) - visitor = self.get_visitor() - expr = visitor.walk_expression((m.c.body, m.c, 0)) - - # TODO - - def test_write_power_expression_var_const(self): - m = self.get_model() - m.c = Constraint(expr=m.x1**2 >= 3) - visitor = self.get_visitor() - expr = visitor.walk_expression((m.c.body, m.c, 0)) - - # TODO - - def test_write_power_expression_var_var(self): - m = self.get_model() - m.c = Constraint(expr=m.x1**m.x2 >= 3) - visitor = self.get_visitor() - expr = visitor.walk_expression((m.c.body, m.c, 0)) - - # TODO - - def test_write_power_expression_const_var(self): - m = self.get_model() - m.c = Constraint(expr=2**m.x2 >= 3) - visitor = self.get_visitor() - expr = visitor.walk_expression((m.c.body, m.c, 0)) - - # TODO - - def test_write_absolute_value_expression(self): - m = self.get_model() - m.c = Constraint(expr=abs(m.x1) >= 3) - visitor = self.get_visitor() - expr = visitor.walk_expression((m.c.body, m.c, 0)) - - # TODO - - def test_write_expression_with_mutable_param(self): - m = self.get_model() - m.p = Param(initialize=4, mutable=True) - m.c = Constraint(expr=m.p**m.x2 >= 3) - visitor = self.get_visitor() - expr = visitor.walk_expression((m.c.body, m.c, 0)) - - # TODO - - def test_monomial_expression(self): - m = self.get_model() - m.p = Param(initialize=4, mutable=True) - - const_expr = 3 * m.x1 - nested_expr = (1 / m.p) * m.x1 - pow_expr = (m.p ** (0.5)) * m.x1 - - visitor = self.get_visitor() - expr = visitor.walk_expression((const_expr, const_expr, 0)) - expr = visitor.walk_expression((nested_expr, nested_expr, 0)) - expr = visitor.walk_expression((pow_expr, pow_expr, 0)) - - # TODO - - def test_log_expression(self): - m = self.get_model() - m.c = Constraint(expr=log(m.x1) >= 3) - visitor = self.get_visitor() - expr = visitor.walk_expression((m.c.body, m.c, 0)) - - # TODO - - # TODO: what other unary expressions? - - def test_handle_complex_number_sqrt(self): - m = self.get_model() - m.p = Param(initialize=3, mutable=True) - m.c = Constraint(expr=sqrt(-m.p) + m.x1 >= 3) - visitor = self.get_visitor() - expr = visitor.walk_expression((m.c.body, m.c, 0)) - - # TODO - +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2024 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + +from pyomo.common.dependencies import attempt_import +from pyomo.environ import ( + Constraint, + Objective, + log +) +from pyomo.opt import WriterFactory +from pyomo.contrib.gurobi_minlp.repn.gurobi_direct_minlp import ( + GurobiMINLPVisitor +) +from pyomo.contrib.gurobi_minlp.tests.test_gurobi_minlp_walker import ( + CommonTest +) + +gurobipy, gurobipy_available = attempt_import('gurobipy', minimum_version='12.0.0') + +def make_model(): + m = ConcreteModel() + m.x1 = Var(domain=NonNegativeReals, bounds=(0, 10)) + m.x2 = Var(domain=Reals, bounds=(-3, 4)) + m.x3 = Var(domain=NonPositiveReals, bounds=(-13, 0)) + m.y1 = Var(domain=Integers, bounds=(4, 14)) + m.y2 = Var(domain=NonNegativeIntegers, bounds=(5, 16)) + m.y3 = Var(domain=NonPositiveIntegers, bounds=(-13, 0)) + m.z1 = Var(domain=Binary) + + m.c1 = Constraint(expr=2 ** m.x2 >= m.x3) + m.c2 = Constraint(expr=m.y1 ** 2 <= 7) + m.c3 = Constraint(expr=m.y2 + m.y3 + 5 * m.z1 >= 17) + + m.obj = Objective(expr=log(m.x1)) + + return m + +class TestGurobiMINLPWriter(CommonTest): + def test_small_model(self): + grb_model = gurobipy.Model() + visitor = GurobiMINLPVisitor(grb_model, symbolic_solver_labels=True) + + m = make_model() + + grb_model, varmap = WriterFactory('gurobi_minlp').write(m) + grb_model.optimize() + + # TODO: assert something! :P + +# ESJ: Note: It appears they don't allow x1 ** x2...? From 16def08ec4ca287b6247796c0b1abe9e59ed3774 Mon Sep 17 00:00:00 2001 From: Emma Johnson <12833636+emma58@users.noreply.github.com> Date: Tue, 4 Feb 2025 13:26:20 -0700 Subject: [PATCH 008/103] Working version of the Gurobi walker based on the expression evaluation visitor, no real tests to speak of yet though --- .../gurobi_minlp/repn/gurobi_direct_minlp.py | 102 +++++++++++++----- .../tests/test_gurobi_minlp_walker.py | 30 +++--- .../tests/test_gurobi_minlp_writer.py | 36 ++++++- 3 files changed, 122 insertions(+), 46 deletions(-) diff --git a/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py b/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py index 3ccff8a487d..86ebd6aabd1 100755 --- a/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py +++ b/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py @@ -55,11 +55,15 @@ from pyomo.repn.quadratic import QuadraticRepnVisitor from pyomo.repn.util import ( apply_node_operation, + ExprType, BeforeChildDispatcher, complex_number_error, OrderedVarRecorder, ) +## DEBUG +from pytest import set_trace + """ Even in Gurobi 12: @@ -83,6 +87,8 @@ """ +_function_map = {} + gurobipy, gurobipy_available = attempt_import('gurobipy', minimum_version='12.0.0') if gurobipy_available: from gurobipy import GRB, nlfunc @@ -92,21 +98,22 @@ 'log': nlfunc.log, 'log10': nlfunc.log10, 'sin': nlfunc.sin, - # TODO you are here - 'asin': sympy.asin, - 'sinh': sympy.sinh, - 'asinh': sympy.asinh, - 'cos': sympy.cos, - 'acos': sympy.acos, - 'cosh': sympy.cosh, - 'acosh': sympy.acosh, - 'tan': sympy.tan, - 'atan': sympy.atan, - 'tanh': sympy.tanh, - 'atanh': sympy.atanh, - 'ceil': sympy.ceiling, - 'floor': sympy.floor, - 'sqrt': sympy.sqrt, + 'cos': nlfunc.cos, + 'tan': nlfunc.tan, + 'sqrt': nlfunc.sqrt, + # TODO: We'll have to do functional programming things if we want to support + # any of these... + 'asin': None, + 'sinh': None, + 'asinh': None, + 'acos': None, + 'cosh': None, + 'acosh': None, + 'atan': None, + 'tanh': None, + 'atanh': None, + 'ceil': None, + 'floor': None, } ) @@ -141,7 +148,6 @@ def _create_grb_var(visitor, pyomo_var, name=""): ) lb = max(domain_lb, pyomo_var.lb) if pyomo_var.lb is not None else domain_lb ub = min(domain_ub, pyomo_var.ub) if pyomo_var.ub is not None else domain_ub - print(f"Trying to add Var:\n\tlb={lb}\n\tub={ub}\n\tvtype={domain}\n\tname={name}") return visitor.grb_model.addVar(lb=lb, ub=ub, vtype=domain, name=name) @@ -162,6 +168,43 @@ def _before_var(visitor, child): visitor.var_map[_id] = grb_var return False, visitor.var_map[_id] + @staticmethod + def _before_native_numeric(visitor, child): + return False, child + + @staticmethod + def _before_native_logical(visitor, child): + return False, InvalidNumber( + child, f"{child!r} ({type(child).__name__}) is not a valid numeric type" + ) + + @staticmethod + def _before_complex(visitor, child): + return False, complex_number_error(child, visitor, child) + + @staticmethod + def _before_invalid(visitor, child): + return False, InvalidNumber( + child, f"{child!r} ({type(child).__name__}) is not a valid numeric type" + ) + + @staticmethod + def _before_string(visitor, child): + return False, InvalidNumber( + child, f"{child!r} ({type(child).__name__}) is not a valid numeric type" + ) + + @staticmethod + def _before_npv(visitor, child): + try: + return False, visitor.check_constant(visitor.evaluate(child), child) + except (ValueError, ArithmeticError): + return True, None + + @staticmethod + def _before_param(visitor, child): + return False, visitor.check_constant(child.value, child) + class GurobiMINLPVisitor(StreamBasedExpressionVisitor): before_child_dispatcher = GurobiMINLPBeforeChildDispatcher() @@ -173,10 +216,9 @@ def __init__(self, grb_model, symbolic_solver_labels=False): self.var_map = {} self._named_expressions = {} self._eval_expr_visitor = _EvaluationVisitor(True) - #self.evaluate = self._eval_expr_visitor.dfs_postorder_stack + self.evaluate = self._eval_expr_visitor.dfs_postorder_stack def initializeWalker(self, expr): - expr, src, src_index = expr walk, result = self.beforeChild(None, expr, 0) if not walk: return False, self.finalizeResult(result) @@ -191,9 +233,10 @@ def beforeChild(self, node, child, child_idx): def exitNode(self, node, data): if node.__class__ is EXPR.UnaryFunctionExpression: - import pdb - pdb.set_trace() - return apply_node_operation((node, data[1],)) + return _function_map[node._name](data[0]) + #import pdb + #pdb.set_trace() + #return apply_node_operation(node, data) return self._eval_expr_visitor.visit(node, data) def finalizeResult(self, result): @@ -260,13 +303,14 @@ def _create_gurobi_expression(self, expr, src, src_index, grb_model, nonlinear (non-quadratic) expression, and returns a gurobipy representation of the expression """ - repn = quadratic_visitor.walk_expression((expr, src, src_index)) + print("Creating Gurobi expression") + repn = quadratic_visitor.walk_expression(expr) if repn.nonlinear is None: - grb_expr = grb_visitor.walk_expression((expr, src, src_index)) + grb_expr = grb_visitor.walk_expression(expr) return grb_expr, False, None else: # It's general nonlinear - grb_expr = grb_visitor.walk_expression((expr, src, src_index)) + grb_expr = grb_visitor.walk_expression(expr) aux = grb_model.addVar() return grb_expr, True, aux @@ -362,12 +406,12 @@ def write(self, model, **options): if cons.equality: grb_model.addConstr(cons.lower == expr) else: - if cons.lower is not None: - grb_model.addConstr(cons.lower <= expr) - if cons.upper is not None: - grb_model.addConstr(cons.upper >= expr) + if cons.lb is not None: + grb_model.addConstr(cons.lb <= expr) + if cons.ub is not None: + grb_model.addConstr(cons.ub >= expr) - return grb_model, visitor.pyomo_to_gurobipy + return grb_model, visitor.var_map # ESJ TODO: We should probably not do this and actually tack this on to another diff --git a/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_walker.py b/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_walker.py index b1e9076dad0..333ec20dad4 100644 --- a/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_walker.py +++ b/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_walker.py @@ -61,7 +61,7 @@ def test_var_domains(self): m = self.get_model() e = m.x1 + m.x2 + m.x3 + m.y1 + m.y2 + m.y3 + m.z1 visitor = self.get_visitor() - expr = visitor.walk_expression((e, e, 0)) + expr = visitor.walk_expression(e) x1 = visitor.var_map[id(m.x1)] x2 = visitor.var_map[id(m.x2)] @@ -110,7 +110,7 @@ def test_var_bounds(self): e = m.x1 + m.x2 + m.x3 + m.y1 + m.y2 + m.y3 + m.z1 visitor = self.get_visitor() - expr = visitor.walk_expression((e, e, 0)) + expr = visitor.walk_expression(e) x2 = visitor.var_map[id(m.x2)] x3 = visitor.var_map[id(m.x3)] @@ -140,7 +140,7 @@ def test_write_addition(self): m = self.get_model() m.c = Constraint(expr=m.x1 + m.x2 >= 3) visitor = self.get_visitor() - expr = visitor.walk_expression((m.c.body, m.c, 0)) + expr = visitor.walk_expression(m.c.body) # TODO @@ -148,7 +148,7 @@ def test_write_subtraction(self): m = self.get_model() m.c = Constraint(expr=m.x1 - m.x2 >= 3) visitor = self.get_visitor() - expr = visitor.walk_expression((m.c.body, m.c, 0)) + expr = visitor.walk_expression(m.c.body) # TODO @@ -156,7 +156,7 @@ def test_write_product(self): m = self.get_model() m.c = Constraint(expr=m.x1 * m.x2 >= 3) visitor = self.get_visitor() - expr = visitor.walk_expression((m.c.body, m.c, 0)) + expr = visitor.walk_expression(m.c.body) # TODO @@ -164,7 +164,7 @@ def test_write_power_expression_var_const(self): m = self.get_model() m.c = Constraint(expr=m.x1**2 >= 3) visitor = self.get_visitor() - expr = visitor.walk_expression((m.c.body, m.c, 0)) + expr = visitor.walk_expression(m.c.body) # TODO @@ -172,7 +172,7 @@ def test_write_power_expression_var_var(self): m = self.get_model() m.c = Constraint(expr=m.x1**m.x2 >= 3) visitor = self.get_visitor() - expr = visitor.walk_expression((m.c.body, m.c, 0)) + expr = visitor.walk_expression(m.c.body) # TODO @@ -180,7 +180,7 @@ def test_write_power_expression_const_var(self): m = self.get_model() m.c = Constraint(expr=2**m.x2 >= 3) visitor = self.get_visitor() - expr = visitor.walk_expression((m.c.body, m.c, 0)) + expr = visitor.walk_expression(m.c.body) # TODO @@ -188,7 +188,7 @@ def test_write_absolute_value_expression(self): m = self.get_model() m.c = Constraint(expr=abs(m.x1) >= 3) visitor = self.get_visitor() - expr = visitor.walk_expression((m.c.body, m.c, 0)) + expr = visitor.walk_expression(m.c.body) # TODO @@ -197,7 +197,7 @@ def test_write_expression_with_mutable_param(self): m.p = Param(initialize=4, mutable=True) m.c = Constraint(expr=m.p**m.x2 >= 3) visitor = self.get_visitor() - expr = visitor.walk_expression((m.c.body, m.c, 0)) + expr = visitor.walk_expression(m.c.body) # TODO @@ -210,9 +210,9 @@ def test_monomial_expression(self): pow_expr = (m.p ** (0.5)) * m.x1 visitor = self.get_visitor() - expr = visitor.walk_expression((const_expr, const_expr, 0)) - expr = visitor.walk_expression((nested_expr, nested_expr, 0)) - expr = visitor.walk_expression((pow_expr, pow_expr, 0)) + expr = visitor.walk_expression(const_expr) + expr = visitor.walk_expression(nested_expr) + expr = visitor.walk_expression(pow_expr) # TODO @@ -220,7 +220,7 @@ def test_log_expression(self): m = self.get_model() m.c = Constraint(expr=log(m.x1) >= 3) visitor = self.get_visitor() - expr = visitor.walk_expression((m.c.body, m.c, 0)) + expr = visitor.walk_expression(m.c.body) # TODO @@ -231,7 +231,7 @@ def test_handle_complex_number_sqrt(self): m.p = Param(initialize=3, mutable=True) m.c = Constraint(expr=sqrt(-m.p) + m.x1 >= 3) visitor = self.get_visitor() - expr = visitor.walk_expression((m.c.body, m.c, 0)) + expr = visitor.walk_expression(m.c.body) # TODO diff --git a/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_writer.py b/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_writer.py index 93e12577e75..455fe90f11f 100644 --- a/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_writer.py +++ b/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_writer.py @@ -11,9 +11,18 @@ from pyomo.common.dependencies import attempt_import from pyomo.environ import ( + Binary, + ConcreteModel, Constraint, + Integers, + log, + NonNegativeIntegers, + NonNegativeReals, + NonPositiveIntegers, + NonPositiveReals, Objective, - log + Reals, + Var, ) from pyomo.opt import WriterFactory from pyomo.contrib.gurobi_minlp.repn.gurobi_direct_minlp import ( @@ -23,6 +32,9 @@ CommonTest ) +## DEBUG +from pytest import set_trace + gurobipy, gurobipy_available = attempt_import('gurobipy', minimum_version='12.0.0') def make_model(): @@ -50,7 +62,27 @@ def test_small_model(self): m = make_model() - grb_model, varmap = WriterFactory('gurobi_minlp').write(m) + grb_model, var_map = WriterFactory('gurobi_minlp').write(m) + + self.assertEqual(len(var_map), 7) + x1 = var_map[id(m.x1)] + x2 = var_map[id(m.x2)] + x3 = var_map[id(m.x3)] + y1 = var_map[id(m.y1)] + y2 = var_map[id(m.y2)] + y3 = var_map[id(m.y3)] + z1 = var_map[id(m.z1)] + + lin_constrs = grb_model.getConstrs() + # + self.assertEqual(len(lin_constrs), 3) + quad_constrs = grb_model.getQConstrs() + self.assertEqual(len(quad_constrs), 1) + nonlinear_constrs = grb_model.getGenConstrs() + self.assertEqual(nonlinear_constrs, 2) + + set_trace() + grb_model.optimize() # TODO: assert something! :P From dbac17f6a2a4e6479b92a3754f98f852b43f9654 Mon Sep 17 00:00:00 2001 From: Emma Johnson <12833636+emma58@users.noreply.github.com> Date: Tue, 4 Feb 2025 13:29:11 -0700 Subject: [PATCH 009/103] black --- .../gurobi_minlp/repn/gurobi_direct_minlp.py | 43 ++++++++----------- .../tests/test_gurobi_minlp_walker.py | 2 +- .../tests/test_gurobi_minlp_writer.py | 22 +++++----- 3 files changed, 29 insertions(+), 38 deletions(-) diff --git a/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py b/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py index 86ebd6aabd1..563f11fc237 100755 --- a/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py +++ b/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py @@ -92,6 +92,7 @@ gurobipy, gurobipy_available = attempt_import('gurobipy', minimum_version='12.0.0') if gurobipy_available: from gurobipy import GRB, nlfunc + _function_map.update( { 'exp': nlfunc.exp, @@ -175,8 +176,8 @@ def _before_native_numeric(visitor, child): @staticmethod def _before_native_logical(visitor, child): return False, InvalidNumber( - child, f"{child!r} ({type(child).__name__}) is not a valid numeric type" - ) + child, f"{child!r} ({type(child).__name__}) is not a valid numeric type" + ) @staticmethod def _before_complex(visitor, child): @@ -185,14 +186,14 @@ def _before_complex(visitor, child): @staticmethod def _before_invalid(visitor, child): return False, InvalidNumber( - child, f"{child!r} ({type(child).__name__}) is not a valid numeric type" + child, f"{child!r} ({type(child).__name__}) is not a valid numeric type" ) @staticmethod def _before_string(visitor, child): return False, InvalidNumber( - child, f"{child!r} ({type(child).__name__}) is not a valid numeric type" - ) + child, f"{child!r} ({type(child).__name__}) is not a valid numeric type" + ) @staticmethod def _before_npv(visitor, child): @@ -234,9 +235,9 @@ def beforeChild(self, node, child, child_idx): def exitNode(self, node, data): if node.__class__ is EXPR.UnaryFunctionExpression: return _function_map[node._name](data[0]) - #import pdb - #pdb.set_trace() - #return apply_node_operation(node, data) + # import pdb + # pdb.set_trace() + # return apply_node_operation(node, data) return self._eval_expr_visitor.visit(node, data) def finalizeResult(self, result): @@ -296,8 +297,9 @@ class GurobiMINLPWriter(object): def __init__(self): self.config = self.CONFIG() - def _create_gurobi_expression(self, expr, src, src_index, grb_model, - quadratic_visitor, grb_visitor): + def _create_gurobi_expression( + self, expr, src, src_index, grb_model, quadratic_visitor, grb_visitor + ): """ Uses the quadratic walker to determine if the expression is a general nonlinear (non-quadratic) expression, and returns a gurobipy representation @@ -351,8 +353,7 @@ def write(self, model, **options): # Get a quadratic walker instance quadratic_visitor = QuadraticRepnVisitor( - subexpression_cache={}, - var_recorder=OrderedVarRecorder({}, {}, None), + subexpression_cache={}, var_recorder=OrderedVarRecorder({}, {}, None) ) # create Gurobi model @@ -374,12 +375,7 @@ def write(self, model, **options): else: sense = GRB.MAXIMIZE obj_expr, nonlinear, aux = self._create_gurobi_expression( - obj.expr, - obj, - 0, - grb_model, - quadratic_visitor, - visitor + obj.expr, obj, 0, grb_model, quadratic_visitor, visitor ) if nonlinear: # The objective must be linear or quadratic, so we move the nonlinear @@ -389,16 +385,11 @@ def write(self, model, **options): else: grb_model.setObjective(obj_expr, sense=sense) # else it's fine--Gurobi doesn't require us to give an objective, so we don't - + # write constraints for cons in components[Constraint]: expr, nonlinear, aux = self._create_gurobi_expression( - cons.body, - cons, - 0, - grb_model, - quadratic_visitor, - visitor + cons.body, cons, 0, grb_model, quadratic_visitor, visitor ) if nonlinear: grb_model.addConstr(aux == expr) @@ -410,7 +401,7 @@ def write(self, model, **options): grb_model.addConstr(cons.lb <= expr) if cons.ub is not None: grb_model.addConstr(cons.ub >= expr) - + return grb_model, visitor.var_map diff --git a/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_walker.py b/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_walker.py index 333ec20dad4..6b147ad9516 100644 --- a/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_walker.py +++ b/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_walker.py @@ -185,6 +185,7 @@ def test_write_power_expression_const_var(self): # TODO def test_write_absolute_value_expression(self): + # TODO: Gurobi doesn't support abs, I don't think. m = self.get_model() m.c = Constraint(expr=abs(m.x1) >= 3) visitor = self.get_visitor() @@ -234,4 +235,3 @@ def test_handle_complex_number_sqrt(self): expr = visitor.walk_expression(m.c.body) # TODO - diff --git a/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_writer.py b/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_writer.py index 455fe90f11f..4db47dbf108 100644 --- a/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_writer.py +++ b/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_writer.py @@ -25,18 +25,15 @@ Var, ) from pyomo.opt import WriterFactory -from pyomo.contrib.gurobi_minlp.repn.gurobi_direct_minlp import ( - GurobiMINLPVisitor -) -from pyomo.contrib.gurobi_minlp.tests.test_gurobi_minlp_walker import ( - CommonTest -) +from pyomo.contrib.gurobi_minlp.repn.gurobi_direct_minlp import GurobiMINLPVisitor +from pyomo.contrib.gurobi_minlp.tests.test_gurobi_minlp_walker import CommonTest ## DEBUG from pytest import set_trace gurobipy, gurobipy_available = attempt_import('gurobipy', minimum_version='12.0.0') + def make_model(): m = ConcreteModel() m.x1 = Var(domain=NonNegativeReals, bounds=(0, 10)) @@ -47,14 +44,15 @@ def make_model(): m.y3 = Var(domain=NonPositiveIntegers, bounds=(-13, 0)) m.z1 = Var(domain=Binary) - m.c1 = Constraint(expr=2 ** m.x2 >= m.x3) - m.c2 = Constraint(expr=m.y1 ** 2 <= 7) + m.c1 = Constraint(expr=2**m.x2 >= m.x3) + m.c2 = Constraint(expr=m.y1**2 <= 7) m.c3 = Constraint(expr=m.y2 + m.y3 + 5 * m.z1 >= 17) m.obj = Objective(expr=log(m.x1)) return m + class TestGurobiMINLPWriter(CommonTest): def test_small_model(self): grb_model = gurobipy.Model() @@ -74,7 +72,7 @@ def test_small_model(self): z1 = var_map[id(m.z1)] lin_constrs = grb_model.getConstrs() - # + # self.assertEqual(len(lin_constrs), 3) quad_constrs = grb_model.getQConstrs() self.assertEqual(len(quad_constrs), 1) @@ -82,9 +80,11 @@ def test_small_model(self): self.assertEqual(nonlinear_constrs, 2) set_trace() - + grb_model.optimize() # TODO: assert something! :P -# ESJ: Note: It appears they don't allow x1 ** x2...? + +# ESJ: Note: It appears they don't allow x1 ** x2...? Well, they wait and give the +# error in the solver log, so not sure what we want to do about that? From a9f6c3afc1e4d7df5654c8c5582e5adaa8b7c22d Mon Sep 17 00:00:00 2001 From: Emma Johnson <12833636+emma58@users.noreply.github.com> Date: Tue, 4 Feb 2025 13:47:52 -0700 Subject: [PATCH 010/103] The gurobi printStats method agrees with me, but not what I get when I try to get the constraint objects... --- .../gurobi_minlp/repn/gurobi_direct_minlp.py | 1 - .../tests/test_gurobi_minlp_writer.py | 15 ++++++++++----- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py b/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py index 563f11fc237..530c1d68446 100755 --- a/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py +++ b/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py @@ -305,7 +305,6 @@ def _create_gurobi_expression( nonlinear (non-quadratic) expression, and returns a gurobipy representation of the expression """ - print("Creating Gurobi expression") repn = quadratic_visitor.walk_expression(expr) if repn.nonlinear is None: grb_expr = grb_visitor.walk_expression(expr) diff --git a/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_writer.py b/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_writer.py index 4db47dbf108..0ac41f1a81c 100644 --- a/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_writer.py +++ b/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_writer.py @@ -60,7 +60,9 @@ def test_small_model(self): m = make_model() - grb_model, var_map = WriterFactory('gurobi_minlp').write(m) + grb_model, var_map = WriterFactory('gurobi_minlp').write( + m, symbolic_solver_labels=True + ) self.assertEqual(len(var_map), 7) x1 = var_map[id(m.x1)] @@ -71,16 +73,19 @@ def test_small_model(self): y3 = var_map[id(m.y3)] z1 = var_map[id(m.z1)] + self.assertEqual(grb_model.numVars, 9) + self.assertEqual(grb_model.numIntVars, 4) + self.assertEqual(grb_model.numBinVars, 1) + + grb_model.printStats() + lin_constrs = grb_model.getConstrs() - # - self.assertEqual(len(lin_constrs), 3) + self.assertEqual(len(lin_constrs), 2) quad_constrs = grb_model.getQConstrs() self.assertEqual(len(quad_constrs), 1) nonlinear_constrs = grb_model.getGenConstrs() self.assertEqual(nonlinear_constrs, 2) - set_trace() - grb_model.optimize() # TODO: assert something! :P From 62b93d2039ba10fbcf0610564ebfb48ea71701e6 Mon Sep 17 00:00:00 2001 From: Emma Johnson <12833636+emma58@users.noreply.github.com> Date: Fri, 7 Feb 2025 11:24:51 -0700 Subject: [PATCH 011/103] Going back to an explicity exit node dispatcher because for weird things like AbsExpression, we can't rely completely on the expression evaluation visitor, and I need to know the types of expressions. Starting to build out tests of the walker. --- .../gurobi_minlp/repn/gurobi_direct_minlp.py | 192 ++++++++++++------ .../tests/test_gurobi_minlp_walker.py | 173 +++++++++++++++- .../tests/test_gurobi_minlp_writer.py | 2 + 3 files changed, 298 insertions(+), 69 deletions(-) diff --git a/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py b/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py index 530c1d68446..dc7985c30e1 100755 --- a/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py +++ b/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py @@ -9,6 +9,8 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ +from operator import itemgetter + from pyomo.common.dependencies import attempt_import from pyomo.common.collections import ComponentMap from pyomo.common.config import ConfigDict, ConfigValue @@ -56,8 +58,10 @@ from pyomo.repn.util import ( apply_node_operation, ExprType, + ExitNodeDispatcher, BeforeChildDispatcher, complex_number_error, + initialize_exit_node_dispatcher, OrderedVarRecorder, ) @@ -86,6 +90,10 @@ the rule that you would want to specifically tell Gurobi *not* to expand the expression. """ +_CONSTANT = ExprType.CONSTANT +_GENERAL = ExprType.GENERAL +_LINEAR = ExprType.LINEAR +_VARIABLE = ExprType.VARIABLE _function_map = {} @@ -95,26 +103,26 @@ _function_map.update( { - 'exp': nlfunc.exp, - 'log': nlfunc.log, - 'log10': nlfunc.log10, - 'sin': nlfunc.sin, - 'cos': nlfunc.cos, - 'tan': nlfunc.tan, - 'sqrt': nlfunc.sqrt, + 'exp': (_GENERAL, nlfunc.exp), + 'log': (_GENERAL, nlfunc.log), + 'log10': (_GENERAL, nlfunc.log10), + 'sin': (_GENERAL, nlfunc.sin), + 'cos': (_GENERAL, nlfunc.cos), + 'tan': (_GENERAL, nlfunc.tan), + 'sqrt': (_GENERAL, nlfunc.sqrt), # TODO: We'll have to do functional programming things if we want to support # any of these... - 'asin': None, - 'sinh': None, - 'asinh': None, - 'acos': None, - 'cosh': None, - 'acosh': None, - 'atan': None, - 'tanh': None, - 'atanh': None, - 'ceil': None, - 'floor': None, + # 'asin': None, + # 'sinh': None, + # 'asinh': None, + # 'acos': None, + # 'cosh': None, + # 'acosh': None, + # 'atan': None, + # 'tanh': None, + # 'atanh': None, + # 'ceil': None, + # 'floor': None, } ) @@ -167,48 +175,117 @@ def _before_var(visitor, child): name=child.name if visitor.symbolic_solver_labels else "", ) visitor.var_map[_id] = grb_var - return False, visitor.var_map[_id] + return False, (_VARIABLE, visitor.var_map[_id]) - @staticmethod - def _before_native_numeric(visitor, child): - return False, child + +def _handle_node_with_eval_expr_visitor_invariant(visitor, node, data): + return (data[0], visitor._eval_expr_visitor.visit(node, data[1])) - @staticmethod - def _before_native_logical(visitor, child): - return False, InvalidNumber( - child, f"{child!r} ({type(child).__name__}) is not a valid numeric type" - ) - @staticmethod - def _before_complex(visitor, child): - return False, complex_number_error(child, visitor, child) +def _handle_node_with_eval_expr_visitor_unknown(visitor, node, *data): + # ESJ: Is this cheating? + expr_type = max(map(itemgetter(0), data)) + return (expr_type, visitor._eval_expr_visitor.visit(node, map(itemgetter(1), data))) - @staticmethod - def _before_invalid(visitor, child): - return False, InvalidNumber( - child, f"{child!r} ({type(child).__name__}) is not a valid numeric type" - ) + +def _handle_node_with_eval_expr_visitor_constant(visitor, node, *data): + return (_CONSTANT, visitor._eval_expr_visitor.visit(node, map(itemgetter(1), data))) - @staticmethod - def _before_string(visitor, child): - return False, InvalidNumber( - child, f"{child!r} ({type(child).__name__}) is not a valid numeric type" - ) - @staticmethod - def _before_npv(visitor, child): - try: - return False, visitor.check_constant(visitor.evaluate(child), child) - except (ValueError, ArithmeticError): - return True, None +def _handle_node_with_eval_expr_visitor_linear(visitor, node, *data): + return (_LINEAR, visitor._eval_expr_visitor.visit(node, map(itemgetter(1), data))) - @staticmethod - def _before_param(visitor, child): - return False, visitor.check_constant(child.value, child) + +def _handle_node_with_eval_expr_visitor_nonlinear(visitor, node, *data): + return (_GENERAL, visitor._eval_expr_visitor.visit(node, map(itemgetter(1), data))) + + +def _handle_unary(visitor, node, data): + if node._name in _function_map: + expr_type, fcn = _function_map[node._name] + return expr_type, fcn(data[0]) + raise ValueError( + "The unary function '%s' is not supported by the gurobi MINLP writer." + % node._name + ) + + +def _handle_abs_constant(visitor, node, arg1): + return (_CONSTANT, abs(arg1[1])) + + +def _handle_abs_var(visitor, node, arg1): + aux_abs = visitor.grb_model.addVar() + visitor.grb_model.addConstr(aux_abs == gurobipy.abs_(arg1[1])) + + return (_VARIABLE, aux_abs) + + +def _handle_abs_expression(visitor, node, arg1): + # we need auxiliary variable + aux_arg = visitor.grb_model.addVar() + visitor.grb_model.addConstr(aux_arg == arg1[1]) + aux_abs = visitor.grb_model.addVar() + visitor.grb_model.addConstr(aux_abs == gurobipy.abs_(aux_arg)) + + return (_VARIABLE, aux_abs) + + +def define_exit_node_handlers(_exit_node_handlers=None): + if _exit_node_handlers is None: + _exit_node_handlers = {} + + # We can rely on operator overloading for many, but not all expressions. + _exit_node_handlers[SumExpression] = { + None: _handle_node_with_eval_expr_visitor_unknown + } + _exit_node_handlers[LinearExpression] = { + None: _handle_node_with_eval_expr_visitor_linear + } + _exit_node_handlers[NegationExpression] = { + None: _handle_node_with_eval_expr_visitor_invariant + } + _exit_node_handlers[ProductExpression] = { + None: _handle_node_with_eval_expr_visitor_nonlinear, + (_CONSTANT, _CONSTANT): _handle_node_with_eval_expr_visitor_constant, + (_CONSTANT, _LINEAR): _handle_node_with_eval_expr_visitor_linear, + (_LINEAR, _CONSTANT): _handle_node_with_eval_expr_visitor_linear, + (_CONSTANT, _VARIABLE): _handle_node_with_eval_expr_visitor_linear, + (_VARIABLE, _CONSTANT): _handle_node_with_eval_expr_visitor_linear, + } + _exit_node_handlers[MonomialTermExpression] = _exit_node_handlers[ProductExpression] + _exit_node_handlers[DivisionExpression] = { + None: _handle_node_with_eval_expr_visitor_nonlinear, + (_CONSTANT, _CONSTANT): _handle_node_with_eval_expr_visitor_constant, + (_LINEAR, _CONSTANT): _handle_node_with_eval_expr_visitor_linear, + (_VARIABLE, _CONSTANT): _handle_node_with_eval_expr_visitor_linear, + } + _exit_node_handlers[PowExpression] = { + None: _handle_node_with_eval_expr_visitor_nonlinear, + (_CONSTANT, _CONSTANT): _handle_node_with_eval_expr_visitor_constant, + } + _exit_node_handlers[UnaryFunctionExpression] = { + None: _handle_unary, + } + + ## TODO: named expressions, ExprIf, RangedExpressions (if we do exprif... + + # There are special becuase of quirks of Gurobi's current support for general + # nonlinear: + _exit_node_handlers[AbsExpression] = { + None: _handle_abs_expression, + (_CONSTANT,): _handle_abs_constant, + (_VARIABLE,): _handle_abs_var + } + + return _exit_node_handlers class GurobiMINLPVisitor(StreamBasedExpressionVisitor): before_child_dispatcher = GurobiMINLPBeforeChildDispatcher() + exit_node_dispatcher = ExitNodeDispatcher( + initialize_exit_node_dispatcher(define_exit_node_handlers()) + ) def __init__(self, grb_model, symbolic_solver_labels=False): super().__init__() @@ -226,23 +303,20 @@ def initializeWalker(self, expr): return True, expr def beforeChild(self, node, child, child_idx): - # Return native types - if child.__class__ in EXPR.native_types: - return False, child + # # Return native types + # if child.__class__ in EXPR.native_types: + # return False, child return self.before_child_dispatcher[child.__class__](self, child) def exitNode(self, node, data): - if node.__class__ is EXPR.UnaryFunctionExpression: - return _function_map[node._name](data[0]) - # import pdb - # pdb.set_trace() - # return apply_node_operation(node, data) - return self._eval_expr_visitor.visit(node, data) + return self.exit_node_dispatcher[(node.__class__, *map(itemgetter(0), data))]( + self, node, *data + ) def finalizeResult(self, result): self.grb_model.update() - return result + return result[1] # ESJ TODO: THIS IS COPIED FROM THE LINEAR WALKER--CAN WE PUT IT IN UTIL OR # SOMETHING? diff --git a/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_walker.py b/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_walker.py index 6b147ad9516..5f44ed22b3e 100644 --- a/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_walker.py +++ b/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_walker.py @@ -142,7 +142,16 @@ def test_write_addition(self): visitor = self.get_visitor() expr = visitor.walk_expression(m.c.body) - # TODO + x1 = visitor.var_map[id(m.x1)] + x2 = visitor.var_map[id(m.x2)] + + # This is a linear expression + self.assertEqual(expr.size(), 2) + self.assertEqual(expr.getCoeff(0), 1.0) + self.assertEqual(expr.getCoeff(1), 1.0) + self.assertIs(expr.getVar(0), x1) + self.assertIs(expr.getVar(1), x2) + self.assertEqual(expr.getConstant(), 0.0) def test_write_subtraction(self): m = self.get_model() @@ -150,7 +159,16 @@ def test_write_subtraction(self): visitor = self.get_visitor() expr = visitor.walk_expression(m.c.body) - # TODO + x1 = visitor.var_map[id(m.x1)] + x2 = visitor.var_map[id(m.x2)] + + # Also linear, whoot! + self.assertEqual(expr.size(), 2) + self.assertEqual(expr.getCoeff(0), 1.0) + self.assertEqual(expr.getCoeff(1), -1.0) + self.assertIs(expr.getVar(0), x1) + self.assertIs(expr.getVar(1), x2) + self.assertEqual(expr.getConstant(), 0.0) def test_write_product(self): m = self.get_model() @@ -158,23 +176,99 @@ def test_write_product(self): visitor = self.get_visitor() expr = visitor.walk_expression(m.c.body) - # TODO - - def test_write_power_expression_var_const(self): + x1 = visitor.var_map[id(m.x1)] + x2 = visitor.var_map[id(m.x2)] + + # This is quadratic + self.assertEqual(expr.size(), 1) + lin_expr = expr.getLinExpr() + self.assertEqual(lin_expr.size(), 0) + self.assertIs(expr.getVar1(0), x1) + self.assertIs(expr.getVar2(0), x2) + self.assertEqual(expr.getCoeff(0), 1.0) + + def test_write_quadratic_power_expression_var_const(self): m = self.get_model() m.c = Constraint(expr=m.x1**2 >= 3) visitor = self.get_visitor() expr = visitor.walk_expression(m.c.body) - # TODO + # This is also quadratic + x1 = visitor.var_map[id(m.x1)] + + self.assertEqual(expr.size(), 1) + lin_expr = expr.getLinExpr() + self.assertEqual(lin_expr.size(), 0) + self.assertIs(expr.getVar1(0), x1) + self.assertIs(expr.getVar2(0), x1) + self.assertEqual(expr.getCoeff(0), 1.0) + + def test_write_nonquadratic_power_expression_var_const(self): + m = self.get_model() + m.c = Constraint(expr=m.x1**3 >= 3) + visitor = self.get_visitor() + expr = visitor.walk_expression(m.c.body) + + # This is general nonlinear + x1 = visitor.var_map[id(m.x1)] + # TODO: It looks like this representation gets printed to the LP file, + # so I can get it publicly that way... But I need to figure out how + # to intercept writing to a file because they don't let me give the + # string. It's also the transpose of this, but whatever... + opcode, data, parent = expr._to_array_repr() + + # three nodes + self.assertEqual(len(opcode), 3) + + # the root is a power expression + self.assertEqual(parent[0], -1) # means root + self.assertEqual(opcode[0], GRB.OPCODE_POW) + # pow has no additional data + self.assertEqual(data[0], -1) + + # first child is x1 + self.assertEqual(parent[1], 0) + self.assertIs(data[1], x1) + self.assertEqual(opcode[1], GRB.OPCODE_VARIABLE) + + # second child is 3 + self.assertEqual(parent[2], 0) + self.assertEqual(opcode[2], GRB.OPCODE_CONSTANT) + self.assertEqual(data[2], 3.0) # the data is the constant's value + def test_write_power_expression_var_var(self): m = self.get_model() m.c = Constraint(expr=m.x1**m.x2 >= 3) visitor = self.get_visitor() expr = visitor.walk_expression(m.c.body) - # TODO + # You can't actually use this in a model in Gurobi 12, but you can build the + # expression... (It fails during the solve for some reason.) + + x1 = visitor.var_map[id(m.x1)] + x2 = visitor.var_map[id(m.x2)] + + opcode, data, parent = expr._to_array_repr() + + # three nodes + self.assertEqual(len(opcode), 3) + + # the root is a power expression + self.assertEqual(parent[0], -1) # means root + self.assertEqual(opcode[0], GRB.OPCODE_POW) + # pow has no additional data + self.assertEqual(data[0], -1) + + # first child is x1 + self.assertEqual(parent[1], 0) + self.assertIs(data[1], x1) + self.assertEqual(opcode[1], GRB.OPCODE_VARIABLE) + + # second child is x2 + self.assertEqual(parent[2], 0) + self.assertEqual(opcode[2], GRB.OPCODE_VARIABLE) + self.assertIs(data[2], x2) def test_write_power_expression_const_var(self): m = self.get_model() @@ -182,17 +276,76 @@ def test_write_power_expression_const_var(self): visitor = self.get_visitor() expr = visitor.walk_expression(m.c.body) - # TODO + x2 = visitor.var_map[id(m.x2)] - def test_write_absolute_value_expression(self): - # TODO: Gurobi doesn't support abs, I don't think. + opcode, data, parent = expr._to_array_repr() + + # three nodes + self.assertEqual(len(opcode), 3) + + # the root is a power expression + self.assertEqual(parent[0], -1) # means root + self.assertEqual(opcode[0], GRB.OPCODE_POW) + # pow has no additional data + self.assertEqual(data[0], -1) + + # first child is 2 + self.assertEqual(parent[1], 0) + self.assertEqual(data[1], 2.0) + self.assertEqual(opcode[1], GRB.OPCODE_CONSTANT) + + # second child is x2 + self.assertEqual(parent[2], 0) + self.assertEqual(opcode[2], GRB.OPCODE_VARIABLE) + self.assertIs(data[2], x2) + + def test_write_absolute_value_of_var(self): + # Gurobi doesn't support abs of expressions, so we have to do a factorable + # programming thing... m = self.get_model() m.c = Constraint(expr=abs(m.x1) >= 3) visitor = self.get_visitor() expr = visitor.walk_expression(m.c.body) + # expr is actually an auxiliary variable. We should + # get a constraint: + # expr == abs(x1) + + self.assertIsInstance(expr, gurobipy.Var) + grb_model = visitor.grb_model + self.assertEqual(grb_model.numVars, 2) + self.assertEqual(grb_model.numGenConstrs, 1) + self.assertEqual(grb_model.numConstrs, 0) + self.assertEqual(grb_model.numQConstrs, 0) + + # we're going to have to write the resulting model to an lp file to test that we + # have what we expect + # TODO + def test_write_absolute_value_of_expression(self): + m = self.get_model() + m.c = Constraint(expr=abs(m.x1 + 2*m.x2) >= 3) + visitor = self.get_visitor() + expr = visitor.walk_expression(m.c.body) + + # expr is actually an auxiliary variable. We should + # get three constraints: + # aux1 == x1 + 2 * x2 + # expr == abs(aux1) + + # we're going to have to write the resulting model to an lp file to test that we + # have what we expect + self.assertIsInstance(expr, gurobipy.Var) + grb_model = visitor.grb_model + self.assertEqual(grb_model.numVars, 4) + self.assertEqual(grb_model.numGenConstrs, 1) + self.assertEqual(grb_model.numConstrs, 1) + self.assertEqual(grb_model.numQConstrs, 0) + + # TODO + + def test_write_expression_with_mutable_param(self): m = self.get_model() m.p = Param(initialize=4, mutable=True) diff --git a/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_writer.py b/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_writer.py index 0ac41f1a81c..070f9a9ab46 100644 --- a/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_writer.py +++ b/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_writer.py @@ -79,6 +79,8 @@ def test_small_model(self): grb_model.printStats() + grb_model.write("nonlinear_stuff.lp") + lin_constrs = grb_model.getConstrs() self.assertEqual(len(lin_constrs), 2) quad_constrs = grb_model.getQConstrs() From 01b3a7a31b7d8c3293f338f1ce0abc19162b1a55 Mon Sep 17 00:00:00 2001 From: Emma Johnson <12833636+emma58@users.noreply.github.com> Date: Fri, 7 Feb 2025 11:25:39 -0700 Subject: [PATCH 012/103] black --- .../gurobi_minlp/repn/gurobi_direct_minlp.py | 10 +++--- .../tests/test_gurobi_minlp_walker.py | 33 +++++++++---------- 2 files changed, 20 insertions(+), 23 deletions(-) diff --git a/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py b/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py index dc7985c30e1..18ddaad1747 100755 --- a/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py +++ b/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py @@ -177,7 +177,7 @@ def _before_var(visitor, child): visitor.var_map[_id] = grb_var return False, (_VARIABLE, visitor.var_map[_id]) - + def _handle_node_with_eval_expr_visitor_invariant(visitor, node, data): return (data[0], visitor._eval_expr_visitor.visit(node, data[1])) @@ -187,7 +187,7 @@ def _handle_node_with_eval_expr_visitor_unknown(visitor, node, *data): expr_type = max(map(itemgetter(0), data)) return (expr_type, visitor._eval_expr_visitor.visit(node, map(itemgetter(1), data))) - + def _handle_node_with_eval_expr_visitor_constant(visitor, node, *data): return (_CONSTANT, visitor._eval_expr_visitor.visit(node, map(itemgetter(1), data))) @@ -264,9 +264,7 @@ def define_exit_node_handlers(_exit_node_handlers=None): None: _handle_node_with_eval_expr_visitor_nonlinear, (_CONSTANT, _CONSTANT): _handle_node_with_eval_expr_visitor_constant, } - _exit_node_handlers[UnaryFunctionExpression] = { - None: _handle_unary, - } + _exit_node_handlers[UnaryFunctionExpression] = {None: _handle_unary} ## TODO: named expressions, ExprIf, RangedExpressions (if we do exprif... @@ -275,7 +273,7 @@ def define_exit_node_handlers(_exit_node_handlers=None): _exit_node_handlers[AbsExpression] = { None: _handle_abs_expression, (_CONSTANT,): _handle_abs_constant, - (_VARIABLE,): _handle_abs_var + (_VARIABLE,): _handle_abs_var, } return _exit_node_handlers diff --git a/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_walker.py b/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_walker.py index 5f44ed22b3e..d541dcfc272 100644 --- a/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_walker.py +++ b/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_walker.py @@ -160,7 +160,7 @@ def test_write_subtraction(self): expr = visitor.walk_expression(m.c.body) x1 = visitor.var_map[id(m.x1)] - x2 = visitor.var_map[id(m.x2)] + x2 = visitor.var_map[id(m.x2)] # Also linear, whoot! self.assertEqual(expr.size(), 2) @@ -168,7 +168,7 @@ def test_write_subtraction(self): self.assertEqual(expr.getCoeff(1), -1.0) self.assertIs(expr.getVar(0), x1) self.assertIs(expr.getVar(1), x2) - self.assertEqual(expr.getConstant(), 0.0) + self.assertEqual(expr.getConstant(), 0.0) def test_write_product(self): m = self.get_model() @@ -177,7 +177,7 @@ def test_write_product(self): expr = visitor.walk_expression(m.c.body) x1 = visitor.var_map[id(m.x1)] - x2 = visitor.var_map[id(m.x2)] + x2 = visitor.var_map[id(m.x2)] # This is quadratic self.assertEqual(expr.size(), 1) @@ -186,7 +186,7 @@ def test_write_product(self): self.assertIs(expr.getVar1(0), x1) self.assertIs(expr.getVar2(0), x2) self.assertEqual(expr.getCoeff(0), 1.0) - + def test_write_quadratic_power_expression_var_const(self): m = self.get_model() m.c = Constraint(expr=m.x1**2 >= 3) @@ -195,7 +195,7 @@ def test_write_quadratic_power_expression_var_const(self): # This is also quadratic x1 = visitor.var_map[id(m.x1)] - + self.assertEqual(expr.size(), 1) lin_expr = expr.getLinExpr() self.assertEqual(lin_expr.size(), 0) @@ -222,7 +222,7 @@ def test_write_nonquadratic_power_expression_var_const(self): self.assertEqual(len(opcode), 3) # the root is a power expression - self.assertEqual(parent[0], -1) # means root + self.assertEqual(parent[0], -1) # means root self.assertEqual(opcode[0], GRB.OPCODE_POW) # pow has no additional data self.assertEqual(data[0], -1) @@ -235,8 +235,8 @@ def test_write_nonquadratic_power_expression_var_const(self): # second child is 3 self.assertEqual(parent[2], 0) self.assertEqual(opcode[2], GRB.OPCODE_CONSTANT) - self.assertEqual(data[2], 3.0) # the data is the constant's value - + self.assertEqual(data[2], 3.0) # the data is the constant's value + def test_write_power_expression_var_var(self): m = self.get_model() m.c = Constraint(expr=m.x1**m.x2 >= 3) @@ -250,12 +250,12 @@ def test_write_power_expression_var_var(self): x2 = visitor.var_map[id(m.x2)] opcode, data, parent = expr._to_array_repr() - + # three nodes self.assertEqual(len(opcode), 3) # the root is a power expression - self.assertEqual(parent[0], -1) # means root + self.assertEqual(parent[0], -1) # means root self.assertEqual(opcode[0], GRB.OPCODE_POW) # pow has no additional data self.assertEqual(data[0], -1) @@ -279,12 +279,12 @@ def test_write_power_expression_const_var(self): x2 = visitor.var_map[id(m.x2)] opcode, data, parent = expr._to_array_repr() - + # three nodes self.assertEqual(len(opcode), 3) # the root is a power expression - self.assertEqual(parent[0], -1) # means root + self.assertEqual(parent[0], -1) # means root self.assertEqual(opcode[0], GRB.OPCODE_POW) # pow has no additional data self.assertEqual(data[0], -1) @@ -317,15 +317,15 @@ def test_write_absolute_value_of_var(self): self.assertEqual(grb_model.numGenConstrs, 1) self.assertEqual(grb_model.numConstrs, 0) self.assertEqual(grb_model.numQConstrs, 0) - + # we're going to have to write the resulting model to an lp file to test that we # have what we expect - + # TODO def test_write_absolute_value_of_expression(self): m = self.get_model() - m.c = Constraint(expr=abs(m.x1 + 2*m.x2) >= 3) + m.c = Constraint(expr=abs(m.x1 + 2 * m.x2) >= 3) visitor = self.get_visitor() expr = visitor.walk_expression(m.c.body) @@ -342,9 +342,8 @@ def test_write_absolute_value_of_expression(self): self.assertEqual(grb_model.numGenConstrs, 1) self.assertEqual(grb_model.numConstrs, 1) self.assertEqual(grb_model.numQConstrs, 0) - - # TODO + # TODO def test_write_expression_with_mutable_param(self): m = self.get_model() From a6d01a6cd6686cc807b00347c7e5e17a6a7017e7 Mon Sep 17 00:00:00 2001 From: Emma Johnson <12833636+emma58@users.noreply.github.com> Date: Fri, 7 Feb 2025 11:49:49 -0700 Subject: [PATCH 013/103] Fixing a bug in the UnaryExpression handler, more tests --- .../gurobi_minlp/repn/gurobi_direct_minlp.py | 4 +- .../tests/test_gurobi_minlp_walker.py | 61 +++++++++++++++++-- 2 files changed, 59 insertions(+), 6 deletions(-) diff --git a/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py b/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py index 18ddaad1747..9dd74b9086d 100755 --- a/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py +++ b/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py @@ -203,9 +203,9 @@ def _handle_node_with_eval_expr_visitor_nonlinear(visitor, node, *data): def _handle_unary(visitor, node, data): if node._name in _function_map: expr_type, fcn = _function_map[node._name] - return expr_type, fcn(data[0]) + return expr_type, fcn(data[1]) raise ValueError( - "The unary function '%s' is not supported by the gurobi MINLP writer." + "The unary function '%s' is not supported by the Gurobi MINLP writer." % node._name ) diff --git a/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_walker.py b/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_walker.py index d541dcfc272..5212b5e1a33 100644 --- a/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_walker.py +++ b/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_walker.py @@ -352,7 +352,29 @@ def test_write_expression_with_mutable_param(self): visitor = self.get_visitor() expr = visitor.walk_expression(m.c.body) - # TODO + # expr is nonlinear + x2 = visitor.var_map[id(m.x2)] + + opcode, data, parent = expr._to_array_repr() + + # three nodes + self.assertEqual(len(opcode), 3) + + # the root is a power expression + self.assertEqual(parent[0], -1) # means root + self.assertEqual(opcode[0], GRB.OPCODE_POW) + # pow has no additional data + self.assertEqual(data[0], -1) + + # first child is 4 + self.assertEqual(parent[1], 0) + self.assertEqual(data[1], 4.0) + self.assertEqual(opcode[1], GRB.OPCODE_CONSTANT) + + # second child is x2 + self.assertEqual(parent[2], 0) + self.assertEqual(opcode[2], GRB.OPCODE_VARIABLE) + self.assertIs(data[2], x2) def test_monomial_expression(self): m = self.get_model() @@ -364,18 +386,49 @@ def test_monomial_expression(self): visitor = self.get_visitor() expr = visitor.walk_expression(const_expr) + x1 = visitor.var_map[id(m.x1)] + self.assertEqual(expr.size(), 1) + self.assertEqual(expr.getConstant(), 0.0) + self.assertIs(expr.getVar(0), x1) + self.assertEqual(expr.getCoeff(0), 3) + expr = visitor.walk_expression(nested_expr) + self.assertEqual(expr.size(), 1) + self.assertEqual(expr.getConstant(), 0.0) + self.assertIs(expr.getVar(0), x1) + self.assertAlmostEqual(expr.getCoeff(0), 1/4) + expr = visitor.walk_expression(pow_expr) - - # TODO + self.assertEqual(expr.size(), 1) + self.assertEqual(expr.getConstant(), 0.0) + self.assertIs(expr.getVar(0), x1) + self.assertEqual(expr.getCoeff(0), 2) def test_log_expression(self): m = self.get_model() m.c = Constraint(expr=log(m.x1) >= 3) + m.pprint() visitor = self.get_visitor() expr = visitor.walk_expression(m.c.body) - # TODO + # expr is nonlinear + x1 = visitor.var_map[id(m.x1)] + + opcode, data, parent = expr._to_array_repr() + print(expr._to_array_repr()) + + # two nodes + self.assertEqual(len(opcode), 2) + + # the root is a power expression + self.assertEqual(parent[0], -1) # means root + self.assertEqual(opcode[0], GRB.OPCODE_LOG) + self.assertEqual(data[0], -1) + + # child is x1 + self.assertEqual(parent[1], 0) + self.assertIs(data[1], x1) + self.assertEqual(opcode[1], GRB.OPCODE_VARIABLE) # TODO: what other unary expressions? From f8b8709fafd152b87d444fc8e9f7768e3a86937b Mon Sep 17 00:00:00 2001 From: Emma Johnson <12833636+emma58@users.noreply.github.com> Date: Fri, 7 Feb 2025 11:50:11 -0700 Subject: [PATCH 014/103] black --- .../contrib/gurobi_minlp/tests/test_gurobi_minlp_walker.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_walker.py b/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_walker.py index 5212b5e1a33..90d7f627a5c 100644 --- a/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_walker.py +++ b/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_walker.py @@ -391,13 +391,13 @@ def test_monomial_expression(self): self.assertEqual(expr.getConstant(), 0.0) self.assertIs(expr.getVar(0), x1) self.assertEqual(expr.getCoeff(0), 3) - + expr = visitor.walk_expression(nested_expr) self.assertEqual(expr.size(), 1) self.assertEqual(expr.getConstant(), 0.0) self.assertIs(expr.getVar(0), x1) - self.assertAlmostEqual(expr.getCoeff(0), 1/4) - + self.assertAlmostEqual(expr.getCoeff(0), 1 / 4) + expr = visitor.walk_expression(pow_expr) self.assertEqual(expr.size(), 1) self.assertEqual(expr.getConstant(), 0.0) From 8c236384d5bd2525420ec90194ac84615f33979a Mon Sep 17 00:00:00 2001 From: Emma Johnson <12833636+emma58@users.noreply.github.com> Date: Mon, 12 May 2025 08:15:30 -0600 Subject: [PATCH 015/103] Changes I don't remember for Gurobi MINLP --- pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py | 7 ++++++- .../contrib/gurobi_minlp/tests/test_gurobi_minlp_writer.py | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py b/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py index 9dd74b9086d..5ca40006d99 100755 --- a/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py +++ b/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py @@ -179,7 +179,11 @@ def _before_var(visitor, child): def _handle_node_with_eval_expr_visitor_invariant(visitor, node, data): - return (data[0], visitor._eval_expr_visitor.visit(node, data[1])) + """ + Calls expression evaluation visitor on nodes that have an invariant + expression type in the return. + """ + return (data[0], visitor._eval_expr_visitor.visit(node, (data[1],))) def _handle_node_with_eval_expr_visitor_unknown(visitor, node, *data): @@ -308,6 +312,7 @@ def beforeChild(self, node, child, child_idx): return self.before_child_dispatcher[child.__class__](self, child) def exitNode(self, node, data): + print(node.__class__) return self.exit_node_dispatcher[(node.__class__, *map(itemgetter(0), data))]( self, node, *data ) diff --git a/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_writer.py b/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_writer.py index 070f9a9ab46..5467a934741 100644 --- a/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_writer.py +++ b/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_writer.py @@ -86,7 +86,7 @@ def test_small_model(self): quad_constrs = grb_model.getQConstrs() self.assertEqual(len(quad_constrs), 1) nonlinear_constrs = grb_model.getGenConstrs() - self.assertEqual(nonlinear_constrs, 2) + self.assertEqual(len(nonlinear_constrs), 2) grb_model.optimize() From c3d13505e4598188b1c19c5046a0cd55389058f2 Mon Sep 17 00:00:00 2001 From: Emma Johnson <12833636+emma58@users.noreply.github.com> Date: Tue, 13 May 2025 15:30:51 -0600 Subject: [PATCH 016/103] Almost figured out how to test the Gurobi expression tree, woohoo --- .../tests/test_gurobi_minlp_writer.py | 58 ++++++++++++++++++- 1 file changed, 55 insertions(+), 3 deletions(-) diff --git a/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_writer.py b/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_writer.py index 5467a934741..d0a63081b35 100644 --- a/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_writer.py +++ b/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_writer.py @@ -32,6 +32,8 @@ from pytest import set_trace gurobipy, gurobipy_available = attempt_import('gurobipy', minimum_version='12.0.0') +if gurobipy_available: + from gurobipy import GRB def make_model(): @@ -53,6 +55,7 @@ def make_model(): return m +@unittest.skipUnless(gurobipy_available, "Gurobipy 12 is not available") class TestGurobiMINLPWriter(CommonTest): def test_small_model(self): grb_model = gurobipy.Model() @@ -88,9 +91,58 @@ def test_small_model(self): nonlinear_constrs = grb_model.getGenConstrs() self.assertEqual(len(nonlinear_constrs), 2) - grb_model.optimize() - - # TODO: assert something! :P + ## linear constraints + c = lin_constrs[0] + c_expr = grb_model.getRow(c) + # TODO + + c3 = lin_constrs[1] + c3_expr = grb_model.getRow(c3) + self.assertEqual(c3_expr.size(), 3) + self.assertIs(c3_expr.getVar(0), y2) + self.assertEqual(c3_expr.getCoeff(0), 1) + self.assertIs(c3_expr.getVar(1), y3) + self.assertEqual(c3_expr.getCoeff(1), 1) + self.assertIs(c3_expr.getVar(2), z1) + self.assertEqual(c3_expr.getCoeff(2), 5) + self.assertEqual(c3_expr.getConstant(), 0) + self.assertEqual(c3.RHS, 17) + self.assertEqual(c3.Sense, '>') + + ## quadratic constraint + c2 = quad_constrs[0] + c2_expr = grb_model.getQCRow(c2) + lin_expr = c2_expr.getLinExpr() + self.assertEqual(lin_expr.size(), 0) + self.assertEqual(c2.QCRHS, 7) + self.assertEqual(c2.QCSense, '<') + self.assertEqual(c2_expr.size(), 1) + self.assertIs(c2_expr.getVar1(0), y1) + self.assertIs(c2_expr.getVar2(0), y1) + self.assertEqual(c2_expr.getCoeff(0), 1) + + ## general nonlinear constraints + obj_cons = nonlinear_constrs[0] + res_var, opcode, data, parent = grb_model.getGenConstrNLAdv(obj_cons) + self.assertEqual(len(opcode), 2) # two nodes in the expression tree + self.assertEqual(opcode[0], GRB.OPCODE_LOG) + # log has no data + self.assertEqual(parent[0], -1) # it's the root + self.assertEqual(opcode[1], GRB.OPCODE_VARIABLE) + self.assertIs(data[1], x1) + self.assertEqual(parent[1], 0) + + # we can check that res_var is the objective + self.assertEqual(grb_model.ModelSense, 1) # minimizing + obj = grb_model.getObjective() + self.assertEqual(obj.size(), 1) + self.assertEqual(obj.getCoeff(0), 1) + self.assertIs(obj.getVar(0), res_var) + + c1 = nonlinear_constrs[1] + res_var, opcode, data, parent = grb_model.getGenConstrNLAdv(c1) + + set_trace() # ESJ: Note: It appears they don't allow x1 ** x2...? Well, they wait and give the From b53b8ef6e5f07495264c0964760169d8b039df4a Mon Sep 17 00:00:00 2001 From: Emma Johnson <12833636+emma58@users.noreply.github.com> Date: Tue, 13 May 2025 16:49:39 -0600 Subject: [PATCH 017/103] Fixing a bug where I forgot to update the gurobipy model before I returned it --- pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py | 1 + 1 file changed, 1 insertion(+) diff --git a/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py b/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py index 5ca40006d99..071f328195b 100755 --- a/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py +++ b/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py @@ -478,6 +478,7 @@ def write(self, model, **options): if cons.ub is not None: grb_model.addConstr(cons.ub >= expr) + grb_model.update() return grb_model, visitor.var_map From 536844b3188b40b3fb35e8ada3a99e1d75c00bb0 Mon Sep 17 00:00:00 2001 From: Emma Johnson <12833636+emma58@users.noreply.github.com> Date: Tue, 13 May 2025 16:50:23 -0600 Subject: [PATCH 018/103] Moving all the hacky tests to use the public API for getting the nonlinear expression tree, yay!! --- .../tests/test_gurobi_minlp_walker.py | 32 ++++++++++----- .../tests/test_gurobi_minlp_writer.py | 39 +++++++++++++++---- 2 files changed, 54 insertions(+), 17 deletions(-) diff --git a/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_walker.py b/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_walker.py index 90d7f627a5c..2a9e5e321df 100644 --- a/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_walker.py +++ b/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_walker.py @@ -199,10 +199,27 @@ def test_write_quadratic_power_expression_var_const(self): self.assertEqual(expr.size(), 1) lin_expr = expr.getLinExpr() self.assertEqual(lin_expr.size(), 0) + self.assertEqual(lin_expr.getConstant(), 0) self.assertIs(expr.getVar1(0), x1) self.assertIs(expr.getVar2(0), x1) self.assertEqual(expr.getCoeff(0), 1.0) + def _get_nl_expr_tree(self, visitor, expr): + # This is a bit hacky, but the only way that I know to get the expression tree + # publicly is from a general nonlinear constraint. So we can create it, and + # then pull out the expression we just used to test it + grb_model = visitor.grb_model + aux = grb_model.addVar() + grb_model.addConstr(aux == expr) + grb_model.update() + constrs = grb_model.getGenConstrs() + self.assertEqual(len(constrs), 1) + + aux_var, opcode, data, parent = grb_model.getGenConstrNLAdv(constrs[0]) + self.assertIs(aux_var, aux) + return opcode, data, parent + + def test_write_nonquadratic_power_expression_var_const(self): m = self.get_model() m.c = Constraint(expr=m.x1**3 >= 3) @@ -212,11 +229,7 @@ def test_write_nonquadratic_power_expression_var_const(self): # This is general nonlinear x1 = visitor.var_map[id(m.x1)] - # TODO: It looks like this representation gets printed to the LP file, - # so I can get it publicly that way... But I need to figure out how - # to intercept writing to a file because they don't let me give the - # string. It's also the transpose of this, but whatever... - opcode, data, parent = expr._to_array_repr() + opcode, data, parent = self._get_nl_expr_tree(visitor, expr) # three nodes self.assertEqual(len(opcode), 3) @@ -249,7 +262,7 @@ def test_write_power_expression_var_var(self): x1 = visitor.var_map[id(m.x1)] x2 = visitor.var_map[id(m.x2)] - opcode, data, parent = expr._to_array_repr() + opcode, data, parent = self._get_nl_expr_tree(visitor, expr) # three nodes self.assertEqual(len(opcode), 3) @@ -278,7 +291,7 @@ def test_write_power_expression_const_var(self): x2 = visitor.var_map[id(m.x2)] - opcode, data, parent = expr._to_array_repr() + opcode, data, parent = self._get_nl_expr_tree(visitor, expr) # three nodes self.assertEqual(len(opcode), 3) @@ -355,7 +368,7 @@ def test_write_expression_with_mutable_param(self): # expr is nonlinear x2 = visitor.var_map[id(m.x2)] - opcode, data, parent = expr._to_array_repr() + opcode, data, parent = self._get_nl_expr_tree(visitor, expr) # three nodes self.assertEqual(len(opcode), 3) @@ -414,8 +427,7 @@ def test_log_expression(self): # expr is nonlinear x1 = visitor.var_map[id(m.x1)] - opcode, data, parent = expr._to_array_repr() - print(expr._to_array_repr()) + opcode, data, parent = self._get_nl_expr_tree(visitor, expr) # two nodes self.assertEqual(len(opcode), 2) diff --git a/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_writer.py b/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_writer.py index d0a63081b35..1b1ea1ce42f 100644 --- a/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_writer.py +++ b/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_writer.py @@ -9,6 +9,7 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ +import pyomo.common.unittest as unittest from pyomo.common.dependencies import attempt_import from pyomo.environ import ( Binary, @@ -80,10 +81,6 @@ def test_small_model(self): self.assertEqual(grb_model.numIntVars, 4) self.assertEqual(grb_model.numBinVars, 1) - grb_model.printStats() - - grb_model.write("nonlinear_stuff.lp") - lin_constrs = grb_model.getConstrs() self.assertEqual(len(lin_constrs), 2) quad_constrs = grb_model.getQConstrs() @@ -92,9 +89,16 @@ def test_small_model(self): self.assertEqual(len(nonlinear_constrs), 2) ## linear constraints + + # this is the linear piece of c1 c = lin_constrs[0] c_expr = grb_model.getRow(c) - # TODO + self.assertEqual(c.RHS, 0) + self.assertEqual(c.Sense, '<') + self.assertEqual(c_expr.size(), 1) + self.assertEqual(c_expr.getCoeff(0), 1) + self.assertEqual(c_expr.getConstant(), 0) + aux_var = c_expr.getVar(0) c3 = lin_constrs[1] c3_expr = grb_model.getRow(c3) @@ -114,6 +118,7 @@ def test_small_model(self): c2_expr = grb_model.getQCRow(c2) lin_expr = c2_expr.getLinExpr() self.assertEqual(lin_expr.size(), 0) + self.assertEqual(lin_expr.getConstant(), 0) self.assertEqual(c2.QCRHS, 7) self.assertEqual(c2.QCSense, '<') self.assertEqual(c2_expr.size(), 1) @@ -141,8 +146,28 @@ def test_small_model(self): c1 = nonlinear_constrs[1] res_var, opcode, data, parent = grb_model.getGenConstrNLAdv(c1) - - set_trace() + # This is where we link into the linear inequality constraint + self.assertIs(res_var, aux_var) + # test the tree for the expression x3 + (- (2 ** x2)) + self.assertEqual(len(opcode), 6) + self.assertEqual(opcode[0], GRB.OPCODE_PLUS) + # plus has no data + self.assertEqual(parent[0], -1) # root + self.assertEqual(opcode[1], GRB.OPCODE_VARIABLE) + self.assertIs(data[1], x3) + self.assertEqual(parent[1], 0) + self.assertEqual(opcode[2], GRB.OPCODE_UMINUS) # negation + # negation has no data + self.assertEqual(parent[2], 0) + self.assertEqual(opcode[3], GRB.OPCODE_POW) + # pow has no data + self.assertEqual(parent[3], 2) + self.assertEqual(opcode[4], GRB.OPCODE_CONSTANT) + self.assertEqual(data[4], 2) + self.assertEqual(parent[4], 3) + self.assertEqual(opcode[5], GRB.OPCODE_VARIABLE) + self.assertIs(data[5], x2) + self.assertEqual(parent[5], 3) # ESJ: Note: It appears they don't allow x1 ** x2...? Well, they wait and give the From 65980c9c9c8b91511f9116a2c3052a92d727bac4 Mon Sep 17 00:00:00 2001 From: Emma Johnson <12833636+emma58@users.noreply.github.com> Date: Tue, 13 May 2025 16:51:56 -0600 Subject: [PATCH 019/103] black --- .../gurobi_minlp/tests/test_gurobi_minlp_walker.py | 1 - .../gurobi_minlp/tests/test_gurobi_minlp_writer.py | 12 ++++++------ 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_walker.py b/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_walker.py index 2a9e5e321df..f53d05b035b 100644 --- a/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_walker.py +++ b/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_walker.py @@ -219,7 +219,6 @@ def _get_nl_expr_tree(self, visitor, expr): self.assertIs(aux_var, aux) return opcode, data, parent - def test_write_nonquadratic_power_expression_var_const(self): m = self.get_model() m.c = Constraint(expr=m.x1**3 >= 3) diff --git a/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_writer.py b/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_writer.py index 1b1ea1ce42f..967f1b79bf4 100644 --- a/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_writer.py +++ b/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_writer.py @@ -129,16 +129,16 @@ def test_small_model(self): ## general nonlinear constraints obj_cons = nonlinear_constrs[0] res_var, opcode, data, parent = grb_model.getGenConstrNLAdv(obj_cons) - self.assertEqual(len(opcode), 2) # two nodes in the expression tree + self.assertEqual(len(opcode), 2) # two nodes in the expression tree self.assertEqual(opcode[0], GRB.OPCODE_LOG) # log has no data - self.assertEqual(parent[0], -1) # it's the root + self.assertEqual(parent[0], -1) # it's the root self.assertEqual(opcode[1], GRB.OPCODE_VARIABLE) self.assertIs(data[1], x1) self.assertEqual(parent[1], 0) - + # we can check that res_var is the objective - self.assertEqual(grb_model.ModelSense, 1) # minimizing + self.assertEqual(grb_model.ModelSense, 1) # minimizing obj = grb_model.getObjective() self.assertEqual(obj.size(), 1) self.assertEqual(obj.getCoeff(0), 1) @@ -152,11 +152,11 @@ def test_small_model(self): self.assertEqual(len(opcode), 6) self.assertEqual(opcode[0], GRB.OPCODE_PLUS) # plus has no data - self.assertEqual(parent[0], -1) # root + self.assertEqual(parent[0], -1) # root self.assertEqual(opcode[1], GRB.OPCODE_VARIABLE) self.assertIs(data[1], x3) self.assertEqual(parent[1], 0) - self.assertEqual(opcode[2], GRB.OPCODE_UMINUS) # negation + self.assertEqual(opcode[2], GRB.OPCODE_UMINUS) # negation # negation has no data self.assertEqual(parent[2], 0) self.assertEqual(opcode[3], GRB.OPCODE_POW) From 03ba09b07cb5a1b2de653d7c5dd81f863b5e848f Mon Sep 17 00:00:00 2001 From: Emma Johnson <12833636+emma58@users.noreply.github.com> Date: Wed, 14 May 2025 10:02:02 -0600 Subject: [PATCH 020/103] Fixing a typo --- pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py b/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py index 071f328195b..2123a0a3d0f 100755 --- a/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py +++ b/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py @@ -272,7 +272,7 @@ def define_exit_node_handlers(_exit_node_handlers=None): ## TODO: named expressions, ExprIf, RangedExpressions (if we do exprif... - # There are special becuase of quirks of Gurobi's current support for general + # These are special because of quirks of Gurobi's current support for general # nonlinear: _exit_node_handlers[AbsExpression] = { None: _handle_abs_expression, From 767428fd191080360830b51a20541fde23d9239e Mon Sep 17 00:00:00 2001 From: Emma Johnson <12833636+emma58@users.noreply.github.com> Date: Wed, 28 May 2025 18:46:44 -0600 Subject: [PATCH 021/103] Adding a fix and a test for a bug where we didn't account for NPV expressions in the RHS --- .../gurobi_minlp/repn/gurobi_direct_minlp.py | 7 ++-- .../tests/test_gurobi_minlp_writer.py | 42 +++++++++++++++++++ 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py b/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py index 2123a0a3d0f..7e97f3d2136 100755 --- a/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py +++ b/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py @@ -37,6 +37,7 @@ SortComponents, Suffix, Var, + value ) import pyomo.core.expr as EXPR from pyomo.core.expr.numeric_expr import ( @@ -471,12 +472,12 @@ def write(self, model, **options): grb_model.addConstr(aux == expr) expr = aux if cons.equality: - grb_model.addConstr(cons.lower == expr) + grb_model.addConstr(value(cons.lower) == expr) else: if cons.lb is not None: - grb_model.addConstr(cons.lb <= expr) + grb_model.addConstr(value(cons.lb) <= expr) if cons.ub is not None: - grb_model.addConstr(cons.ub >= expr) + grb_model.addConstr(value(cons.ub) >= expr) grb_model.update() return grb_model, visitor.var_map diff --git a/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_writer.py b/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_writer.py index 967f1b79bf4..f91a677deda 100644 --- a/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_writer.py +++ b/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_writer.py @@ -22,6 +22,7 @@ NonPositiveIntegers, NonPositiveReals, Objective, + Param, Reals, Var, ) @@ -169,6 +170,47 @@ def test_small_model(self): self.assertIs(data[5], x2) self.assertEqual(parent[5], 3) + def test_write_NPV_negation_in_RHS(self): + m = ConcreteModel() + m.x1 = Var() + m.p1 = Param(initialize=3, mutable=True) + m.c = Constraint(expr=-m.x1 == m.p1) + m.obj = Objective(expr=m.x1) + + grb_model, var_map = WriterFactory('gurobi_minlp').write( + m, symbolic_solver_labels=True + ) + + self.assertEqual(len(var_map), 1) + x1 = var_map[id(m.x1)] + + self.assertEqual(grb_model.numVars, 1) + self.assertEqual(grb_model.numIntVars, 0) + self.assertEqual(grb_model.numBinVars, 0) + + lin_constrs = grb_model.getConstrs() + self.assertEqual(len(lin_constrs), 1) + quad_constrs = grb_model.getQConstrs() + self.assertEqual(len(quad_constrs), 0) + nonlinear_constrs = grb_model.getGenConstrs() + self.assertEqual(len(nonlinear_constrs), 0) + + # constraint + c = lin_constrs[0] + c_expr = grb_model.getRow(c) + self.assertEqual(c.RHS, 3) + self.assertEqual(c.Sense, '=') + self.assertEqual(c_expr.size(), 1) + self.assertEqual(c_expr.getCoeff(0), -1) + self.assertEqual(c_expr.getConstant(), 0) + self.assertIs(c_expr.getVar(0), x1) + + # objective + self.assertEqual(grb_model.ModelSense, 1) # minimizing + obj = grb_model.getObjective() + self.assertEqual(obj.size(), 1) + self.assertEqual(obj.getCoeff(0), 1) + self.assertIs(obj.getVar(0), x1) # ESJ: Note: It appears they don't allow x1 ** x2...? Well, they wait and give the # error in the solver log, so not sure what we want to do about that? From 9eed3150d67e6ffc74642567ef9ec2cccac51bdf Mon Sep 17 00:00:00 2001 From: Emma Johnson <12833636+emma58@users.noreply.github.com> Date: Wed, 28 May 2025 20:21:38 -0600 Subject: [PATCH 022/103] Adding a fix and test for a problem calling _apply_operation for DivisionExpressions from the writer --- .../gurobi_minlp/repn/gurobi_direct_minlp.py | 5 +++- .../tests/test_gurobi_minlp_walker.py | 29 +++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py b/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py index 7e97f3d2136..12eef05960f 100755 --- a/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py +++ b/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py @@ -202,7 +202,10 @@ def _handle_node_with_eval_expr_visitor_linear(visitor, node, *data): def _handle_node_with_eval_expr_visitor_nonlinear(visitor, node, *data): - return (_GENERAL, visitor._eval_expr_visitor.visit(node, map(itemgetter(1), data))) + # ESJ: _apply_operation for DivisionExpression expects that result is indexed, so + # I'm making it a tuple rather than a map. + return (_GENERAL, visitor._eval_expr_visitor.visit(node, + tuple(map(itemgetter(1), data)))) def _handle_unary(visitor, node, data): diff --git a/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_walker.py b/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_walker.py index f53d05b035b..3cb7fa96181 100644 --- a/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_walker.py +++ b/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_walker.py @@ -187,6 +187,35 @@ def test_write_product(self): self.assertIs(expr.getVar2(0), x2) self.assertEqual(expr.getCoeff(0), 1.0) + def test_write_division(self): + m = self.get_model() + m.c = Constraint(expr=1 / m.x1 == 1) + + visitor = self.get_visitor() + expr = visitor.walk_expression(m.c.body) + + x1 = visitor.var_map[id(m.x1)] + + opcode, data, parent = self._get_nl_expr_tree(visitor, expr) + + # three nodes + self.assertEqual(len(opcode), 3) + # the root is a division expression + self.assertEqual(parent[0], -1) # root + self.assertEqual(opcode[0], GRB.OPCODE_DIVIDE) + # divide has no additional data + self.assertEqual(data[0], -1) + + # first arg is 1 + self.assertEqual(parent[1], 0) + self.assertEqual(opcode[1], GRB.OPCODE_CONSTANT) + self.assertEqual(data[1], 1) + + # second arg is x1 + self.assertEqual(parent[2], 0) + self.assertEqual(opcode[2], GRB.OPCODE_VARIABLE) + self.assertIs(data[2], x1) + def test_write_quadratic_power_expression_var_const(self): m = self.get_model() m.c = Constraint(expr=m.x1**2 >= 3) From 39b7ea1f8d60290ab56a779989fabb0cef2d07e0 Mon Sep 17 00:00:00 2001 From: Emma Johnson <12833636+emma58@users.noreply.github.com> Date: Wed, 28 May 2025 20:31:52 -0600 Subject: [PATCH 023/103] Fixing a bug with handling of fixed variables --- .../gurobi_minlp/repn/gurobi_direct_minlp.py | 3 +-- .../tests/test_gurobi_minlp_walker.py | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py b/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py index 12eef05960f..6d31d0c8b03 100755 --- a/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py +++ b/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py @@ -169,7 +169,7 @@ def _before_var(visitor, child): if child.fixed: # ESJ TODO: I want the linear walker implementation of # check_constant... Could it be in the base class or something? - return False, visitor.check_constant(child.value, child) + return False, (_CONSTANT, visitor.check_constant(child.value, child)) grb_var = _create_grb_var( visitor, child, @@ -316,7 +316,6 @@ def beforeChild(self, node, child, child_idx): return self.before_child_dispatcher[child.__class__](self, child) def exitNode(self, node, data): - print(node.__class__) return self.exit_node_dispatcher[(node.__class__, *map(itemgetter(0), data))]( self, node, *data ) diff --git a/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_walker.py b/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_walker.py index 3cb7fa96181..9c03a92d711 100644 --- a/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_walker.py +++ b/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_walker.py @@ -187,6 +187,22 @@ def test_write_product(self): self.assertIs(expr.getVar2(0), x2) self.assertEqual(expr.getCoeff(0), 1.0) + def test_write_product_with_fixed_var(self): + m = self.get_model() + m.x2.fix(4) + m.c = Constraint(expr=m.x1 * m.x2 == 1) + + visitor = self.get_visitor() + expr = visitor.walk_expression(m.c.body) + + x1 = visitor.var_map[id(m.x1)] + + # this is linear + self.assertEqual(expr.size(), 1) + self.assertEqual(expr.getCoeff(0), 4.0) + self.assertIs(expr.getVar(0), x1) + self.assertEqual(expr.getConstant(), 0.0) + def test_write_division(self): m = self.get_model() m.c = Constraint(expr=1 / m.x1 == 1) From 971f324c251152effbabacb6b63e26c651347758 Mon Sep 17 00:00:00 2001 From: Emma Johnson <12833636+emma58@users.noreply.github.com> Date: Wed, 28 May 2025 20:33:40 -0600 Subject: [PATCH 024/103] black --- pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py | 8 +++++--- .../gurobi_minlp/tests/test_gurobi_minlp_walker.py | 2 +- .../gurobi_minlp/tests/test_gurobi_minlp_writer.py | 1 + 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py b/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py index 6d31d0c8b03..b1570626eaf 100755 --- a/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py +++ b/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py @@ -37,7 +37,7 @@ SortComponents, Suffix, Var, - value + value, ) import pyomo.core.expr as EXPR from pyomo.core.expr.numeric_expr import ( @@ -204,8 +204,10 @@ def _handle_node_with_eval_expr_visitor_linear(visitor, node, *data): def _handle_node_with_eval_expr_visitor_nonlinear(visitor, node, *data): # ESJ: _apply_operation for DivisionExpression expects that result is indexed, so # I'm making it a tuple rather than a map. - return (_GENERAL, visitor._eval_expr_visitor.visit(node, - tuple(map(itemgetter(1), data)))) + return ( + _GENERAL, + visitor._eval_expr_visitor.visit(node, tuple(map(itemgetter(1), data))), + ) def _handle_unary(visitor, node, data): diff --git a/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_walker.py b/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_walker.py index 9c03a92d711..234c9b963e9 100644 --- a/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_walker.py +++ b/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_walker.py @@ -217,7 +217,7 @@ def test_write_division(self): # three nodes self.assertEqual(len(opcode), 3) # the root is a division expression - self.assertEqual(parent[0], -1) # root + self.assertEqual(parent[0], -1) # root self.assertEqual(opcode[0], GRB.OPCODE_DIVIDE) # divide has no additional data self.assertEqual(data[0], -1) diff --git a/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_writer.py b/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_writer.py index f91a677deda..b88c683c031 100644 --- a/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_writer.py +++ b/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_writer.py @@ -212,5 +212,6 @@ def test_write_NPV_negation_in_RHS(self): self.assertEqual(obj.getCoeff(0), 1) self.assertIs(obj.getVar(0), x1) + # ESJ: Note: It appears they don't allow x1 ** x2...? Well, they wait and give the # error in the solver log, so not sure what we want to do about that? From 51f35eceac77d7a6a63a0e91bf2efe91142313e9 Mon Sep 17 00:00:00 2001 From: Emma Johnson <12833636+emma58@users.noreply.github.com> Date: Wed, 28 May 2025 20:41:36 -0600 Subject: [PATCH 025/103] Adding missing imports --- pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py b/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py index b1570626eaf..b5aae37ae50 100755 --- a/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py +++ b/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py @@ -9,7 +9,7 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ -from operator import itemgetter +from operator import attrgetter, itemgetter from pyomo.common.dependencies import attempt_import from pyomo.common.collections import ComponentMap @@ -63,6 +63,8 @@ BeforeChildDispatcher, complex_number_error, initialize_exit_node_dispatcher, + InvalidNumber, + nan, OrderedVarRecorder, ) From 7609f097f4c4c3d9e2f3de3e9fa78455ca8777ed Mon Sep 17 00:00:00 2001 From: Emma Johnson <12833636+emma58@users.noreply.github.com> Date: Wed, 28 May 2025 20:47:30 -0600 Subject: [PATCH 026/103] BooleanVars are welcome to hang out on the model --- .../gurobi_minlp/repn/gurobi_direct_minlp.py | 2 + .../tests/test_gurobi_minlp_writer.py | 48 +++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py b/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py index b5aae37ae50..d8ebdb2853c 100755 --- a/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py +++ b/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py @@ -22,6 +22,7 @@ from pyomo.core.base import ( Binary, Block, + BooleanVar, Constraint, Expression, Integers, @@ -411,6 +412,7 @@ def write(self, model, **options): Objective, Constraint, Var, + BooleanVar, Param, Suffix, # FIXME: Non-active components should not report as Active diff --git a/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_writer.py b/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_writer.py index b88c683c031..2cd2e3bb942 100644 --- a/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_writer.py +++ b/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_writer.py @@ -13,10 +13,12 @@ from pyomo.common.dependencies import attempt_import from pyomo.environ import ( Binary, + BooleanVar, ConcreteModel, Constraint, Integers, log, + LogicalConstraint, NonNegativeIntegers, NonNegativeReals, NonPositiveIntegers, @@ -212,6 +214,52 @@ def test_write_NPV_negation_in_RHS(self): self.assertEqual(obj.getCoeff(0), 1) self.assertIs(obj.getVar(0), x1) + def test_writer_ignores_deactivated_logical_constraints(self): + m = ConcreteModel() + m.x1 = Var() + m.p1 = Param(initialize=3, mutable=True) + m.c = Constraint(expr=-m.x1 == m.p1) + m.obj = Objective(expr=m.x1) + + m.b = BooleanVar() + m.whatever = LogicalConstraint(expr=~m.b) + m.whatever.deactivate() + + grb_model, var_map = WriterFactory('gurobi_minlp').write( + m, symbolic_solver_labels=True + ) + + self.assertEqual(len(var_map), 1) + x1 = var_map[id(m.x1)] + + self.assertEqual(grb_model.numVars, 1) + self.assertEqual(grb_model.numIntVars, 0) + self.assertEqual(grb_model.numBinVars, 0) + + lin_constrs = grb_model.getConstrs() + self.assertEqual(len(lin_constrs), 1) + quad_constrs = grb_model.getQConstrs() + self.assertEqual(len(quad_constrs), 0) + nonlinear_constrs = grb_model.getGenConstrs() + self.assertEqual(len(nonlinear_constrs), 0) + + # constraint + c = lin_constrs[0] + c_expr = grb_model.getRow(c) + self.assertEqual(c.RHS, 3) + self.assertEqual(c.Sense, '=') + self.assertEqual(c_expr.size(), 1) + self.assertEqual(c_expr.getCoeff(0), -1) + self.assertEqual(c_expr.getConstant(), 0) + self.assertIs(c_expr.getVar(0), x1) + + # objective + self.assertEqual(grb_model.ModelSense, 1) # minimizing + obj = grb_model.getObjective() + self.assertEqual(obj.size(), 1) + self.assertEqual(obj.getCoeff(0), 1) + self.assertIs(obj.getVar(0), x1) + # ESJ: Note: It appears they don't allow x1 ** x2...? Well, they wait and give the # error in the solver log, so not sure what we want to do about that? From 1cda2fc5c8346c23408f6d58fb20942b7d3d61ee Mon Sep 17 00:00:00 2001 From: Emma Johnson <12833636+emma58@users.noreply.github.com> Date: Wed, 28 May 2025 21:14:52 -0600 Subject: [PATCH 027/103] Adding support for named expressions --- .../gurobi_minlp/repn/gurobi_direct_minlp.py | 22 ++++++- .../tests/test_gurobi_minlp_writer.py | 64 +++++++++++++++++++ 2 files changed, 84 insertions(+), 2 deletions(-) diff --git a/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py b/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py index d8ebdb2853c..8b505d01297 100755 --- a/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py +++ b/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py @@ -181,6 +181,15 @@ def _before_var(visitor, child): visitor.var_map[_id] = grb_var return False, (_VARIABLE, visitor.var_map[_id]) + @staticmethod + def _before_named_expression(visitor, child): + _id = id(child) + if _id in visitor.subexpression_cache: + _type, expr = visitor.subexpression_cache[_id] + return False, (_type, expr) + else: + return True, None + def _handle_node_with_eval_expr_visitor_invariant(visitor, node, data): """ @@ -223,6 +232,13 @@ def _handle_unary(visitor, node, data): ) +def _handle_named_expression(visitor, node, arg1): + # Record this common expression + visitor.subexpression_cache[id(node)] = arg1 + _type, arg1 = arg1 + return _type, arg1 + + def _handle_abs_constant(visitor, node, arg1): return (_CONSTANT, abs(arg1[1])) @@ -279,7 +295,8 @@ def define_exit_node_handlers(_exit_node_handlers=None): } _exit_node_handlers[UnaryFunctionExpression] = {None: _handle_unary} - ## TODO: named expressions, ExprIf, RangedExpressions (if we do exprif... + ## TODO: ExprIf, RangedExpressions (if we do exprif... + _exit_node_handlers[Expression] = {None: _handle_named_expression} # These are special because of quirks of Gurobi's current support for general # nonlinear: @@ -303,7 +320,7 @@ def __init__(self, grb_model, symbolic_solver_labels=False): self.grb_model = grb_model self.symbolic_solver_labels = symbolic_solver_labels self.var_map = {} - self._named_expressions = {} + self.subexpression_cache = {} self._eval_expr_visitor = _EvaluationVisitor(True) self.evaluate = self._eval_expr_visitor.dfs_postorder_stack @@ -411,6 +428,7 @@ def write(self, model, **options): Block, Objective, Constraint, + Expression, Var, BooleanVar, Param, diff --git a/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_writer.py b/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_writer.py index 2cd2e3bb942..6b86652a4d5 100644 --- a/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_writer.py +++ b/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_writer.py @@ -16,6 +16,7 @@ BooleanVar, ConcreteModel, Constraint, + Expression, Integers, log, LogicalConstraint, @@ -260,6 +261,69 @@ def test_writer_ignores_deactivated_logical_constraints(self): self.assertEqual(obj.getCoeff(0), 1) self.assertIs(obj.getVar(0), x1) + def test_named_expressions(self): + m = ConcreteModel() + m.x = Var() + m.y = Var() + m.e = Expression(expr=m.x ** 2 + m.y) + m.c = Constraint(expr=m.e <= 7) + m.c2 = Constraint(expr=m.e >= -3) + m.obj = Objective(expr=0) + + grb_model, var_map = WriterFactory('gurobi_minlp').write( + m, symbolic_solver_labels=True + ) + + self.assertEqual(len(var_map), 2) + x = var_map[id(m.x)] + y = var_map[id(m.y)] + + self.assertEqual(grb_model.numVars, 2) + self.assertEqual(grb_model.numIntVars, 0) + self.assertEqual(grb_model.numBinVars, 0) + + lin_constrs = grb_model.getConstrs() + self.assertEqual(len(lin_constrs), 0) + quad_constrs = grb_model.getQConstrs() + self.assertEqual(len(quad_constrs), 2) + nonlinear_constrs = grb_model.getGenConstrs() + self.assertEqual(len(nonlinear_constrs), 0) + + # constraint 1 + c1 = quad_constrs[0] + expr = grb_model.getQCRow(c1) + lin_expr = expr.getLinExpr() + self.assertEqual(lin_expr.size(), 1) + self.assertEqual(lin_expr.getConstant(), 0) + self.assertEqual(lin_expr.getCoeff(0), 1) + self.assertIs(lin_expr.getVar(0), y) + self.assertEqual(c1.QCRHS, 7) + self.assertEqual(c1.QCSense, '<') + self.assertEqual(expr.size(), 1) + self.assertIs(expr.getVar1(0), x) + self.assertIs(expr.getVar2(0), x) + self.assertEqual(expr.getCoeff(0), 1) + + # constraint 2 + c2 = quad_constrs[1] + expr = grb_model.getQCRow(c1) + lin_expr = expr.getLinExpr() + self.assertEqual(lin_expr.size(), 1) + self.assertEqual(lin_expr.getConstant(), 0) + self.assertEqual(lin_expr.getCoeff(0), 1) + self.assertIs(lin_expr.getVar(0), y) + self.assertEqual(c2.QCRHS, -3) + self.assertEqual(c2.QCSense, '>') + self.assertEqual(expr.size(), 1) + self.assertIs(expr.getVar1(0), x) + self.assertIs(expr.getVar2(0), x) + self.assertEqual(expr.getCoeff(0), 1) + + # objective + self.assertEqual(grb_model.ModelSense, 1) # minimizing + obj = grb_model.getObjective() + self.assertEqual(obj.size(), 0) + # ESJ: Note: It appears they don't allow x1 ** x2...? Well, they wait and give the # error in the solver log, so not sure what we want to do about that? From 598a90c9fb5ab04df180fa10dd390af6d4472b07 Mon Sep 17 00:00:00 2001 From: Emma Johnson <12833636+emma58@users.noreply.github.com> Date: Wed, 28 May 2025 21:15:15 -0600 Subject: [PATCH 028/103] black --- pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_writer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_writer.py b/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_writer.py index 6b86652a4d5..04bada8aafc 100644 --- a/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_writer.py +++ b/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_writer.py @@ -265,7 +265,7 @@ def test_named_expressions(self): m = ConcreteModel() m.x = Var() m.y = Var() - m.e = Expression(expr=m.x ** 2 + m.y) + m.e = Expression(expr=m.x**2 + m.y) m.c = Constraint(expr=m.e <= 7) m.c2 = Constraint(expr=m.e >= -3) m.obj = Objective(expr=0) From 81caf8896b98021adb0ca2be410a2697caba8950 Mon Sep 17 00:00:00 2001 From: Emma Johnson <12833636+emma58@users.noreply.github.com> Date: Thu, 29 May 2025 11:06:38 -0600 Subject: [PATCH 029/103] Adding a solution loader and a results object --- .../gurobi_minlp/repn/gurobi_direct_minlp.py | 193 +++++++++++++----- .../tests/test_gurobi_minlp_walker.py | 23 ++- .../tests/test_gurobi_minlp_writer.py | 8 +- 3 files changed, 166 insertions(+), 58 deletions(-) diff --git a/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py b/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py index 8b505d01297..9b8ac5fa21a 100755 --- a/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py +++ b/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py @@ -9,15 +9,20 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ +import datetime +import io from operator import attrgetter, itemgetter from pyomo.common.dependencies import attempt_import -from pyomo.common.collections import ComponentMap +from pyomo.common.collections import ComponentMap, ComponentSet from pyomo.common.config import ConfigDict, ConfigValue from pyomo.common.numeric_types import native_complex_types +from pyomo.common.timing import HierarchicalTimer # ESJ TODO: We should move this somewhere sensible from pyomo.contrib.cp.repn.docplex_writer import collect_valid_components +from pyomo.contrib.solver.common.solution_loader import SolutionLoaderBase +from pyomo.contrib.solver.solvers.gurobi_direct import GurobiDirect from pyomo.core.base import ( Binary, @@ -54,6 +59,7 @@ SumExpression, ) from pyomo.core.expr.visitor import StreamBasedExpressionVisitor, _EvaluationVisitor +from pyomo.core.staleflag import StaleFlagManager from pyomo.opt import SolverFactory, WriterFactory from pyomo.repn.quadratic import QuadraticRepnVisitor @@ -167,8 +173,7 @@ def _create_grb_var(visitor, pyomo_var, name=""): class GurobiMINLPBeforeChildDispatcher(BeforeChildDispatcher): @staticmethod def _before_var(visitor, child): - _id = id(child) - if _id not in visitor.var_map: + if child not in visitor.var_map: if child.fixed: # ESJ TODO: I want the linear walker implementation of # check_constant... Could it be in the base class or something? @@ -178,8 +183,8 @@ def _before_var(visitor, child): child, name=child.name if visitor.symbolic_solver_labels else "", ) - visitor.var_map[_id] = grb_var - return False, (_VARIABLE, visitor.var_map[_id]) + visitor.var_map[child] = grb_var + return False, (_VARIABLE, visitor.var_map[child]) @staticmethod def _before_named_expression(visitor, child): @@ -319,7 +324,7 @@ def __init__(self, grb_model, symbolic_solver_labels=False): super().__init__() self.grb_model = grb_model self.symbolic_solver_labels = symbolic_solver_labels - self.var_map = {} + self.var_map = ComponentMap() self.subexpression_cache = {} self._eval_expr_visitor = _EvaluationVisitor(True) self.evaluate = self._eval_expr_visitor.dfs_postorder_stack @@ -473,6 +478,7 @@ def write(self, model, **options): ) elif len(active_objs) == 1: obj = active_objs[0] + pyo_obj = [obj,] if obj.sense is minimize: sense = GRB.MINIMIZE else: @@ -488,6 +494,9 @@ def write(self, model, **options): else: grb_model.setObjective(obj_expr, sense=sense) # else it's fine--Gurobi doesn't require us to give an objective, so we don't + # either, but we do have to pass the info through for the results object + else: + pyo_obj = [] # write constraints for cons in components[Constraint]: @@ -506,7 +515,29 @@ def write(self, model, **options): grb_model.addConstr(value(cons.ub) >= expr) grb_model.update() - return grb_model, visitor.var_map + return grb_model, visitor.var_map, pyo_obj + + +class GurobiMINLPSolutionLoader(SolutionLoaderBase): + def __init__(self, grb_model, var_map, pyo_obj): + self._grb_model = grb_model + self._pyo_to_grb_var_map = var_map + self._pyo_obj = pyo_obj + + def load_vars(self, vars_to_load=None, solution_number=0): + assert solution_number == 0 + if self._grb_model.SolCount == 0: + raise NoSolutionError() + + if vars_to_load: + vars_to_load = ComponentSet(vars_to_load) + else: + vars_to_load = ComponentSet(self._pyo_to_grb_var_map.keys()) + + for pyo_var, grb_var in self._pyo_to_grb_var_map.items(): + if pyo_var in vars_to_load: + pyo_var.set_value(grb_var.x, skip_validation=True) + StaleFlagManager.mark_all_as_stale(delayed=True) # ESJ TODO: We should probably not do this and actually tack this on to another @@ -518,45 +549,45 @@ def write(self, model, **options): doc='Direct interface to Gurobi version 12 and up ' 'supporting general nonlinear expressions', ) -class GurobiMINLPSolver(object): - CONFIG = ConfigDict("gurobi_minlp_solver") - CONFIG.declare( - 'symbolic_solver_labels', - ConfigValue( - default=False, - domain=bool, - description='Write Pyomo Var and Constraint names to gurobipy model', - ), - ) - CONFIG.declare( - 'tee', - ConfigValue( - default=False, domain=bool, description="Stream solver output to terminal." - ), - ) - CONFIG.declare( - 'options', ConfigValue(default={}, description="Dictionary of solver options.") - ) - - def __init__(self, **kwds): - self.config = self.CONFIG() - self.config.set_value(kwds) - # TODO termination conditions and things - - # Support use as a context manager under current solver API - def __enter__(self): - return self - - def __exit__(self, t, v, traceback): - pass - - def available(self, exception_flag=True): - # TODO - pass - - def license_is_valid(self): - # TODO - pass +class GurobiMINLPSolver(GurobiDirect): + # CONFIG = ConfigDict("gurobi_minlp_solver") + # CONFIG.declare( + # 'symbolic_solver_labels', + # ConfigValue( + # default=False, + # domain=bool, + # description='Write Pyomo Var and Constraint names to gurobipy model', + # ), + # ) + # CONFIG.declare( + # 'tee', + # ConfigValue( + # default=False, domain=bool, description="Stream solver output to terminal." + # ), + # ) + # CONFIG.declare( + # 'options', ConfigValue(default={}, description="Dictionary of solver options.") + # ) + + # def __init__(self, **kwds): + # self.config = self.CONFIG() + # self.config.set_value(kwds) + # # TODO termination conditions and things + + # # Support use as a context manager under current solver API + # def __enter__(self): + # return self + + # def __exit__(self, t, v, traceback): + # pass + + # def available(self, exception_flag=True): + # # TODO + # pass + + # def license_is_valid(self): + # # TODO + # pass def solve(self, model, **kwds): """Solve the model. @@ -564,15 +595,71 @@ def solve(self, model, **kwds): Args: model (Block): a Pyomo model or Block to be solved """ - config = self.config() - config.set_value(kwds) + start_timestamp = datetime.datetime.now(datetime.timezone.utc) + config = self.config(value=kwds, preserve_implicit=True) + if not self.available(): + c = self.__class__ + raise ApplicationError( + f'Solver {c.__module__}.{c.__qualname__} is not available ' + f'({self.available()}).' + ) + if config.timer is None: + config.timer = HierarchicalTimer() + timer = config.timer + + StaleFlagManager.mark_all_as_stale() + + timer.start('compile_model') writer = GurobiMINLPWriter() - grb_model, var_map = writer.write( + grb_model, var_map, pyo_obj = writer.write( model, symbolic_solver_labels=config.symbolic_solver_labels ) - # TODO: Is this right?? - grbsol = grb_model.optimize(**self.options) - # TODO: handle results status - # return results + timer.stop('compile_model') + + ostreams = [io.StringIO()] + config.tee + + # set options + options = config.solver_options + + grb_model.setParam('LogToConsole', 1) + + if config.threads is not None: + grb_model.setParam('Threads', config.threads) + if config.time_limit is not None: + grb_model.setParam('TimeLimit', config.time_limit) + if config.rel_gap is not None: + grb_model.setParam('MIPGap', config.rel_gap) + if config.abs_gap is not None: + grb_model.setParam('MIPGapAbs', config.abs_gap) + + if config.use_mipstart: + raise MouseTrap("MIPSTART not yet supported") + + for key, option in options.items(): + grb_model.setParam(key, option) + + grbsol = grb_model.optimize() + + res = self._postsolve( + timer, + config, + GurobiMINLPSolutionLoader( + grb_model, + var_map, + pyo_obj + ) + ) + + res.solver_config = config + res.solver_name = 'Gurobi' + res.solver_version = self.version() + res.solver_log = ostreams[0].getvalue() + + end_timestamp = datetime.datetime.now(datetime.timezone.utc) + res.timing_info.start_timestamp = start_timestamp + res.timing_info.wall_time = (end_timestamp - start_timestamp).total_seconds() + res.timing_info.timer = timer + return res + diff --git a/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_walker.py b/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_walker.py index 234c9b963e9..5bc3b264f2a 100644 --- a/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_walker.py +++ b/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_walker.py @@ -11,20 +11,27 @@ from pyomo.common.dependencies import attempt_import import pyomo.common.unittest as unittest -from pyomo.contrib.gurobi_minlp.repn.gurobi_direct_minlp import GurobiMINLPVisitor +from pyomo.contrib.gurobi_minlp.repn.gurobi_direct_minlp import ( + GurobiMINLPSolver, + GurobiMINLPVisitor +) from pyomo.environ import ( Binary, ConcreteModel, Constraint, Integers, log, + maximize, NonNegativeIntegers, NonNegativeReals, NonPositiveIntegers, NonPositiveReals, + Objective, Param, Reals, sqrt, + SolverFactory, + value, Var, ) @@ -496,3 +503,17 @@ def test_handle_complex_number_sqrt(self): expr = visitor.walk_expression(m.c.body) # TODO + + def test_solve_model(self): + m = ConcreteModel() + m.x = Var(bounds=(0, 1)) + m.y = Var() + m.c = Constraint(expr=m.y == m.x**2) + m.obj = Objective(expr=m.x + m.y, sense=maximize) + + results = SolverFactory('gurobi_direct_minlp').solve(m) + + self.assertEqual(value(m.obj.expr), 2) + + self.assertEqual(value(m.x), 1) + self.assertEqual(value(m.y), 1) diff --git a/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_writer.py b/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_writer.py index 04bada8aafc..2e04312bd1e 100644 --- a/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_writer.py +++ b/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_writer.py @@ -68,7 +68,7 @@ def test_small_model(self): m = make_model() - grb_model, var_map = WriterFactory('gurobi_minlp').write( + grb_model, var_map, obj = WriterFactory('gurobi_minlp').write( m, symbolic_solver_labels=True ) @@ -180,7 +180,7 @@ def test_write_NPV_negation_in_RHS(self): m.c = Constraint(expr=-m.x1 == m.p1) m.obj = Objective(expr=m.x1) - grb_model, var_map = WriterFactory('gurobi_minlp').write( + grb_model, var_map, obj = WriterFactory('gurobi_minlp').write( m, symbolic_solver_labels=True ) @@ -226,7 +226,7 @@ def test_writer_ignores_deactivated_logical_constraints(self): m.whatever = LogicalConstraint(expr=~m.b) m.whatever.deactivate() - grb_model, var_map = WriterFactory('gurobi_minlp').write( + grb_model, var_map, obj = WriterFactory('gurobi_minlp').write( m, symbolic_solver_labels=True ) @@ -270,7 +270,7 @@ def test_named_expressions(self): m.c2 = Constraint(expr=m.e >= -3) m.obj = Objective(expr=0) - grb_model, var_map = WriterFactory('gurobi_minlp').write( + grb_model, var_map, obj = WriterFactory('gurobi_minlp').write( m, symbolic_solver_labels=True ) From e44530b879590a367163a3e85fb326d99bf936c2 Mon Sep 17 00:00:00 2001 From: Emma Johnson <12833636+emma58@users.noreply.github.com> Date: Thu, 29 May 2025 11:10:01 -0600 Subject: [PATCH 030/103] Testing results object a little --- pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_walker.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_walker.py b/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_walker.py index 5bc3b264f2a..97c4dea467d 100644 --- a/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_walker.py +++ b/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_walker.py @@ -517,3 +517,6 @@ def test_solve_model(self): self.assertEqual(value(m.x), 1) self.assertEqual(value(m.y), 1) + + self.assertEqual(results.incumbent_objective, 2) + self.assertEqual(results.objective_bound, 2) From a408ceb61b70525d2654365dfae6ccc1a5ca53f0 Mon Sep 17 00:00:00 2001 From: Emma Johnson <12833636+emma58@users.noreply.github.com> Date: Thu, 29 May 2025 11:10:28 -0600 Subject: [PATCH 031/103] black --- .../gurobi_minlp/repn/gurobi_direct_minlp.py | 13 +++---------- .../gurobi_minlp/tests/test_gurobi_minlp_walker.py | 2 +- 2 files changed, 4 insertions(+), 11 deletions(-) diff --git a/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py b/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py index 9b8ac5fa21a..361ea2a9ac5 100755 --- a/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py +++ b/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py @@ -478,7 +478,7 @@ def write(self, model, **options): ) elif len(active_objs) == 1: obj = active_objs[0] - pyo_obj = [obj,] + pyo_obj = [obj] if obj.sense is minimize: sense = GRB.MINIMIZE else: @@ -639,17 +639,11 @@ def solve(self, model, **kwds): for key, option in options.items(): grb_model.setParam(key, option) - + grbsol = grb_model.optimize() res = self._postsolve( - timer, - config, - GurobiMINLPSolutionLoader( - grb_model, - var_map, - pyo_obj - ) + timer, config, GurobiMINLPSolutionLoader(grb_model, var_map, pyo_obj) ) res.solver_config = config @@ -662,4 +656,3 @@ def solve(self, model, **kwds): res.timing_info.wall_time = (end_timestamp - start_timestamp).total_seconds() res.timing_info.timer = timer return res - diff --git a/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_walker.py b/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_walker.py index 97c4dea467d..7ba2cf1e5a8 100644 --- a/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_walker.py +++ b/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_walker.py @@ -13,7 +13,7 @@ import pyomo.common.unittest as unittest from pyomo.contrib.gurobi_minlp.repn.gurobi_direct_minlp import ( GurobiMINLPSolver, - GurobiMINLPVisitor + GurobiMINLPVisitor, ) from pyomo.environ import ( Binary, From 9ebea39852bdee37078167b51d3b6c51055bb721 Mon Sep 17 00:00:00 2001 From: Emma Johnson <12833636+emma58@users.noreply.github.com> Date: Thu, 29 May 2025 11:13:30 -0600 Subject: [PATCH 032/103] NFC: Cleaning up and making comment more true --- .../gurobi_minlp/repn/gurobi_direct_minlp.py | 45 +------------------ 1 file changed, 2 insertions(+), 43 deletions(-) diff --git a/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py b/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py index 361ea2a9ac5..875806efbf8 100755 --- a/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py +++ b/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py @@ -540,55 +540,14 @@ def load_vars(self, vars_to_load=None, solution_number=0): StaleFlagManager.mark_all_as_stale(delayed=True) -# ESJ TODO: We should probably not do this and actually tack this on to another -# solver? But I'm not sure. In any case, it should probably at least inerhit -# from another direct interface to Gurobi since all the handling of licenses and -# termination conditions and things should be common. +# ESJ TODO: I just did the most convenient inheritence for the moment--if this is the +# right thing to do is a different question. @SolverFactory.register( 'gurobi_direct_minlp', doc='Direct interface to Gurobi version 12 and up ' 'supporting general nonlinear expressions', ) class GurobiMINLPSolver(GurobiDirect): - # CONFIG = ConfigDict("gurobi_minlp_solver") - # CONFIG.declare( - # 'symbolic_solver_labels', - # ConfigValue( - # default=False, - # domain=bool, - # description='Write Pyomo Var and Constraint names to gurobipy model', - # ), - # ) - # CONFIG.declare( - # 'tee', - # ConfigValue( - # default=False, domain=bool, description="Stream solver output to terminal." - # ), - # ) - # CONFIG.declare( - # 'options', ConfigValue(default={}, description="Dictionary of solver options.") - # ) - - # def __init__(self, **kwds): - # self.config = self.CONFIG() - # self.config.set_value(kwds) - # # TODO termination conditions and things - - # # Support use as a context manager under current solver API - # def __enter__(self): - # return self - - # def __exit__(self, t, v, traceback): - # pass - - # def available(self, exception_flag=True): - # # TODO - # pass - - # def license_is_valid(self): - # # TODO - # pass - def solve(self, model, **kwds): """Solve the model. From 5daef08f57a1f794095683ae3e83d31f6f690d68 Mon Sep 17 00:00:00 2001 From: Emma Johnson <12833636+emma58@users.noreply.github.com> Date: Thu, 29 May 2025 11:17:32 -0600 Subject: [PATCH 033/103] Whoops, misfiled test --- .../tests/test_gurobi_minlp_walker.py | 20 --------------- .../tests/test_gurobi_minlp_writer.py | 25 ++++++++++++++++++- 2 files changed, 24 insertions(+), 21 deletions(-) diff --git a/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_walker.py b/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_walker.py index 7ba2cf1e5a8..227eb4b442b 100644 --- a/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_walker.py +++ b/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_walker.py @@ -12,7 +12,6 @@ from pyomo.common.dependencies import attempt_import import pyomo.common.unittest as unittest from pyomo.contrib.gurobi_minlp.repn.gurobi_direct_minlp import ( - GurobiMINLPSolver, GurobiMINLPVisitor, ) from pyomo.environ import ( @@ -21,7 +20,6 @@ Constraint, Integers, log, - maximize, NonNegativeIntegers, NonNegativeReals, NonPositiveIntegers, @@ -30,8 +28,6 @@ Param, Reals, sqrt, - SolverFactory, - value, Var, ) @@ -504,19 +500,3 @@ def test_handle_complex_number_sqrt(self): # TODO - def test_solve_model(self): - m = ConcreteModel() - m.x = Var(bounds=(0, 1)) - m.y = Var() - m.c = Constraint(expr=m.y == m.x**2) - m.obj = Objective(expr=m.x + m.y, sense=maximize) - - results = SolverFactory('gurobi_direct_minlp').solve(m) - - self.assertEqual(value(m.obj.expr), 2) - - self.assertEqual(value(m.x), 1) - self.assertEqual(value(m.y), 1) - - self.assertEqual(results.incumbent_objective, 2) - self.assertEqual(results.objective_bound, 2) diff --git a/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_writer.py b/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_writer.py index 2e04312bd1e..19fddea2779 100644 --- a/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_writer.py +++ b/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_writer.py @@ -20,6 +20,7 @@ Integers, log, LogicalConstraint, + maximize, NonNegativeIntegers, NonNegativeReals, NonPositiveIntegers, @@ -27,10 +28,15 @@ Objective, Param, Reals, + SolverFactory, + value, Var, ) from pyomo.opt import WriterFactory -from pyomo.contrib.gurobi_minlp.repn.gurobi_direct_minlp import GurobiMINLPVisitor +from pyomo.contrib.gurobi_minlp.repn.gurobi_direct_minlp import ( + GurobiMINLPSolver, + GurobiMINLPVisitor +) from pyomo.contrib.gurobi_minlp.tests.test_gurobi_minlp_walker import CommonTest ## DEBUG @@ -323,6 +329,23 @@ def test_named_expressions(self): self.assertEqual(grb_model.ModelSense, 1) # minimizing obj = grb_model.getObjective() self.assertEqual(obj.size(), 0) + + def test_solve_model(self): + m = ConcreteModel() + m.x = Var(bounds=(0, 1)) + m.y = Var() + m.c = Constraint(expr=m.y == m.x**2) + m.obj = Objective(expr=m.x + m.y, sense=maximize) + + results = SolverFactory('gurobi_direct_minlp').solve(m) + + self.assertEqual(value(m.obj.expr), 2) + + self.assertEqual(value(m.x), 1) + self.assertEqual(value(m.y), 1) + + self.assertEqual(results.incumbent_objective, 2) + self.assertEqual(results.objective_bound, 2) # ESJ: Note: It appears they don't allow x1 ** x2...? Well, they wait and give the From e91f71388099d2bd5320b8e9ab2e29b2742c1aeb Mon Sep 17 00:00:00 2001 From: Emma Johnson <12833636+emma58@users.noreply.github.com> Date: Thu, 29 May 2025 11:41:58 -0600 Subject: [PATCH 034/103] Fixing another but with how we hit _apply_operation for DivisionExpression nodes --- .../tests/test_gurobi_minlp_walker.py | 24 +++++++++++++++---- .../tests/test_gurobi_minlp_writer.py | 4 ++-- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_walker.py b/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_walker.py index 227eb4b442b..566032ac14d 100644 --- a/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_walker.py +++ b/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_walker.py @@ -11,9 +11,7 @@ from pyomo.common.dependencies import attempt_import import pyomo.common.unittest as unittest -from pyomo.contrib.gurobi_minlp.repn.gurobi_direct_minlp import ( - GurobiMINLPVisitor, -) +from pyomo.contrib.gurobi_minlp.repn.gurobi_direct_minlp import GurobiMINLPVisitor from pyomo.environ import ( Binary, ConcreteModel, @@ -235,6 +233,25 @@ def test_write_division(self): self.assertEqual(opcode[2], GRB.OPCODE_VARIABLE) self.assertIs(data[2], x1) + def test_write_division_linear(self): + m = self.get_model() + m.p = Param(initialize=3, mutable=True) + m.c = Constraint(expr=(m.x1 + m.x2) * m.p / 10 == 1) + + visitor = self.get_visitor() + expr = visitor.walk_expression(m.c.body) + + x1 = visitor.var_map[id(m.x1)] + x2 = visitor.var_map[id(m.x2)] + + # linear + self.assertEqual(expr.size(), 2) + self.assertEqual(expr.getConstant(), 0) + self.assertAlmostEqual(expr.getCoeff(0), 3/10) + self.assertIs(expr.getVar(0), x1) + self.assertAlmostEqual(expr.getCoeff(1), 3/10) + self.assertIs(expr.getVar(1), x2) + def test_write_quadratic_power_expression_var_const(self): m = self.get_model() m.c = Constraint(expr=m.x1**2 >= 3) @@ -499,4 +516,3 @@ def test_handle_complex_number_sqrt(self): expr = visitor.walk_expression(m.c.body) # TODO - diff --git a/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_writer.py b/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_writer.py index 19fddea2779..2849bf0d988 100644 --- a/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_writer.py +++ b/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_writer.py @@ -35,7 +35,7 @@ from pyomo.opt import WriterFactory from pyomo.contrib.gurobi_minlp.repn.gurobi_direct_minlp import ( GurobiMINLPSolver, - GurobiMINLPVisitor + GurobiMINLPVisitor, ) from pyomo.contrib.gurobi_minlp.tests.test_gurobi_minlp_walker import CommonTest @@ -329,7 +329,7 @@ def test_named_expressions(self): self.assertEqual(grb_model.ModelSense, 1) # minimizing obj = grb_model.getObjective() self.assertEqual(obj.size(), 0) - + def test_solve_model(self): m = ConcreteModel() m.x = Var(bounds=(0, 1)) From 461c64c1a9c27cd501920ca81b0737e6c1dda521 Mon Sep 17 00:00:00 2001 From: Emma Johnson <12833636+emma58@users.noreply.github.com> Date: Thu, 29 May 2025 11:47:43 -0600 Subject: [PATCH 035/103] I assume there are other ways to hit that bug, don't have tests at the moment --- pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py b/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py index 875806efbf8..115bab277c1 100755 --- a/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py +++ b/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py @@ -207,15 +207,15 @@ def _handle_node_with_eval_expr_visitor_invariant(visitor, node, data): def _handle_node_with_eval_expr_visitor_unknown(visitor, node, *data): # ESJ: Is this cheating? expr_type = max(map(itemgetter(0), data)) - return (expr_type, visitor._eval_expr_visitor.visit(node, map(itemgetter(1), data))) + return (expr_type, visitor._eval_expr_visitor.visit(node, tuple(map(itemgetter(1), data)))) def _handle_node_with_eval_expr_visitor_constant(visitor, node, *data): - return (_CONSTANT, visitor._eval_expr_visitor.visit(node, map(itemgetter(1), data))) + return (_CONSTANT, visitor._eval_expr_visitor.visit(node, tuple(map(itemgetter(1), data)))) def _handle_node_with_eval_expr_visitor_linear(visitor, node, *data): - return (_LINEAR, visitor._eval_expr_visitor.visit(node, map(itemgetter(1), data))) + return (_LINEAR, visitor._eval_expr_visitor.visit(node, tuple(map(itemgetter(1), data)))) def _handle_node_with_eval_expr_visitor_nonlinear(visitor, node, *data): From 850eecaad74c7b0c5aac010e40107c78be8ba9ff Mon Sep 17 00:00:00 2001 From: Emma Johnson <12833636+emma58@users.noreply.github.com> Date: Thu, 29 May 2025 11:48:08 -0600 Subject: [PATCH 036/103] Black --- .../gurobi_minlp/repn/gurobi_direct_minlp.py | 15 ++++++++++++--- .../tests/test_gurobi_minlp_walker.py | 4 ++-- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py b/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py index 115bab277c1..cd38ee5176a 100755 --- a/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py +++ b/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py @@ -207,15 +207,24 @@ def _handle_node_with_eval_expr_visitor_invariant(visitor, node, data): def _handle_node_with_eval_expr_visitor_unknown(visitor, node, *data): # ESJ: Is this cheating? expr_type = max(map(itemgetter(0), data)) - return (expr_type, visitor._eval_expr_visitor.visit(node, tuple(map(itemgetter(1), data)))) + return ( + expr_type, + visitor._eval_expr_visitor.visit(node, tuple(map(itemgetter(1), data))), + ) def _handle_node_with_eval_expr_visitor_constant(visitor, node, *data): - return (_CONSTANT, visitor._eval_expr_visitor.visit(node, tuple(map(itemgetter(1), data)))) + return ( + _CONSTANT, + visitor._eval_expr_visitor.visit(node, tuple(map(itemgetter(1), data))), + ) def _handle_node_with_eval_expr_visitor_linear(visitor, node, *data): - return (_LINEAR, visitor._eval_expr_visitor.visit(node, tuple(map(itemgetter(1), data)))) + return ( + _LINEAR, + visitor._eval_expr_visitor.visit(node, tuple(map(itemgetter(1), data))), + ) def _handle_node_with_eval_expr_visitor_nonlinear(visitor, node, *data): diff --git a/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_walker.py b/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_walker.py index 566032ac14d..9614af91c2f 100644 --- a/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_walker.py +++ b/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_walker.py @@ -247,9 +247,9 @@ def test_write_division_linear(self): # linear self.assertEqual(expr.size(), 2) self.assertEqual(expr.getConstant(), 0) - self.assertAlmostEqual(expr.getCoeff(0), 3/10) + self.assertAlmostEqual(expr.getCoeff(0), 3 / 10) self.assertIs(expr.getVar(0), x1) - self.assertAlmostEqual(expr.getCoeff(1), 3/10) + self.assertAlmostEqual(expr.getCoeff(1), 3 / 10) self.assertIs(expr.getVar(1), x2) def test_write_quadratic_power_expression_var_const(self): From 26b0be303c803f99a14663abdd760e5ec47a11bc Mon Sep 17 00:00:00 2001 From: Emma Johnson <12833636+emma58@users.noreply.github.com> Date: Tue, 10 Jun 2025 15:41:03 -0600 Subject: [PATCH 037/103] Adding some notes and fixing a typo --- .../gurobi_minlp/repn/gurobi_direct_minlp.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py b/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py index cd38ee5176a..39dc309804b 100755 --- a/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py +++ b/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py @@ -9,6 +9,15 @@ # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ + +## TODO + +# Look into if I can piggyback off of ipopt writer and just plug in my walker +# Why did I have to make a custom solution loader? +# Move into contrib.solver: doc/onlinedoc/explanation/experimental has information about future solvers. Put some docs here. +# Is there a half-matrix half-explicit way to give MINLPs to Gurobi? Soren thinks yes... +# Open a PR into Miranda's fork. + import datetime import io from operator import attrgetter, itemgetter @@ -357,7 +366,7 @@ def exitNode(self, node, data): ) def finalizeResult(self, result): - self.grb_model.update() + #self.grb_model.update() return result[1] # ESJ TODO: THIS IS COPIED FROM THE LINEAR WALKER--CAN WE PUT IT IN UTIL OR @@ -399,7 +408,7 @@ def check_constant(self, ans, obj): 'gurobi_minlp', 'Direct interface to Gurobi that allows for general nonlinear expressions', ) -class GurobiMINLPWriter(object): +class GurobiMINLPWriter(): CONFIG = ConfigDict('gurobi_minlp_writer') CONFIG.declare( 'symbolic_solver_labels', @@ -549,7 +558,7 @@ def load_vars(self, vars_to_load=None, solution_number=0): StaleFlagManager.mark_all_as_stale(delayed=True) -# ESJ TODO: I just did the most convenient inheritence for the moment--if this is the +# ESJ TODO: I just did the most convenient inheritance for the moment--if this is the # right thing to do is a different question. @SolverFactory.register( 'gurobi_direct_minlp', From 2c1859729e22da2cd048f3eebca0cf60b9c798c0 Mon Sep 17 00:00:00 2001 From: Emma Johnson <12833636+emma58@users.noreply.github.com> Date: Wed, 9 Jul 2025 16:14:15 -0600 Subject: [PATCH 038/103] Moving to the contrib.solver SolverFactory so that we get the wrapper --- pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py | 3 ++- pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_writer.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py b/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py index 39dc309804b..7c9ed7ebb01 100755 --- a/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py +++ b/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py @@ -30,6 +30,7 @@ # ESJ TODO: We should move this somewhere sensible from pyomo.contrib.cp.repn.docplex_writer import collect_valid_components +from pyomo.contrib.solver.common.factory import SolverFactory from pyomo.contrib.solver.common.solution_loader import SolutionLoaderBase from pyomo.contrib.solver.solvers.gurobi_direct import GurobiDirect @@ -70,7 +71,7 @@ from pyomo.core.expr.visitor import StreamBasedExpressionVisitor, _EvaluationVisitor from pyomo.core.staleflag import StaleFlagManager -from pyomo.opt import SolverFactory, WriterFactory +from pyomo.opt import WriterFactory from pyomo.repn.quadratic import QuadraticRepnVisitor from pyomo.repn.util import ( apply_node_operation, diff --git a/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_writer.py b/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_writer.py index 2849bf0d988..aa7b3dd6610 100644 --- a/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_writer.py +++ b/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_writer.py @@ -28,7 +28,6 @@ Objective, Param, Reals, - SolverFactory, value, Var, ) @@ -37,6 +36,7 @@ GurobiMINLPSolver, GurobiMINLPVisitor, ) +from pyomo.contrib.solver.common.factory import SolverFactory from pyomo.contrib.gurobi_minlp.tests.test_gurobi_minlp_walker import CommonTest ## DEBUG From 18c3d3ad2b0a4b5114efcf20dea12dfb9ae61571 Mon Sep 17 00:00:00 2001 From: Emma Johnson <12833636+emma58@users.noreply.github.com> Date: Wed, 9 Jul 2025 16:27:50 -0600 Subject: [PATCH 039/103] Fixing a couple tests that need update called to work --- .../gurobi_minlp/tests/test_gurobi_minlp_walker.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_walker.py b/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_walker.py index 9614af91c2f..6ec70116053 100644 --- a/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_walker.py +++ b/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_walker.py @@ -64,6 +64,10 @@ def test_var_domains(self): visitor = self.get_visitor() expr = visitor.walk_expression(e) + # We don't call update in walk expression for performance reasons, but + # we need to update here in order to be able to test expr. + visitor.grb_model.update() + x1 = visitor.var_map[id(m.x1)] x2 = visitor.var_map[id(m.x2)] x3 = visitor.var_map[id(m.x3)] @@ -113,6 +117,10 @@ def test_var_bounds(self): visitor = self.get_visitor() expr = visitor.walk_expression(e) + # We don't call update in walk expression for performance reasons, but + # we need to update here in order to be able to test expr. + visitor.grb_model.update() + x2 = visitor.var_map[id(m.x2)] x3 = visitor.var_map[id(m.x3)] y1 = visitor.var_map[id(m.y1)] From cc22abc4062881f81cda9a426c35bc78700ef00c Mon Sep 17 00:00:00 2001 From: Emma Johnson <12833636+emma58@users.noreply.github.com> Date: Wed, 9 Jul 2025 16:57:57 -0600 Subject: [PATCH 040/103] Making the absolute value tests do something --- .../tests/test_gurobi_minlp_walker.py | 40 +++++++++++++++---- 1 file changed, 33 insertions(+), 7 deletions(-) diff --git a/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_walker.py b/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_walker.py index 6ec70116053..6a43f31d495 100644 --- a/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_walker.py +++ b/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_walker.py @@ -395,18 +395,22 @@ def test_write_absolute_value_of_var(self): # expr is actually an auxiliary variable. We should # get a constraint: # expr == abs(x1) - + x1 = visitor.var_map[id(m.x1)] + self.assertIsInstance(expr, gurobipy.Var) grb_model = visitor.grb_model + # We don't call update in walk expression for performance reasons, but + # we need to update here in order to be able to test expr. + grb_model.update() self.assertEqual(grb_model.numVars, 2) self.assertEqual(grb_model.numGenConstrs, 1) self.assertEqual(grb_model.numConstrs, 0) self.assertEqual(grb_model.numQConstrs, 0) - # we're going to have to write the resulting model to an lp file to test that we - # have what we expect - - # TODO + cons = grb_model.getGenConstrs()[0] + aux, v = grb_model.getGenConstrAbs(cons) + self.assertIs(aux, expr) + self.assertIs(v, x1) def test_write_absolute_value_of_expression(self): m = self.get_model() @@ -419,17 +423,39 @@ def test_write_absolute_value_of_expression(self): # aux1 == x1 + 2 * x2 # expr == abs(aux1) + x1 = visitor.var_map[m.x1] + x2 = visitor.var_map[m.x2] + # we're going to have to write the resulting model to an lp file to test that we # have what we expect self.assertIsInstance(expr, gurobipy.Var) grb_model = visitor.grb_model + # We don't call update in walk expression for performance reasons, but + # we need to update here in order to be able to test expr. + grb_model.update() self.assertEqual(grb_model.numVars, 4) self.assertEqual(grb_model.numGenConstrs, 1) self.assertEqual(grb_model.numConstrs, 1) self.assertEqual(grb_model.numQConstrs, 0) - # TODO - + cons = grb_model.getGenConstrs()[0] + aux2, aux1 = grb_model.getGenConstrAbs(cons) + self.assertIs(aux2, expr) + + cons = grb_model.getConstrs()[0] + # this guy is linear equality + self.assertEqual(cons.RHS, 0) + self.assertEqual(cons.Sense, '=') + linexpr = grb_model.getRow(cons) + self.assertEqual(linexpr.getConstant(), 0) + self.assertEqual(linexpr.size(), 3) + self.assertEqual(linexpr.getCoeff(0), -1) + self.assertIs(linexpr.getVar(0), x1) + self.assertEqual(linexpr.getCoeff(1), -2) + self.assertIs(linexpr.getVar(1), x2) + self.assertEqual(linexpr.getCoeff(2), 1) + self.assertIs(linexpr.getVar(2), aux1) + def test_write_expression_with_mutable_param(self): m = self.get_model() m.p = Param(initialize=4, mutable=True) From 0b7d03a66c0f6c01d6c763412ac36c6ec792db04 Mon Sep 17 00:00:00 2001 From: Emma Johnson <12833636+emma58@users.noreply.github.com> Date: Mon, 21 Jul 2025 17:08:05 -0600 Subject: [PATCH 041/103] Fixing a problem where Gurobi believes 0*nonlinear_stuff is nonlinear and we have to let if keep believing that, adding tests --- .../gurobi_minlp/repn/gurobi_direct_minlp.py | 10 +- .../tests/test_gurobi_minlp_walker.py | 88 +++++++++++++---- .../tests/test_gurobi_minlp_writer.py | 99 +++++++++++++++++++ 3 files changed, 170 insertions(+), 27 deletions(-) diff --git a/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py b/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py index 7c9ed7ebb01..288e7f70bf3 100755 --- a/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py +++ b/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py @@ -368,7 +368,7 @@ def exitNode(self, node, data): def finalizeResult(self, result): #self.grb_model.update() - return result[1] + return result # ESJ TODO: THIS IS COPIED FROM THE LINEAR WALKER--CAN WE PUT IT IN UTIL OR # SOMETHING? @@ -431,13 +431,11 @@ def _create_gurobi_expression( nonlinear (non-quadratic) expression, and returns a gurobipy representation of the expression """ - repn = quadratic_visitor.walk_expression(expr) - if repn.nonlinear is None: - grb_expr = grb_visitor.walk_expression(expr) + #repn = quadratic_visitor.walk_expression(expr) + expr_type, grb_expr = grb_visitor.walk_expression(expr) + if expr_type is not _GENERAL: return grb_expr, False, None else: - # It's general nonlinear - grb_expr = grb_visitor.walk_expression(expr) aux = grb_model.addVar() return grb_expr, True, aux diff --git a/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_walker.py b/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_walker.py index 6a43f31d495..05353cf176a 100644 --- a/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_walker.py +++ b/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_walker.py @@ -62,7 +62,7 @@ def test_var_domains(self): m = self.get_model() e = m.x1 + m.x2 + m.x3 + m.y1 + m.y2 + m.y3 + m.z1 visitor = self.get_visitor() - expr = visitor.walk_expression(e) + _, expr = visitor.walk_expression(e) # We don't call update in walk expression for performance reasons, but # we need to update here in order to be able to test expr. @@ -115,7 +115,7 @@ def test_var_bounds(self): e = m.x1 + m.x2 + m.x3 + m.y1 + m.y2 + m.y3 + m.z1 visitor = self.get_visitor() - expr = visitor.walk_expression(e) + _, expr = visitor.walk_expression(e) # We don't call update in walk expression for performance reasons, but # we need to update here in order to be able to test expr. @@ -149,7 +149,7 @@ def test_write_addition(self): m = self.get_model() m.c = Constraint(expr=m.x1 + m.x2 >= 3) visitor = self.get_visitor() - expr = visitor.walk_expression(m.c.body) + _, expr = visitor.walk_expression(m.c.body) x1 = visitor.var_map[id(m.x1)] x2 = visitor.var_map[id(m.x2)] @@ -166,7 +166,7 @@ def test_write_subtraction(self): m = self.get_model() m.c = Constraint(expr=m.x1 - m.x2 >= 3) visitor = self.get_visitor() - expr = visitor.walk_expression(m.c.body) + _, expr = visitor.walk_expression(m.c.body) x1 = visitor.var_map[id(m.x1)] x2 = visitor.var_map[id(m.x2)] @@ -183,7 +183,7 @@ def test_write_product(self): m = self.get_model() m.c = Constraint(expr=m.x1 * m.x2 >= 3) visitor = self.get_visitor() - expr = visitor.walk_expression(m.c.body) + _, expr = visitor.walk_expression(m.c.body) x1 = visitor.var_map[id(m.x1)] x2 = visitor.var_map[id(m.x2)] @@ -202,7 +202,7 @@ def test_write_product_with_fixed_var(self): m.c = Constraint(expr=m.x1 * m.x2 == 1) visitor = self.get_visitor() - expr = visitor.walk_expression(m.c.body) + _, expr = visitor.walk_expression(m.c.body) x1 = visitor.var_map[id(m.x1)] @@ -212,12 +212,56 @@ def test_write_product_with_fixed_var(self): self.assertIs(expr.getVar(0), x1) self.assertEqual(expr.getConstant(), 0.0) + def test_write_product_with_0(self): + m = self.get_model() + m.c = Constraint(expr=(0 * m.x1 * m.x2) * m.x3 == 0) + + visitor = self.get_visitor() + _, expr = visitor.walk_expression(m.c.body) + + x1 = visitor.var_map[m.x1] + x2 = visitor.var_map[m.x2] + x3 = visitor.var_map[m.x3] + + # this is a "nonlinear" + opcode, data, parent = self._get_nl_expr_tree(visitor, expr) + + self.assertEqual(len(opcode), 6) + self.assertEqual(parent[0], -1) # root + self.assertEqual(opcode[0], GRB.OPCODE_MULTIPLY) + self.assertEqual(data[0], -1) # no additional data + + # first arg is another multiply with three children + self.assertEqual(parent[1], 0) + self.assertEqual(opcode[1], GRB.OPCODE_MULTIPLY) + self.assertEqual(data[0], -1) + + # second arg is the constant + self.assertEqual(parent[2], 1) + self.assertEqual(opcode[2], GRB.OPCODE_CONSTANT) + self.assertEqual(data[2], 0) + + # third arg is x1 + self.assertEqual(parent[3], 1) + self.assertEqual(opcode[3], GRB.OPCODE_VARIABLE) + self.assertIs(data[3], x1) + + # fourth arg is x2 + self.assertEqual(parent[4], 1) + self.assertEqual(opcode[4], GRB.OPCODE_VARIABLE) + self.assertIs(data[4], x2) + + # fifth arg is x3, whose parent is the root + self.assertEqual(parent[5], 0) + self.assertEqual(opcode[5], GRB.OPCODE_VARIABLE) + self.assertIs(data[5], x3) + def test_write_division(self): m = self.get_model() m.c = Constraint(expr=1 / m.x1 == 1) visitor = self.get_visitor() - expr = visitor.walk_expression(m.c.body) + _, expr = visitor.walk_expression(m.c.body) x1 = visitor.var_map[id(m.x1)] @@ -247,7 +291,7 @@ def test_write_division_linear(self): m.c = Constraint(expr=(m.x1 + m.x2) * m.p / 10 == 1) visitor = self.get_visitor() - expr = visitor.walk_expression(m.c.body) + _, expr = visitor.walk_expression(m.c.body) x1 = visitor.var_map[id(m.x1)] x2 = visitor.var_map[id(m.x2)] @@ -264,7 +308,7 @@ def test_write_quadratic_power_expression_var_const(self): m = self.get_model() m.c = Constraint(expr=m.x1**2 >= 3) visitor = self.get_visitor() - expr = visitor.walk_expression(m.c.body) + _, expr = visitor.walk_expression(m.c.body) # This is also quadratic x1 = visitor.var_map[id(m.x1)] @@ -296,7 +340,7 @@ def test_write_nonquadratic_power_expression_var_const(self): m = self.get_model() m.c = Constraint(expr=m.x1**3 >= 3) visitor = self.get_visitor() - expr = visitor.walk_expression(m.c.body) + _, expr = visitor.walk_expression(m.c.body) # This is general nonlinear x1 = visitor.var_map[id(m.x1)] @@ -326,7 +370,7 @@ def test_write_power_expression_var_var(self): m = self.get_model() m.c = Constraint(expr=m.x1**m.x2 >= 3) visitor = self.get_visitor() - expr = visitor.walk_expression(m.c.body) + _, expr = visitor.walk_expression(m.c.body) # You can't actually use this in a model in Gurobi 12, but you can build the # expression... (It fails during the solve for some reason.) @@ -359,7 +403,7 @@ def test_write_power_expression_const_var(self): m = self.get_model() m.c = Constraint(expr=2**m.x2 >= 3) visitor = self.get_visitor() - expr = visitor.walk_expression(m.c.body) + _, expr = visitor.walk_expression(m.c.body) x2 = visitor.var_map[id(m.x2)] @@ -390,7 +434,7 @@ def test_write_absolute_value_of_var(self): m = self.get_model() m.c = Constraint(expr=abs(m.x1) >= 3) visitor = self.get_visitor() - expr = visitor.walk_expression(m.c.body) + _, expr = visitor.walk_expression(m.c.body) # expr is actually an auxiliary variable. We should # get a constraint: @@ -416,7 +460,7 @@ def test_write_absolute_value_of_expression(self): m = self.get_model() m.c = Constraint(expr=abs(m.x1 + 2 * m.x2) >= 3) visitor = self.get_visitor() - expr = visitor.walk_expression(m.c.body) + _, expr = visitor.walk_expression(m.c.body) # expr is actually an auxiliary variable. We should # get three constraints: @@ -461,7 +505,7 @@ def test_write_expression_with_mutable_param(self): m.p = Param(initialize=4, mutable=True) m.c = Constraint(expr=m.p**m.x2 >= 3) visitor = self.get_visitor() - expr = visitor.walk_expression(m.c.body) + _, expr = visitor.walk_expression(m.c.body) # expr is nonlinear x2 = visitor.var_map[id(m.x2)] @@ -496,20 +540,20 @@ def test_monomial_expression(self): pow_expr = (m.p ** (0.5)) * m.x1 visitor = self.get_visitor() - expr = visitor.walk_expression(const_expr) + _, expr = visitor.walk_expression(const_expr) x1 = visitor.var_map[id(m.x1)] self.assertEqual(expr.size(), 1) self.assertEqual(expr.getConstant(), 0.0) self.assertIs(expr.getVar(0), x1) self.assertEqual(expr.getCoeff(0), 3) - expr = visitor.walk_expression(nested_expr) + _, expr = visitor.walk_expression(nested_expr) self.assertEqual(expr.size(), 1) self.assertEqual(expr.getConstant(), 0.0) self.assertIs(expr.getVar(0), x1) self.assertAlmostEqual(expr.getCoeff(0), 1 / 4) - expr = visitor.walk_expression(pow_expr) + _, expr = visitor.walk_expression(pow_expr) self.assertEqual(expr.size(), 1) self.assertEqual(expr.getConstant(), 0.0) self.assertIs(expr.getVar(0), x1) @@ -520,7 +564,7 @@ def test_log_expression(self): m.c = Constraint(expr=log(m.x1) >= 3) m.pprint() visitor = self.get_visitor() - expr = visitor.walk_expression(m.c.body) + _, expr = visitor.walk_expression(m.c.body) # expr is nonlinear x1 = visitor.var_map[id(m.x1)] @@ -547,6 +591,8 @@ def test_handle_complex_number_sqrt(self): m.p = Param(initialize=3, mutable=True) m.c = Constraint(expr=sqrt(-m.p) + m.x1 >= 3) visitor = self.get_visitor() - expr = visitor.walk_expression(m.c.body) + _, expr = visitor.walk_expression(m.c.body) - # TODO + # TODO: What did I want to happen here? We should probably catch + # this in the writer? + self.assertTrue(False) diff --git a/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_writer.py b/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_writer.py index aa7b3dd6610..8891e804422 100644 --- a/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_writer.py +++ b/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_writer.py @@ -37,6 +37,7 @@ GurobiMINLPVisitor, ) from pyomo.contrib.solver.common.factory import SolverFactory +from pyomo.contrib.solver.common.results import TerminationCondition from pyomo.contrib.gurobi_minlp.tests.test_gurobi_minlp_walker import CommonTest ## DEBUG @@ -347,6 +348,104 @@ def test_solve_model(self): self.assertEqual(results.incumbent_objective, 2) self.assertEqual(results.objective_bound, 2) + def test_unbounded_because_of_multplying_by_0(self): + # Gurobi belives that the expression in m.c is nonlinear, so we have + # to pass it that way for this to work. Because this is in fact an + # unbounded model. + + m = ConcreteModel() + m.x1 = Var() + m.x2 = Var() + m.x3 = Var() + m.c = Constraint(expr=(0 * m.x1 * m.x2) * m.x3 == 0) + m.obj = Objective(expr=m.x1) + + grb_model, var_map, obj = WriterFactory('gurobi_minlp').write( + m, symbolic_solver_labels=True + ) + + self.assertEqual(len(var_map), 3) + x1 = var_map[id(m.x1)] + x2 = var_map[id(m.x2)] + x3 = var_map[id(m.x3)] + + self.assertEqual(grb_model.numVars, 4) + self.assertEqual(grb_model.numIntVars, 0) + self.assertEqual(grb_model.numBinVars, 0) + + lin_constrs = grb_model.getConstrs() + self.assertEqual(len(lin_constrs), 1) + quad_constrs = grb_model.getQConstrs() + self.assertEqual(len(quad_constrs), 0) + nonlinear_constrs = grb_model.getGenConstrs() + self.assertEqual(len(nonlinear_constrs), 1) + + # this is the auxiliary variable equality + c = lin_constrs[0] + c_expr = grb_model.getRow(c) + self.assertEqual(c.RHS, 0) + self.assertEqual(c.Sense, '=') + self.assertEqual(c_expr.size(), 1) + self.assertEqual(c_expr.getCoeff(0), 1) + self.assertEqual(c_expr.getConstant(), 0) + aux_var = c_expr.getVar(0) + + # this is the nonlinear equality + c = nonlinear_constrs[0] + res_var, opcode, data, parent = grb_model.getGenConstrNLAdv(c) + # This is where we link into the linear inequality constraint + self.assertIs(res_var, aux_var) + + self.assertEqual(len(opcode), 6) + self.assertEqual(parent[0], -1) # root + self.assertEqual(opcode[0], GRB.OPCODE_MULTIPLY) + self.assertEqual(data[0], -1) # no additional data + + # first arg is another multiply with three children + self.assertEqual(parent[1], 0) + self.assertEqual(opcode[1], GRB.OPCODE_MULTIPLY) + self.assertEqual(data[0], -1) + + # second arg is the constant + self.assertEqual(parent[2], 1) + self.assertEqual(opcode[2], GRB.OPCODE_CONSTANT) + self.assertEqual(data[2], 0) + + # third arg is x1 + self.assertEqual(parent[3], 1) + self.assertEqual(opcode[3], GRB.OPCODE_VARIABLE) + self.assertIs(data[3], x1) + + # fourth arg is x2 + self.assertEqual(parent[4], 1) + self.assertEqual(opcode[4], GRB.OPCODE_VARIABLE) + self.assertIs(data[4], x2) + + # fifth arg is x3, whose parent is the root + self.assertEqual(parent[5], 0) + self.assertEqual(opcode[5], GRB.OPCODE_VARIABLE) + self.assertIs(data[5], x3) + + opt = SolverFactory('gurobi_direct_minlp') + opt.config.raise_exception_on_nonoptimal_result = False + results = opt.solve(m) + # model is unbounded + self.assertEqual(results.termination_condition, TerminationCondition.unbounded) + + def test_soren_example2(self): + import numpy as np + + m = ConcreteModel() + m.x1 = Var() + m.x2 = Var() + m.x1.fix(np.float64(0)) + m.x2.fix(np.float64(0)) + m.c = Constraint(expr=m.x1 == m.x2) + m.obj = Objective(expr=m.x1) + m.pprint() + results = SolverFactory('gurobi_direct_minlp').solve(m) + results.display() + # ESJ: Note: It appears they don't allow x1 ** x2...? Well, they wait and give the # error in the solver log, so not sure what we want to do about that? From 520b7a0704a24f670ad33db358e6ed48a3cd96b7 Mon Sep 17 00:00:00 2001 From: Emma Johnson <12833636+emma58@users.noreply.github.com> Date: Tue, 5 Aug 2025 13:26:50 -0600 Subject: [PATCH 042/103] Tracking quadratics separately from general nonlinear because we need to know at the end --- .../gurobi_minlp/repn/gurobi_direct_minlp.py | 67 +++++++++++++++++-- .../tests/test_gurobi_minlp_writer.py | 62 ++++++++++++++++- 2 files changed, 123 insertions(+), 6 deletions(-) diff --git a/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py b/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py index 288e7f70bf3..3807aab1332 100755 --- a/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py +++ b/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py @@ -113,6 +113,7 @@ _CONSTANT = ExprType.CONSTANT _GENERAL = ExprType.GENERAL _LINEAR = ExprType.LINEAR +_QUADRATIC = ExprType.QUADRATIC _VARIABLE = ExprType.VARIABLE _function_map = {} @@ -199,10 +200,13 @@ def _before_var(visitor, child): @staticmethod def _before_named_expression(visitor, child): _id = id(child) + print("before %s" % child.expr) if _id in visitor.subexpression_cache: + print("found it--don't descend") _type, expr = visitor.subexpression_cache[_id] return False, (_type, expr) else: + print("New Expression") return True, None @@ -217,6 +221,9 @@ def _handle_node_with_eval_expr_visitor_invariant(visitor, node, data): def _handle_node_with_eval_expr_visitor_unknown(visitor, node, *data): # ESJ: Is this cheating? expr_type = max(map(itemgetter(0), data)) + print("Handle unknown node") + print(node) + print(expr_type) return ( expr_type, visitor._eval_expr_visitor.visit(node, tuple(map(itemgetter(1), data))), @@ -237,6 +244,13 @@ def _handle_node_with_eval_expr_visitor_linear(visitor, node, *data): ) +def _handle_node_with_eval_expr_visitor_quadratic(visitor, node, *data): + return ( + _QUADRATIC, + visitor._eval_expr_visitor.visit(node, tuple(map(itemgetter(1), data))), + ) + + def _handle_node_with_eval_expr_visitor_nonlinear(visitor, node, *data): # ESJ: _apply_operation for DivisionExpression expects that result is indexed, so # I'm making it a tuple rather than a map. @@ -246,6 +260,30 @@ def _handle_node_with_eval_expr_visitor_nonlinear(visitor, node, *data): ) +def _handle_linear_constant_pow_expr(visitor, node, arg1, arg2): + print("handle linear constant pow: %s" % node) + expr_type = _GENERAL + if arg2[1] == 1: + expr_type = _LINEAR + if arg2[1] == 2: + print("It's quadratic") + expr_type = _QUADRATIC + return ( + expr_type, + visitor._eval_expr_visitor.visit(node, tuple(map(itemgetter(1), (arg1, arg2)))), + ) + + +def _handle_quadratic_constant_pow_expr(visitor, node, arg1, arg2): + expr_type = _GENERAL + if arg2[1] == 1: + expr_type = _QUADRATIC + return ( + expr_type, + visitor._eval_expr_visitor.visit(node, tuple(map(itemgetter(1), (arg1, arg2)))), + ) + + def _handle_unary(visitor, node, data): if node._name in _function_map: expr_type, fcn = _function_map[node._name] @@ -258,6 +296,7 @@ def _handle_unary(visitor, node, data): def _handle_named_expression(visitor, node, arg1): # Record this common expression + print("caching %s" % arg1[1]) visitor.subexpression_cache[id(node)] = arg1 _type, arg1 = arg1 return _type, arg1 @@ -302,9 +341,14 @@ def define_exit_node_handlers(_exit_node_handlers=None): None: _handle_node_with_eval_expr_visitor_nonlinear, (_CONSTANT, _CONSTANT): _handle_node_with_eval_expr_visitor_constant, (_CONSTANT, _LINEAR): _handle_node_with_eval_expr_visitor_linear, - (_LINEAR, _CONSTANT): _handle_node_with_eval_expr_visitor_linear, + (_CONSTANT, _QUADRATIC): _handle_node_with_eval_expr_visitor_quadratic, (_CONSTANT, _VARIABLE): _handle_node_with_eval_expr_visitor_linear, + (_LINEAR, _CONSTANT): _handle_node_with_eval_expr_visitor_linear, + (_LINEAR, _LINEAR): _handle_node_with_eval_expr_visitor_quadratic, + (_LINEAR, _VARIABLE): _handle_node_with_eval_expr_visitor_quadratic, (_VARIABLE, _CONSTANT): _handle_node_with_eval_expr_visitor_linear, + (_VARIABLE, _LINEAR): _handle_node_with_eval_expr_visitor_quadratic, + (_VARIABLE, _VARIABLE): _handle_node_with_eval_expr_visitor_quadratic, } _exit_node_handlers[MonomialTermExpression] = _exit_node_handlers[ProductExpression] _exit_node_handlers[DivisionExpression] = { @@ -312,10 +356,14 @@ def define_exit_node_handlers(_exit_node_handlers=None): (_CONSTANT, _CONSTANT): _handle_node_with_eval_expr_visitor_constant, (_LINEAR, _CONSTANT): _handle_node_with_eval_expr_visitor_linear, (_VARIABLE, _CONSTANT): _handle_node_with_eval_expr_visitor_linear, + (_QUADRATIC, _CONSTANT): _handle_node_with_eval_expr_visitor_quadratic, } _exit_node_handlers[PowExpression] = { None: _handle_node_with_eval_expr_visitor_nonlinear, (_CONSTANT, _CONSTANT): _handle_node_with_eval_expr_visitor_constant, + (_VARIABLE, _CONSTANT): _handle_linear_constant_pow_expr, + (_LINEAR, _CONSTANT): _handle_linear_constant_pow_expr, + (_QUADRATIC, _CONSTANT): _handle_quadratic_constant_pow_expr, } _exit_node_handlers[UnaryFunctionExpression] = {None: _handle_unary} @@ -362,6 +410,9 @@ def beforeChild(self, node, child, child_idx): return self.before_child_dispatcher[child.__class__](self, child) def exitNode(self, node, data): + print("EXIT NODE") + print(node) + print(self.exit_node_dispatcher[(node.__class__, *map(itemgetter(0), data))]) return self.exit_node_dispatcher[(node.__class__, *map(itemgetter(0), data))]( self, node, *data ) @@ -427,15 +478,21 @@ def _create_gurobi_expression( self, expr, src, src_index, grb_model, quadratic_visitor, grb_visitor ): """ - Uses the quadratic walker to determine if the expression is a general - nonlinear (non-quadratic) expression, and returns a gurobipy representation - of the expression + Returns a gurobipy representation of the expression """ - #repn = quadratic_visitor.walk_expression(expr) expr_type, grb_expr = grb_visitor.walk_expression(expr) + print(grb_expr) if expr_type is not _GENERAL: + print("not general: %s" % grb_expr) return grb_expr, False, None else: + #print("Is this the problem?") + # TODO: Yes, this is the problem. We should not be calling quadratics + # GENERAL first of all, because we don't want them to end up here. + # Second of all, for general nonlinear big E expressions, we should + # cache the auxiliary Var, I think. For other things, we should cache + # the gurobi expression itself. + print("general nonlinear") aux = grb_model.addVar() return grb_expr, True, aux diff --git a/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_writer.py b/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_writer.py index 8891e804422..1c339fa5cb7 100644 --- a/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_writer.py +++ b/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_writer.py @@ -268,7 +268,7 @@ def test_writer_ignores_deactivated_logical_constraints(self): self.assertEqual(obj.getCoeff(0), 1) self.assertIs(obj.getVar(0), x1) - def test_named_expressions(self): + def test_named_expression_quadratic(self): m = ConcreteModel() m.x = Var() m.y = Var() @@ -331,6 +331,66 @@ def test_named_expressions(self): obj = grb_model.getObjective() self.assertEqual(obj.size(), 0) + def test_named_expression_nonlinear(self): + m = ConcreteModel() + m.x = Var() + m.y = Var() + m.e = Expression(expr=log(m.x)**2 + m.y) + m.c = Constraint(expr=m.e <= 7) + m.c2 = Constraint(expr=m.e + m.y**3 + log(m.x + m.y) >= -3) + m.obj = Objective(expr=0) + + grb_model, var_map, obj = WriterFactory('gurobi_minlp').write( + m, symbolic_solver_labels=True + ) + + self.assertEqual(len(var_map), 2) + x = var_map[id(m.x)] + y = var_map[id(m.y)] + + self.assertEqual(grb_model.numVars, 4) + self.assertEqual(grb_model.numIntVars, 0) + self.assertEqual(grb_model.numBinVars, 0) + + lin_constrs = grb_model.getConstrs() + self.assertEqual(len(lin_constrs), 2) + quad_constrs = grb_model.getQConstrs() + self.assertEqual(len(quad_constrs), 0) + nonlinear_constrs = grb_model.getGenConstrs() + self.assertEqual(len(nonlinear_constrs), 2) + + # TODO: test the constraints + c1 = lin_constrs[0] + aux1 = grb_model.getRow(c1) + self.assertEqual(c1.RHS, 7) + self.assertEqual(c1.Sense, '<') + self.assertEqual(aux1.size(), 1) + self.assertEqual(aux1.getCoeff(0), 1) + self.assertEqual(aux1.getConstant(), 0) + aux1 = aux1.getVar(0) + + c2 = lin_constrs[1] + aux2 = grb_model.getRow(c2) + self.assertEqual(c2.RHS, -3) + self.assertEqual(c2.Sense, '>') + self.assertEqual(aux2.size(), 1) + self.assertEqual(aux2.getCoeff(0), 1) + self.assertEqual(aux2.getConstant(), 0) + aux2 = aux2.getVar(0) + + g1 = nonlinear_constrs[0] + aux_var, opcode, data, parent = grb_model.getGenConstrNLAdv(g1) + self.assertIs(aux_var, aux1) + from pytest import set_trace + set_trace() + + g2 = nonlinear_constrs[1] + + # objective + self.assertEqual(grb_model.ModelSense, 1) # minimizing + obj = grb_model.getObjective() + self.assertEqual(obj.size(), 0) + def test_solve_model(self): m = ConcreteModel() m.x = Var(bounds=(0, 1)) From 0271a1290e0015eb470e4200dbe05cd11789b857 Mon Sep 17 00:00:00 2001 From: Emma Johnson <12833636+emma58@users.noreply.github.com> Date: Wed, 6 Aug 2025 07:39:18 -0600 Subject: [PATCH 043/103] Removing a lot of debugging --- pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py | 7 ------- .../contrib/gurobi_minlp/tests/test_gurobi_minlp_writer.py | 1 + 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py b/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py index 3807aab1332..b75d2bc4c70 100755 --- a/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py +++ b/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py @@ -200,7 +200,6 @@ def _before_var(visitor, child): @staticmethod def _before_named_expression(visitor, child): _id = id(child) - print("before %s" % child.expr) if _id in visitor.subexpression_cache: print("found it--don't descend") _type, expr = visitor.subexpression_cache[_id] @@ -261,12 +260,10 @@ def _handle_node_with_eval_expr_visitor_nonlinear(visitor, node, *data): def _handle_linear_constant_pow_expr(visitor, node, arg1, arg2): - print("handle linear constant pow: %s" % node) expr_type = _GENERAL if arg2[1] == 1: expr_type = _LINEAR if arg2[1] == 2: - print("It's quadratic") expr_type = _QUADRATIC return ( expr_type, @@ -296,7 +293,6 @@ def _handle_unary(visitor, node, data): def _handle_named_expression(visitor, node, arg1): # Record this common expression - print("caching %s" % arg1[1]) visitor.subexpression_cache[id(node)] = arg1 _type, arg1 = arg1 return _type, arg1 @@ -481,9 +477,7 @@ def _create_gurobi_expression( Returns a gurobipy representation of the expression """ expr_type, grb_expr = grb_visitor.walk_expression(expr) - print(grb_expr) if expr_type is not _GENERAL: - print("not general: %s" % grb_expr) return grb_expr, False, None else: #print("Is this the problem?") @@ -492,7 +486,6 @@ def _create_gurobi_expression( # Second of all, for general nonlinear big E expressions, we should # cache the auxiliary Var, I think. For other things, we should cache # the gurobi expression itself. - print("general nonlinear") aux = grb_model.addVar() return grb_expr, True, aux diff --git a/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_writer.py b/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_writer.py index 1c339fa5cb7..5b068797541 100644 --- a/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_writer.py +++ b/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_writer.py @@ -381,6 +381,7 @@ def test_named_expression_nonlinear(self): g1 = nonlinear_constrs[0] aux_var, opcode, data, parent = grb_model.getGenConstrNLAdv(g1) self.assertIs(aux_var, aux1) + self.assertEqual(len(opcode), 6) from pytest import set_trace set_trace() From 051a598f37896abd0a5241a1a2d9dd7456254726 Mon Sep 17 00:00:00 2001 From: Emma Johnson <12833636+emma58@users.noreply.github.com> Date: Fri, 26 Sep 2025 15:44:04 -0600 Subject: [PATCH 044/103] Prevent 0 from being added to sums in the Gurobi expressions by not having the start of SumExpressions's apply_operation be at 0 --- pyomo/core/expr/numeric_expr.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/pyomo/core/expr/numeric_expr.py b/pyomo/core/expr/numeric_expr.py index dd3f8ae7df8..7887ed0e28d 100644 --- a/pyomo/core/expr/numeric_expr.py +++ b/pyomo/core/expr/numeric_expr.py @@ -1206,7 +1206,11 @@ def _trunc_extend(self, other): return self.__class__(_args) def _apply_operation(self, result): - return sum(result) + # Avoid 0 being added to summations by specifying the start + if result: + return sum(result[1:], start=result[0]) + else: + return 0 def _compute_polynomial_degree(self, result): # NB: We can't use max() here because None (non-polynomial) From 585d9e4ed0122e100c42a400319687189074040d Mon Sep 17 00:00:00 2001 From: Emma Johnson <12833636+emma58@users.noreply.github.com> Date: Fri, 26 Sep 2025 15:44:47 -0600 Subject: [PATCH 045/103] Adding converter from nonlinear Gurobi expression trees to pyomo expressions for easy of testing and debugging... --- .../tests/gurobi_to_pyomo_expressions.py | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 pyomo/contrib/gurobi_minlp/tests/gurobi_to_pyomo_expressions.py diff --git a/pyomo/contrib/gurobi_minlp/tests/gurobi_to_pyomo_expressions.py b/pyomo/contrib/gurobi_minlp/tests/gurobi_to_pyomo_expressions.py new file mode 100644 index 00000000000..32ae3f6c562 --- /dev/null +++ b/pyomo/contrib/gurobi_minlp/tests/gurobi_to_pyomo_expressions.py @@ -0,0 +1,65 @@ +from pyomo.common.dependencies import attempt_import +from pyomo.core.expr.numeric_expr import ( + SumExpression, + ProductExpression, + DivisionExpression, + PowExpression, + NegationExpression, + UnaryFunctionExpression, + sqrt, + exp, + log, + log10, + sin, + cos, + tan +) + +gurobipy, gurobipy_available = attempt_import('gurobipy', minimum_version='12.0.0') + +grb_op_to_pyo = {} +if gurobipy_available: + from gurobipy import GRB + + grb_op_to_pyo.update({ + GRB.OPCODE_PLUS: (SumExpression, ()), + #GRB.OPCODE_MINUS: , # This is sum of negated term for us + GRB.OPCODE_UMINUS: (NegationExpression, ()), + GRB.OPCODE_MULTIPLY: (ProductExpression, ()), # Their multiply is n-ary + GRB.OPCODE_DIVIDE: (DivisionExpression, ()), + #GRB.OPCODE_SQUARE: , # This is pow with a fixed second argument for us + GRB.OPCODE_SQRT: (UnaryFunctionExpression, ('sqrt', sqrt)), + GRB.OPCODE_EXP: (UnaryFunctionExpression, ('exp', exp)), + GRB.OPCODE_LOG: (UnaryFunctionExpression, ('log', log)), + GRB.OPCODE_LOG2: (UnaryFunctionExpression, ('log', log)), + GRB.OPCODE_LOG10: (UnaryFunctionExpression, ('log10', log10)), + GRB.OPCODE_POW: (PowExpression, ()), + GRB.OPCODE_SIN: (UnaryFunctionExpression, ('sin', sin)), + GRB.OPCODE_COS: (UnaryFunctionExpression, ('cos', cos)), + GRB.OPCODE_TAN: (UnaryFunctionExpression, ('tan', tan)), + #GRB.OPCODE_LOGISTIC: We don't have this one. + }) + +nary_ops = { SumExpression, } + +def grb_nl_to_pyo_expr(op, data, parent, var_map): + ans = [] + for i, (op, data, parent) in enumerate(zip(op, data, parent)): + if op in grb_op_to_pyo: + cls, args = grb_op_to_pyo[op] + ans.append(cls((), *args)) + elif op == GRB.OPCODE_VARIABLE: + ans.append(var_map[data]) + elif op == GRB.OPCODE_CONSTANT: + ans.append(data) + else: + raise RuntimeError( + f"The gurobi-to-pyomo expression converter encountered an unexpected " + f"(or unsupported) opcode: {op}" + ) + if i: + ans[parent]._args_ = ans[parent]._args_ + (ans[-1],) + if ans[parent].__class__ in nary_ops: + ans[parent]._nargs += 1 + + return ans[0] From a011fa3c680117eed22a44524cf3b6ce9771621e Mon Sep 17 00:00:00 2001 From: Emma Johnson <12833636+emma58@users.noreply.github.com> Date: Fri, 26 Sep 2025 16:44:37 -0600 Subject: [PATCH 046/103] Finishing the named expression test that inspired the whole conversion to pyomo expressions in the first place --- .../tests/test_gurobi_minlp_writer.py | 34 +++++++++++++++---- 1 file changed, 28 insertions(+), 6 deletions(-) diff --git a/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_writer.py b/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_writer.py index 5b068797541..0814ce11e62 100644 --- a/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_writer.py +++ b/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_writer.py @@ -11,6 +11,11 @@ import pyomo.common.unittest as unittest from pyomo.common.dependencies import attempt_import +from pyomo.contrib.gurobi_minlp.tests.gurobi_to_pyomo_expressions import ( + grb_nl_to_pyo_expr +) +from pyomo.core.expr.compare import assertExpressionsEqual +from pyomo.core.expr.numeric_expr import SumExpression from pyomo.environ import ( Binary, BooleanVar, @@ -347,6 +352,7 @@ def test_named_expression_nonlinear(self): self.assertEqual(len(var_map), 2) x = var_map[id(m.x)] y = var_map[id(m.y)] + reverse_var_map = {grbv : pyov for pyov, grbv in var_map.items()} self.assertEqual(grb_model.numVars, 4) self.assertEqual(grb_model.numIntVars, 0) @@ -359,7 +365,6 @@ def test_named_expression_nonlinear(self): nonlinear_constrs = grb_model.getGenConstrs() self.assertEqual(len(nonlinear_constrs), 2) - # TODO: test the constraints c1 = lin_constrs[0] aux1 = grb_model.getRow(c1) self.assertEqual(c1.RHS, 7) @@ -377,15 +382,32 @@ def test_named_expression_nonlinear(self): self.assertEqual(aux2.getCoeff(0), 1) self.assertEqual(aux2.getConstant(), 0) aux2 = aux2.getVar(0) - + + # log(x)**2 + y g1 = nonlinear_constrs[0] aux_var, opcode, data, parent = grb_model.getGenConstrNLAdv(g1) self.assertIs(aux_var, aux1) - self.assertEqual(len(opcode), 6) - from pytest import set_trace - set_trace() - + assertExpressionsEqual( + self, + grb_nl_to_pyo_expr(opcode, data, parent, reverse_var_map), + log(m.x)**2 + m.y + ) + + # log(x)**2 + y + y**3 + log(x + y) g2 = nonlinear_constrs[1] + aux_var, opcode, data, parent = grb_model.getGenConstrNLAdv(g2) + self.assertIs(aux_var, aux2) + pyo_expr = grb_nl_to_pyo_expr(opcode, data, parent, reverse_var_map) + assertExpressionsEqual( + self, + pyo_expr, + SumExpression( + (SumExpression(( + SumExpression((log(m.x)**2, m.y)), + m.y ** 3.0)), + log(SumExpression((m.x, m.y)))) + ) + ) # objective self.assertEqual(grb_model.ModelSense, 1) # minimizing From 70838ed847c0698b0aba930da02cdbd45fd5ccf4 Mon Sep 17 00:00:00 2001 From: Emma Johnson <12833636+emma58@users.noreply.github.com> Date: Fri, 26 Sep 2025 16:45:04 -0600 Subject: [PATCH 047/103] Removing a lot of debugging in the writer --- .../contrib/gurobi_minlp/repn/gurobi_direct_minlp.py | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py b/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py index b75d2bc4c70..eabb919e596 100755 --- a/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py +++ b/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py @@ -201,11 +201,9 @@ def _before_var(visitor, child): def _before_named_expression(visitor, child): _id = id(child) if _id in visitor.subexpression_cache: - print("found it--don't descend") _type, expr = visitor.subexpression_cache[_id] return False, (_type, expr) else: - print("New Expression") return True, None @@ -218,11 +216,9 @@ def _handle_node_with_eval_expr_visitor_invariant(visitor, node, data): def _handle_node_with_eval_expr_visitor_unknown(visitor, node, *data): - # ESJ: Is this cheating? + # The expression type is whatever the highest one of the incoming arguments + # was. expr_type = max(map(itemgetter(0), data)) - print("Handle unknown node") - print(node) - print(expr_type) return ( expr_type, visitor._eval_expr_visitor.visit(node, tuple(map(itemgetter(1), data))), @@ -406,9 +402,6 @@ def beforeChild(self, node, child, child_idx): return self.before_child_dispatcher[child.__class__](self, child) def exitNode(self, node, data): - print("EXIT NODE") - print(node) - print(self.exit_node_dispatcher[(node.__class__, *map(itemgetter(0), data))]) return self.exit_node_dispatcher[(node.__class__, *map(itemgetter(0), data))]( self, node, *data ) From e78473036e5a674e80b8282d8eb60571ce3bc96f Mon Sep 17 00:00:00 2001 From: Emma Johnson <12833636+emma58@users.noreply.github.com> Date: Fri, 26 Sep 2025 16:45:49 -0600 Subject: [PATCH 048/103] Implementing conversion from Gurobi square, fixing bug where SumExpression expects args to be a list --- .../tests/gurobi_to_pyomo_expressions.py | 22 ++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/pyomo/contrib/gurobi_minlp/tests/gurobi_to_pyomo_expressions.py b/pyomo/contrib/gurobi_minlp/tests/gurobi_to_pyomo_expressions.py index 32ae3f6c562..b28ffc7b4eb 100644 --- a/pyomo/contrib/gurobi_minlp/tests/gurobi_to_pyomo_expressions.py +++ b/pyomo/contrib/gurobi_minlp/tests/gurobi_to_pyomo_expressions.py @@ -27,7 +27,9 @@ GRB.OPCODE_UMINUS: (NegationExpression, ()), GRB.OPCODE_MULTIPLY: (ProductExpression, ()), # Their multiply is n-ary GRB.OPCODE_DIVIDE: (DivisionExpression, ()), - #GRB.OPCODE_SQUARE: , # This is pow with a fixed second argument for us + GRB.OPCODE_SQUARE: (PowExpression, ()), # This is pow with a + # fixed second + # argument for us GRB.OPCODE_SQRT: (UnaryFunctionExpression, ('sqrt', sqrt)), GRB.OPCODE_EXP: (UnaryFunctionExpression, ('exp', exp)), GRB.OPCODE_LOG: (UnaryFunctionExpression, ('log', log)), @@ -42,12 +44,16 @@ nary_ops = { SumExpression, } -def grb_nl_to_pyo_expr(op, data, parent, var_map): +def grb_nl_to_pyo_expr(opcode, data, parent, var_map): ans = [] - for i, (op, data, parent) in enumerate(zip(op, data, parent)): + for i, (op, data, parent) in enumerate(zip(opcode, data, parent)): if op in grb_op_to_pyo: cls, args = grb_op_to_pyo[op] - ans.append(cls((), *args)) + # SumExpression requires args to be a list (not a + # tuple). Since all other expression types are fine with + # whatever, we make it a list here to avoid (another) + # special case. + ans.append(cls([], *args)) elif op == GRB.OPCODE_VARIABLE: ans.append(var_map[data]) elif op == GRB.OPCODE_CONSTANT: @@ -58,8 +64,14 @@ def grb_nl_to_pyo_expr(op, data, parent, var_map): f"(or unsupported) opcode: {op}" ) if i: - ans[parent]._args_ = ans[parent]._args_ + (ans[-1],) + # there are two special cases here to account for minus and square + print(ans[parent].__class__) + print(ans[parent]._args_) + ans[parent]._args_ = ans[parent]._args_ + [ans[-1],] if ans[parent].__class__ in nary_ops: ans[parent]._nargs += 1 + if opcode[parent] == GRB.OPCODE_SQUARE: + # add the exponent + ans[parent]._args_ = ans[parent]._args_ + [2,] return ans[0] From bbb27e85f0cbdab10d1dbff3cd24014eacc7dd4c Mon Sep 17 00:00:00 2001 From: Emma Johnson <12833636+emma58@users.noreply.github.com> Date: Fri, 26 Sep 2025 16:59:41 -0600 Subject: [PATCH 049/103] Converting a division test to the prettier way of testing nonlinear expressions --- .../tests/test_gurobi_minlp_walker.py | 35 +++++++++---------- 1 file changed, 17 insertions(+), 18 deletions(-) diff --git a/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_walker.py b/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_walker.py index 05353cf176a..223ff5e1367 100644 --- a/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_walker.py +++ b/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_walker.py @@ -10,8 +10,12 @@ # ___________________________________________________________________________ from pyomo.common.dependencies import attempt_import +from pyomo.core.expr.compare import assertExpressionsEqual import pyomo.common.unittest as unittest from pyomo.contrib.gurobi_minlp.repn.gurobi_direct_minlp import GurobiMINLPVisitor +from pyomo.contrib.gurobi_minlp.tests.gurobi_to_pyomo_expressions import ( + grb_nl_to_pyo_expr +) from pyomo.environ import ( Binary, ConcreteModel, @@ -263,27 +267,18 @@ def test_write_division(self): visitor = self.get_visitor() _, expr = visitor.walk_expression(m.c.body) - x1 = visitor.var_map[id(m.x1)] + visitor.grb_model.update() + grb_to_pyo_var_map = {grb_var: py_var for py_var, grb_var + in visitor.var_map.items()} opcode, data, parent = self._get_nl_expr_tree(visitor, expr) - # three nodes - self.assertEqual(len(opcode), 3) - # the root is a division expression - self.assertEqual(parent[0], -1) # root - self.assertEqual(opcode[0], GRB.OPCODE_DIVIDE) - # divide has no additional data - self.assertEqual(data[0], -1) - - # first arg is 1 - self.assertEqual(parent[1], 0) - self.assertEqual(opcode[1], GRB.OPCODE_CONSTANT) - self.assertEqual(data[1], 1) - - # second arg is x1 - self.assertEqual(parent[2], 0) - self.assertEqual(opcode[2], GRB.OPCODE_VARIABLE) - self.assertIs(data[2], x1) + pyo_expr = grb_nl_to_pyo_expr(opcode, data, parent, grb_to_pyo_var_map) + assertExpressionsEqual( + self, + pyo_expr, + 1.0 / m.x1 + ) def test_write_division_linear(self): m = self.get_model() @@ -593,6 +588,10 @@ def test_handle_complex_number_sqrt(self): visitor = self.get_visitor() _, expr = visitor.walk_expression(m.c.body) + # TODO: What does gurobi do with this? If nothing good, then I + # need to do something different for constants so that I can + # catch this. + # TODO: What did I want to happen here? We should probably catch # this in the writer? self.assertTrue(False) From 5157c0d5d5192029ce168b48b8893f5135233f5d Mon Sep 17 00:00:00 2001 From: Emma Johnson <12833636+emma58@users.noreply.github.com> Date: Fri, 26 Sep 2025 21:21:18 -0600 Subject: [PATCH 050/103] Raising error for invalid numbers since I think no good can come from passing them through to Gurobi --- .../gurobi_minlp/repn/gurobi_direct_minlp.py | 24 ++++++++++---- .../tests/test_gurobi_minlp_walker.py | 32 ++++++++++++------- 2 files changed, 38 insertions(+), 18 deletions(-) diff --git a/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py b/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py index eabb919e596..99a4efdee85 100755 --- a/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py +++ b/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py @@ -25,6 +25,7 @@ from pyomo.common.dependencies import attempt_import from pyomo.common.collections import ComponentMap, ComponentSet from pyomo.common.config import ConfigDict, ConfigValue +from pyomo.common.errors import InvalidValueError from pyomo.common.numeric_types import native_complex_types from pyomo.common.timing import HierarchicalTimer @@ -85,6 +86,8 @@ OrderedVarRecorder, ) +import sys + ## DEBUG from pytest import set_trace @@ -287,6 +290,16 @@ def _handle_unary(visitor, node, data): ) +def _handle_unary_constant(visitor, node, data): + try: + return _CONSTANT, node._fcn(value(data[1])) + except: + raise InvalidValueError( + f"Invalid number encountered evaluating constant unary expression " + f"{node}: {sys.exc_info()[1]}" + ) + + def _handle_named_expression(visitor, node, arg1): # Record this common expression visitor.subexpression_cache[id(node)] = arg1 @@ -357,7 +370,10 @@ def define_exit_node_handlers(_exit_node_handlers=None): (_LINEAR, _CONSTANT): _handle_linear_constant_pow_expr, (_QUADRATIC, _CONSTANT): _handle_quadratic_constant_pow_expr, } - _exit_node_handlers[UnaryFunctionExpression] = {None: _handle_unary} + _exit_node_handlers[UnaryFunctionExpression] = { + None: _handle_unary, + (_CONSTANT,): _handle_unary_constant, + } ## TODO: ExprIf, RangedExpressions (if we do exprif... _exit_node_handlers[Expression] = {None: _handle_named_expression} @@ -473,12 +489,6 @@ def _create_gurobi_expression( if expr_type is not _GENERAL: return grb_expr, False, None else: - #print("Is this the problem?") - # TODO: Yes, this is the problem. We should not be calling quadratics - # GENERAL first of all, because we don't want them to end up here. - # Second of all, for general nonlinear big E expressions, we should - # cache the auxiliary Var, I think. For other things, we should cache - # the gurobi expression itself. aux = grb_model.addVar() return grb_expr, True, aux diff --git a/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_walker.py b/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_walker.py index 223ff5e1367..882173adf82 100644 --- a/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_walker.py +++ b/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_walker.py @@ -11,6 +11,7 @@ from pyomo.common.dependencies import attempt_import from pyomo.core.expr.compare import assertExpressionsEqual +from pyomo.common.errors import InvalidValueError import pyomo.common.unittest as unittest from pyomo.contrib.gurobi_minlp.repn.gurobi_direct_minlp import GurobiMINLPVisitor from pyomo.contrib.gurobi_minlp.tests.gurobi_to_pyomo_expressions import ( @@ -579,19 +580,28 @@ def test_log_expression(self): self.assertIs(data[1], x1) self.assertEqual(opcode[1], GRB.OPCODE_VARIABLE) - # TODO: what other unary expressions? - def test_handle_complex_number_sqrt(self): m = self.get_model() m.p = Param(initialize=3, mutable=True) m.c = Constraint(expr=sqrt(-m.p) + m.x1 >= 3) + visitor = self.get_visitor() - _, expr = visitor.walk_expression(m.c.body) - - # TODO: What does gurobi do with this? If nothing good, then I - # need to do something different for constants so that I can - # catch this. - - # TODO: What did I want to happen here? We should probably catch - # this in the writer? - self.assertTrue(False) + with self.assertRaisesRegex( + InvalidValueError, + r"Invalid number encountered evaluating constant unary expression " + r"sqrt\(- p\): math domain error" + ): + _, expr = visitor.walk_expression(m.c.body) + + def test_handle_invalid_log(self): + m = self.get_model() + m.p = Param(initialize=0, mutable=True) + m.c = Constraint(expr=log(m.p) + m.x1 >= 3) + + visitor = self.get_visitor() + with self.assertRaisesRegex( + InvalidValueError, + r"Invalid number encountered evaluating constant unary expression " + r"log\(p\): math domain error" + ): + _, expr = visitor.walk_expression(m.c.body) From 6a78ee5c7443d81fa0fcf8e10d0f17463d293eee Mon Sep 17 00:00:00 2001 From: Emma Johnson <12833636+emma58@users.noreply.github.com> Date: Fri, 26 Sep 2025 22:10:30 -0600 Subject: [PATCH 051/103] Fixing the numpy boolean problem, but exposing a problem with expression types passed up from the walker --- .../gurobi_minlp/repn/gurobi_direct_minlp.py | 31 ++++++++++----- .../tests/test_gurobi_minlp_writer.py | 38 ++++++++++++++++--- 2 files changed, 55 insertions(+), 14 deletions(-) diff --git a/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py b/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py index 99a4efdee85..8e301b9b039 100755 --- a/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py +++ b/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py @@ -134,8 +134,8 @@ 'cos': (_GENERAL, nlfunc.cos), 'tan': (_GENERAL, nlfunc.tan), 'sqrt': (_GENERAL, nlfunc.sqrt), - # TODO: We'll have to do functional programming things if we want to support - # any of these... + # Not supporting any of these right now--we'd have to build them from the + # above: # 'asin': None, # 'sinh': None, # 'asinh': None, @@ -487,10 +487,10 @@ def _create_gurobi_expression( """ expr_type, grb_expr = grb_visitor.walk_expression(expr) if expr_type is not _GENERAL: - return grb_expr, False, None + return expr_type, grb_expr, False, None else: aux = grb_model.addVar() - return grb_expr, True, aux + return expr_type, grb_expr, True, aux def write(self, model, **options): config = options.pop('config', self.config)(options) @@ -553,7 +553,7 @@ def write(self, model, **options): sense = GRB.MINIMIZE else: sense = GRB.MAXIMIZE - obj_expr, nonlinear, aux = self._create_gurobi_expression( + expr_type, obj_expr, nonlinear, aux = self._create_gurobi_expression( obj.expr, obj, 0, grb_model, quadratic_visitor, visitor ) if nonlinear: @@ -570,19 +570,32 @@ def write(self, model, **options): # write constraints for cons in components[Constraint]: - expr, nonlinear, aux = self._create_gurobi_expression( + expr_type, expr, nonlinear, aux = self._create_gurobi_expression( cons.body, cons, 0, grb_model, quadratic_visitor, visitor ) if nonlinear: grb_model.addConstr(aux == expr) expr = aux + print(expr_type) + lb = value(cons.lb) + ub = value(cons.ub) + if expr_type == _CONSTANT: + # cast everything to a float in case there are numpy + # types because you can't do addConstr(np.True_) + print("trivial constraint") + expr = float(expr) + lb = float(lb) + ub = float(ub) if cons.equality: - grb_model.addConstr(value(cons.lower) == expr) + print(type(lb == expr)) + print(type(lb)) + print(type(expr)) + grb_model.addConstr(lb == expr) else: if cons.lb is not None: - grb_model.addConstr(value(cons.lb) <= expr) + grb_model.addConstr(lb <= expr) if cons.ub is not None: - grb_model.addConstr(value(cons.ub) >= expr) + grb_model.addConstr(ub >= expr) grb_model.update() return grb_model, visitor.var_map, pyo_obj diff --git a/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_writer.py b/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_writer.py index 0814ce11e62..cc0d7d77d3f 100644 --- a/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_writer.py +++ b/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_writer.py @@ -11,6 +11,8 @@ import pyomo.common.unittest as unittest from pyomo.common.dependencies import attempt_import +from pyomo.common.dependencies import numpy as np, numpy_available + from pyomo.contrib.gurobi_minlp.tests.gurobi_to_pyomo_expressions import ( grb_nl_to_pyo_expr ) @@ -514,10 +516,9 @@ def test_unbounded_because_of_multplying_by_0(self): results = opt.solve(m) # model is unbounded self.assertEqual(results.termination_condition, TerminationCondition.unbounded) - - def test_soren_example2(self): - import numpy as np + @unittest.skipUnless(numpy_available, "Numpy is not available") + def test_numpy_trivially_true_constraint(self): m = ConcreteModel() m.x1 = Var() m.x2 = Var() @@ -525,9 +526,36 @@ def test_soren_example2(self): m.x2.fix(np.float64(0)) m.c = Constraint(expr=m.x1 == m.x2) m.obj = Objective(expr=m.x1) - m.pprint() results = SolverFactory('gurobi_direct_minlp').solve(m) - results.display() + + self.assertEqual(results.termination_condition, + TerminationCondition.convergenceCriteriaSatisfied) + self.assertEqual(value(m.obj), 0) + self.assertEqual(results.incumbent_objective, 0) + self.assertEqual(results.objective_bound, 0) + + # ESJ TODO: There's still a bug here, but it's with the + # expression type I'm passing through, not with the numpy + # situation now. + + def test_trivally_true_constraint(self): + """ + We can pass trivially true things to Gurobi and it's fine + """ + m = ConcreteModel() + m.x1 = Var() + m.x2 = Var() + m.x1.fix(2) + m.x2.fix(2) + m.c = Constraint(expr=m.x1 <= m.x2) + m.obj = Objective(expr=m.x1) + results = SolverFactory('gurobi_direct_minlp').solve(m, tee=True) + + self.assertEqual(results.termination_condition, + TerminationCondition.convergenceCriteriaSatisfied) + self.assertEqual(value(m.obj), 2) + self.assertEqual(results.incumbent_objective, 2) + self.assertEqual(results.objective_bound, 2) # ESJ: Note: It appears they don't allow x1 ** x2...? Well, they wait and give the From 12848b7a1d800e3ce10ba341149489532e2cef66 Mon Sep 17 00:00:00 2001 From: Ronald van der Velden Date: Mon, 29 Sep 2025 17:30:51 +0200 Subject: [PATCH 052/103] Initial support for MINLP, with tests --- .../solvers/plugins/solvers/gurobi_direct.py | 82 +++++++- pyomo/solvers/tests/test_gurobi_minlp.py | 197 ++++++++++++++++++ 2 files changed, 278 insertions(+), 1 deletion(-) create mode 100644 pyomo/solvers/tests/test_gurobi_minlp.py diff --git a/pyomo/solvers/plugins/solvers/gurobi_direct.py b/pyomo/solvers/plugins/solvers/gurobi_direct.py index 6d8a9137aa2..1c428ae7836 100644 --- a/pyomo/solvers/plugins/solvers/gurobi_direct.py +++ b/pyomo/solvers/plugins/solvers/gurobi_direct.py @@ -13,6 +13,7 @@ import re import sys +from gurobipy import GRB, LinExpr, NLExpr, nlfunc from pyomo.common.collections import ComponentSet, ComponentMap, Bunch from pyomo.common.dependencies import attempt_import from pyomo.common.errors import ApplicationError @@ -33,6 +34,8 @@ from pyomo.opt.base import SolverFactory from pyomo.core.base.suffix import Suffix +from pyomo.core.expr.numeric_expr import DivisionExpression, PowExpression, ProductExpression, NumericExpression, UnaryFunctionExpression, SumExpression + logger = logging.getLogger('pyomo.solvers') @@ -40,6 +43,9 @@ class DegreeError(ValueError): pass +class NonLinearError(ValueError): + pass + def _is_numeric(x): try: @@ -280,12 +286,14 @@ def _get_expr_from_pyomo_repn(self, repn, max_degree=2): referenced_vars = ComponentSet() degree = repn.polynomial_degree() + """ if (degree is None) or (degree > max_degree): raise DegreeError( 'GurobiDirect does not support expressions of degree {0}.'.format( degree ) ) + """ if len(repn.linear_vars) > 0: referenced_vars.update(repn.linear_vars) @@ -308,8 +316,70 @@ def _get_expr_from_pyomo_repn(self, repn, max_degree=2): new_expr += repn.constant + if repn.nonlinear_expr is not None: + nlexpr = repn.nonlinear_expr + nl_grb_expr, nl_ref_vars = self._get_nlexpr_from_pyomo_expr(nlexpr) + for var in nl_ref_vars: + referenced_vars.add(var) + new_expr += nl_grb_expr + return new_expr, referenced_vars, degree + def _get_nlexpr_from_pyomo_expr(self, nlexpr): + referenced_vars = ComponentSet() + grb_args = [] + if isinstance(nlexpr, NumericExpression): + for arg in nlexpr.args: + grb_arg, arg_vars, _ = self._get_expr_from_pyomo_expr(arg) + grb_args.append(grb_arg) + for var in arg_vars: + referenced_vars.add(var) + + if type(nlexpr) is ProductExpression: + result = 1 + for g in grb_args: + result = result * g + return result, referenced_vars + + if type(nlexpr) is DivisionExpression: + if len(grb_args) != 2: + raise NonLinearError(f'Unexpected argument count for division') + result = grb_args[0] / grb_args[1] + return result, referenced_vars + + if type(nlexpr) is SumExpression: + result = LinExpr() + for g in grb_args: + result += g + return result, referenced_vars + + if type(nlexpr) is PowExpression: + if len(grb_args) != 2: + raise NonLinearError(f'Unexpected argument count for power function') + return grb_args[0] ** grb_args[1], referenced_vars + + if type(nlexpr) is UnaryFunctionExpression: + if len(grb_args) != 1: + raise NonLinearError(f'Unexpected argument count for unary function') + if nlexpr.name == 'exp': + return nlfunc.exp(grb_args[0]), referenced_vars + if nlexpr.name == 'sin': + return nlfunc.sin(grb_args[0]), referenced_vars + if nlexpr.name == 'cos': + return nlfunc.cos(grb_args[0]), referenced_vars + if nlexpr.name == 'sqrt': + return nlfunc.sqrt(grb_args[0]), referenced_vars + if nlexpr.name == 'tan': + return nlfunc.tan(grb_args[0]), referenced_vars + if nlexpr.name == 'log': + return nlfunc.log(grb_args[0]), referenced_vars + if nlexpr.name == 'log10': + return nlfunc.log10(grb_args[0]), referenced_vars + + + raise NonLinearError(f'Unsupported nonlinear feature {nlexpr} ({nlexpr.name})') + + def _get_expr_from_pyomo_expr(self, expr, max_degree=2): if max_degree == 2: repn = generate_standard_repn(expr, quadratic=True) @@ -494,6 +564,11 @@ def _add_block(self, block): def _addConstr(self, degree, lhs, sense=None, rhs=None, name=""): if degree == 2: con = self._solver_model.addQConstr(lhs, sense, rhs, name) + elif type(lhs) is NLExpr: + sm = self._solver_model + v = sm.addVar(lb=-GRB.INFINITY, ub=GRB.INFINITY) + sm.addConstr(v == lhs) + con = self._solver_model.addQConstr(v, sense, rhs, name) else: con = self._solver_model.addLConstr(lhs, sense, rhs, name) return con @@ -648,6 +723,11 @@ def _set_objective(self, obj): obj.expr, self._max_obj_degree ) + if isinstance(gurobi_expr, NLExpr): + grb_obj_var = self._solver_model.addVar(lb=-GRB.INFINITY) + self._solver_model.addConstr(grb_obj_var == gurobi_expr) + gurobi_expr = grb_obj_var + for var in referenced_vars: self._referenced_variables[var] += 1 @@ -1152,4 +1232,4 @@ def load_slacks(self, cons_to_load=None): def _update(self): self._solver_model.update() - self._needs_updated = False + self._needs_updated = False \ No newline at end of file diff --git a/pyomo/solvers/tests/test_gurobi_minlp.py b/pyomo/solvers/tests/test_gurobi_minlp.py new file mode 100644 index 00000000000..e922f496e16 --- /dev/null +++ b/pyomo/solvers/tests/test_gurobi_minlp.py @@ -0,0 +1,197 @@ +from math import pi +import pyomo.common.unittest as unittest + +from pyomo.core.base.constraint import Constraint +from pyomo.environ import * + +gurobi_direct = SolverFactory('gurobi_direct') + +class TestGurobiMINLP(unittest.TestCase): + @unittest.skipUnless( + gurobi_direct.available(exception_flag=False) and gurobi_direct.license_is_valid(), + "needs Gurobi Direct interface", + ) + def test_gurobi_minlp_sincosexp(self): + m = ConcreteModel(name="test") + m.x = Var(bounds=(-1, 4)) + m.o = Objective(expr=sin(m.x) + cos(2*m.x) + 1) + m.c = Constraint(expr=0.25 * exp(m.x) - m.x <= 0) + gurobi_direct.solve(m) + self.assertAlmostEqual(1, m.o(), delta=1e-3) + self.assertAlmostEqual(1.571, m.x(), delta=1e-3) + + @unittest.skipUnless( + gurobi_direct.available(exception_flag=False) and gurobi_direct.license_is_valid(), + "needs Gurobi Direct interface", + ) + def test_gurobi_minlp_tan(self): + m = ConcreteModel(name="test") + m.x = Var(bounds=(0, pi/2)) + m.o = Objective(expr=tan(m.x)/(m.x**2)) + gurobi_direct.solve(m) + self.assertAlmostEqual(0.948, m.x(), delta=1e-3) + self.assertAlmostEqual(1.549, m.o(), delta=1e-3) + + @unittest.skipUnless( + gurobi_direct.available(exception_flag=False) and gurobi_direct.license_is_valid(), + "needs Gurobi Direct interface", + ) + def test_gurobi_minlp_sqrt(self): + m = ConcreteModel(name="test") + m.x = Var(bounds=(0, 2)) + m.o = Objective(expr=sqrt(m.x)-(m.x**2)/3, sense=maximize) + gurobi_direct.solve(m) + self.assertAlmostEqual(0.825, m.x(), delta=1e-3) + self.assertAlmostEqual(0.681, m.o(), delta=1e-3) + + @unittest.skipUnless( + gurobi_direct.available(exception_flag=False) and gurobi_direct.license_is_valid(), + "needs Gurobi Direct interface", + ) + def test_gurobi_minlp_log(self): + m = ConcreteModel(name="test") + m.x = Var(bounds=(1, 2)) + m.o = Objective(expr=(m.x*m.x)/log(m.x)) + gurobi_direct.solve(m) + self.assertAlmostEqual(1.396, m.x(), delta=1e-3) + self.assertAlmostEqual(8.155, m.o(), delta=1e-3) + + @unittest.skipUnless( + gurobi_direct.available(exception_flag=False) and gurobi_direct.license_is_valid(), + "needs Gurobi Direct interface", + ) + def test_gurobi_minlp_log10(self): + m = ConcreteModel(name="test") + m.x = Var(bounds=(1, 2)) + m.o = Objective(expr=(m.x*m.x)/log10(m.x)) + gurobi_direct.solve(m) + self.assertAlmostEqual(1.649, m.x(), delta=1e-3) + self.assertAlmostEqual(12.518, m.o(), delta=1e-3) + + @unittest.skipUnless( + gurobi_direct.available(exception_flag=False) and gurobi_direct.license_is_valid(), + "needs Gurobi Direct interface", + ) + def test_gurobi_minlp_sigmoid(self): + m = ConcreteModel(name="test") + m.x = Var(bounds=(0, 4)) + m.y = Var(bounds=(0, 4)) + m.o = Objective(expr=m.y-m.x) + m.c = Constraint(expr=1/(1+exp(-m.x)) <= m.y) + gurobi_direct.solve(m, tee=True, options={'logfile':'gurobi.log'}) + self.assertAlmostEqual(m.o(), -3.017, delta=1e-3) + + def _build_divpwr_model(self, divide: bool, min: bool): + model = ConcreteModel(name="test") + model.x1 = Var(domain=NonNegativeReals, bounds=(0.5, 0.6)) + model.x2 = Var(domain=NonNegativeReals, bounds=(0.1, 0.2)) + model.y = Var(domain=Boolean, initialize=1) + + y_mult = 1.3 + if divide: + obj = (1 - model.y) / model.x1 + model.y * y_mult / model.x2 + else: + obj = (1 - model.y) * (model.x1 ** -1) + model.y * y_mult * (model.x2 ** -1) + + if min: + model.OBJ = Objective(expr = -1 * obj, sense=minimize) + else: + model.OBJ = Objective(expr = obj, sense=maximize) + + return model + + @unittest.skipUnless( + gurobi_direct.available(exception_flag=False) and gurobi_direct.license_is_valid(), + "needs Gurobi Direct interface", + ) + def test_gurobi_minlp_divpwr(self): + params = [ + {"min": False, "divide": False, "obj": 13 }, + {"min": False, "divide": True, "obj": 13 }, + {"min": True, "divide": False, "obj": -13}, + {"min": True, "divide": True, "obj": -13}, + ] + for p in params: + model = self._build_divpwr_model(p['divide'], p['min']) + gurobi_direct.solve(model) + self.assertEqual(p["obj"], model.OBJ()) + self.assertEqual(1, model.y.value) + + @unittest.skipUnless( + gurobi_direct.available(exception_flag=False) and gurobi_direct.license_is_valid(), + "needs Gurobi Direct interface", + ) + def test_gurobi_minlp_acopf(self): + # Based on https://docs.gurobi.com/projects/examples/en/current/examples/python/acopf_4buses.html + + # Number of Buses (Nodes) + N = 4 + + # Conductance/susceptance components + G = [ + [1.7647, -0.5882, 0.0, -1.1765], + [-0.5882, 1.5611, -0.3846, -0.5882], + [0.0, -0.3846, 1.5611, -1.1765], + [-1.1765, -0.5882, -1.1765, 2.9412], + ] + B = [ + [-7.0588, 2.3529, 0.0, 4.7059], + [2.3529, -6.629, 1.9231, 2.3529], + [0.0, 1.9231, -6.629, 4.7059], + [4.7059, 2.3529, 4.7059, -11.7647], + ] + + # Assign bounds where fixings are needed + v_lb = [1.0, 0.0, 1.0, 0.0] + v_ub = [1.0, 1.5, 1.0, 1.5] + P_lb = [-3.0, -0.3, 0.3, -0.2] + P_ub = [3.0, -0.3, 0.3, -0.2] + Q_lb = [-3.0, -0.2, -3.0, -0.15] + Q_ub = [3.0, -0.2, 3.0, -0.15] + theta_lb = [0.0, -pi / 2, -pi / 2, -pi / 2] + theta_ub = [0.0, pi / 2, pi / 2, pi / 2] + + exp_v = [1.0, 0.949, 1.0, 0.973] + exp_theta = [0.0, -2.176, 1.046, -0.768] + exp_P = [0.2083, -0.3, 0.3, -0.2] + exp_Q = [0.212, -0.2, 0.173, -0.15] + + m = ConcreteModel(name="acopf") + + m.P = VarList() + m.Q = VarList() + m.v = VarList() + m.theta = VarList() + + for i in range(N): + p = m.P.add() + p.lb = P_lb[i] + p.ub = P_ub[i] + + q = m.Q.add() + q.lb = Q_lb[i] + q.ub = Q_ub[i] + + v = m.v.add() + v.lb = v_lb[i] + v.ub = v_ub[i] + + theta = m.theta.add() + theta.lb = theta_lb[i] + theta.ub = theta_ub[i] + + m.obj = Objective(expr=m.Q[1] + m.Q[3]) + + m.define_P = ConstraintList() + m.define_Q = ConstraintList() + for i in range(N): + m.define_P.add(m.P[i+1] == m.v[i+1] * sum(m.v[j+1] * (G[i][j] * cos(m.theta[i+1] - m.theta[j+1]) + B[i][j] * sin(m.theta[i+1] - m.theta[j+1])) for j in range(N))) + m.define_Q.add(m.Q[i+1] == m.v[i+1] * sum(m.v[j+1] * (G[i][j] * sin(m.theta[i+1] - m.theta[j+1]) - B[i][j] * cos(m.theta[i+1] - m.theta[j+1])) for j in range(N))) + + results = gurobi_direct.solve(m, tee=True) + self.assertEqual(SolverStatus.ok, results.solver.status) + for i in range(N): + self.assertAlmostEqual(exp_P[i], m.P[i+1].value, delta=1e-3, msg=f'P[{i}]') + self.assertAlmostEqual(exp_Q[i], m.Q[i+1].value, delta=1e-3, msg=f'Q[{i}]') + self.assertAlmostEqual(exp_v[i], m.v[i+1].value, delta=1e-3, msg=f'v[{i}]') + self.assertAlmostEqual(exp_theta[i], m.theta[i+1].value * 180 / pi, delta=1e-3, msg=f'theta[{i}]') \ No newline at end of file From d4e939eb757eafd1b5762d6a8cf827eea3d27438 Mon Sep 17 00:00:00 2001 From: Emma Johnson <12833636+emma58@users.noreply.github.com> Date: Mon, 29 Sep 2025 13:36:38 -0600 Subject: [PATCH 053/103] Fixing a bug where we labeled constant LinearExpressions as linear rather than constant --- .../gurobi_minlp/repn/gurobi_direct_minlp.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py b/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py index 8e301b9b039..1c6308d809c 100755 --- a/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py +++ b/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py @@ -337,7 +337,8 @@ def define_exit_node_handlers(_exit_node_handlers=None): None: _handle_node_with_eval_expr_visitor_unknown } _exit_node_handlers[LinearExpression] = { - None: _handle_node_with_eval_expr_visitor_linear + # Can come back LINEAR or CONSTANT, so we use the 'unknown' version + None: _handle_node_with_eval_expr_visitor_unknown } _exit_node_handlers[NegationExpression] = { None: _handle_node_with_eval_expr_visitor_invariant @@ -411,19 +412,19 @@ def initializeWalker(self, expr): return True, expr def beforeChild(self, node, child, child_idx): - # # Return native types - # if child.__class__ in EXPR.native_types: - # return False, child - return self.before_child_dispatcher[child.__class__](self, child) def exitNode(self, node, data): + print("EXIT") + print(node) + print(node.__class__) + print(data) + print("==========") return self.exit_node_dispatcher[(node.__class__, *map(itemgetter(0), data))]( self, node, *data ) def finalizeResult(self, result): - #self.grb_model.update() return result # ESJ TODO: THIS IS COPIED FROM THE LINEAR WALKER--CAN WE PUT IT IN UTIL OR @@ -584,8 +585,10 @@ def write(self, model, **options): # types because you can't do addConstr(np.True_) print("trivial constraint") expr = float(expr) - lb = float(lb) - ub = float(ub) + if lb is not None: + lb = float(lb) + if ub is not None: + ub = float(ub) if cons.equality: print(type(lb == expr)) print(type(lb)) From 87d276245f1440f805d219cf09efdbd9b37d7f02 Mon Sep 17 00:00:00 2001 From: Emma Johnson <12833636+emma58@users.noreply.github.com> Date: Mon, 29 Sep 2025 13:37:36 -0600 Subject: [PATCH 054/103] Removing debugging --- pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py b/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py index 1c6308d809c..2b68f15373a 100755 --- a/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py +++ b/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py @@ -415,11 +415,6 @@ def beforeChild(self, node, child, child_idx): return self.before_child_dispatcher[child.__class__](self, child) def exitNode(self, node, data): - print("EXIT") - print(node) - print(node.__class__) - print(data) - print("==========") return self.exit_node_dispatcher[(node.__class__, *map(itemgetter(0), data))]( self, node, *data ) @@ -577,22 +572,17 @@ def write(self, model, **options): if nonlinear: grb_model.addConstr(aux == expr) expr = aux - print(expr_type) lb = value(cons.lb) ub = value(cons.ub) if expr_type == _CONSTANT: # cast everything to a float in case there are numpy # types because you can't do addConstr(np.True_) - print("trivial constraint") expr = float(expr) if lb is not None: lb = float(lb) if ub is not None: ub = float(ub) if cons.equality: - print(type(lb == expr)) - print(type(lb)) - print(type(expr)) grb_model.addConstr(lb == expr) else: if cons.lb is not None: From 3afba1d59e85209c46158e8b43cf431b91454291 Mon Sep 17 00:00:00 2001 From: Emma Johnson <12833636+emma58@users.noreply.github.com> Date: Mon, 29 Sep 2025 13:40:29 -0600 Subject: [PATCH 055/103] NFC: Black has many complaints --- .../gurobi_minlp/repn/gurobi_direct_minlp.py | 2 +- .../tests/gurobi_to_pyomo_expressions.py | 51 ++++++++++--------- .../tests/test_gurobi_minlp_walker.py | 29 +++++------ .../tests/test_gurobi_minlp_writer.py | 36 +++++++------ 4 files changed, 61 insertions(+), 57 deletions(-) diff --git a/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py b/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py index 2b68f15373a..668151ce999 100755 --- a/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py +++ b/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py @@ -461,7 +461,7 @@ def check_constant(self, ans, obj): 'gurobi_minlp', 'Direct interface to Gurobi that allows for general nonlinear expressions', ) -class GurobiMINLPWriter(): +class GurobiMINLPWriter: CONFIG = ConfigDict('gurobi_minlp_writer') CONFIG.declare( 'symbolic_solver_labels', diff --git a/pyomo/contrib/gurobi_minlp/tests/gurobi_to_pyomo_expressions.py b/pyomo/contrib/gurobi_minlp/tests/gurobi_to_pyomo_expressions.py index b28ffc7b4eb..a0c8eb0d336 100644 --- a/pyomo/contrib/gurobi_minlp/tests/gurobi_to_pyomo_expressions.py +++ b/pyomo/contrib/gurobi_minlp/tests/gurobi_to_pyomo_expressions.py @@ -12,7 +12,7 @@ log10, sin, cos, - tan + tan, ) gurobipy, gurobipy_available = attempt_import('gurobipy', minimum_version='12.0.0') @@ -21,28 +21,31 @@ if gurobipy_available: from gurobipy import GRB - grb_op_to_pyo.update({ - GRB.OPCODE_PLUS: (SumExpression, ()), - #GRB.OPCODE_MINUS: , # This is sum of negated term for us - GRB.OPCODE_UMINUS: (NegationExpression, ()), - GRB.OPCODE_MULTIPLY: (ProductExpression, ()), # Their multiply is n-ary - GRB.OPCODE_DIVIDE: (DivisionExpression, ()), - GRB.OPCODE_SQUARE: (PowExpression, ()), # This is pow with a - # fixed second - # argument for us - GRB.OPCODE_SQRT: (UnaryFunctionExpression, ('sqrt', sqrt)), - GRB.OPCODE_EXP: (UnaryFunctionExpression, ('exp', exp)), - GRB.OPCODE_LOG: (UnaryFunctionExpression, ('log', log)), - GRB.OPCODE_LOG2: (UnaryFunctionExpression, ('log', log)), - GRB.OPCODE_LOG10: (UnaryFunctionExpression, ('log10', log10)), - GRB.OPCODE_POW: (PowExpression, ()), - GRB.OPCODE_SIN: (UnaryFunctionExpression, ('sin', sin)), - GRB.OPCODE_COS: (UnaryFunctionExpression, ('cos', cos)), - GRB.OPCODE_TAN: (UnaryFunctionExpression, ('tan', tan)), - #GRB.OPCODE_LOGISTIC: We don't have this one. - }) + grb_op_to_pyo.update( + { + GRB.OPCODE_PLUS: (SumExpression, ()), + # GRB.OPCODE_MINUS: , # This is sum of negated term for us + GRB.OPCODE_UMINUS: (NegationExpression, ()), + GRB.OPCODE_MULTIPLY: (ProductExpression, ()), # Their multiply is n-ary + GRB.OPCODE_DIVIDE: (DivisionExpression, ()), + GRB.OPCODE_SQUARE: (PowExpression, ()), # This is pow with a + # fixed second + # argument for us + GRB.OPCODE_SQRT: (UnaryFunctionExpression, ('sqrt', sqrt)), + GRB.OPCODE_EXP: (UnaryFunctionExpression, ('exp', exp)), + GRB.OPCODE_LOG: (UnaryFunctionExpression, ('log', log)), + GRB.OPCODE_LOG2: (UnaryFunctionExpression, ('log', log)), + GRB.OPCODE_LOG10: (UnaryFunctionExpression, ('log10', log10)), + GRB.OPCODE_POW: (PowExpression, ()), + GRB.OPCODE_SIN: (UnaryFunctionExpression, ('sin', sin)), + GRB.OPCODE_COS: (UnaryFunctionExpression, ('cos', cos)), + GRB.OPCODE_TAN: (UnaryFunctionExpression, ('tan', tan)), + # GRB.OPCODE_LOGISTIC: We don't have this one. + } + ) + +nary_ops = {SumExpression} -nary_ops = { SumExpression, } def grb_nl_to_pyo_expr(opcode, data, parent, var_map): ans = [] @@ -67,11 +70,11 @@ def grb_nl_to_pyo_expr(opcode, data, parent, var_map): # there are two special cases here to account for minus and square print(ans[parent].__class__) print(ans[parent]._args_) - ans[parent]._args_ = ans[parent]._args_ + [ans[-1],] + ans[parent]._args_ = ans[parent]._args_ + [ans[-1]] if ans[parent].__class__ in nary_ops: ans[parent]._nargs += 1 if opcode[parent] == GRB.OPCODE_SQUARE: # add the exponent - ans[parent]._args_ = ans[parent]._args_ + [2,] + ans[parent]._args_ = ans[parent]._args_ + [2] return ans[0] diff --git a/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_walker.py b/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_walker.py index 882173adf82..c7fe6cfbcf6 100644 --- a/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_walker.py +++ b/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_walker.py @@ -15,7 +15,7 @@ import pyomo.common.unittest as unittest from pyomo.contrib.gurobi_minlp.repn.gurobi_direct_minlp import GurobiMINLPVisitor from pyomo.contrib.gurobi_minlp.tests.gurobi_to_pyomo_expressions import ( - grb_nl_to_pyo_expr + grb_nl_to_pyo_expr, ) from pyomo.environ import ( Binary, @@ -232,9 +232,9 @@ def test_write_product_with_0(self): opcode, data, parent = self._get_nl_expr_tree(visitor, expr) self.assertEqual(len(opcode), 6) - self.assertEqual(parent[0], -1) # root + self.assertEqual(parent[0], -1) # root self.assertEqual(opcode[0], GRB.OPCODE_MULTIPLY) - self.assertEqual(data[0], -1) # no additional data + self.assertEqual(data[0], -1) # no additional data # first arg is another multiply with three children self.assertEqual(parent[1], 0) @@ -269,17 +269,14 @@ def test_write_division(self): _, expr = visitor.walk_expression(m.c.body) visitor.grb_model.update() - grb_to_pyo_var_map = {grb_var: py_var for py_var, grb_var - in visitor.var_map.items()} + grb_to_pyo_var_map = { + grb_var: py_var for py_var, grb_var in visitor.var_map.items() + } opcode, data, parent = self._get_nl_expr_tree(visitor, expr) pyo_expr = grb_nl_to_pyo_expr(opcode, data, parent, grb_to_pyo_var_map) - assertExpressionsEqual( - self, - pyo_expr, - 1.0 / m.x1 - ) + assertExpressionsEqual(self, pyo_expr, 1.0 / m.x1) def test_write_division_linear(self): m = self.get_model() @@ -436,7 +433,7 @@ def test_write_absolute_value_of_var(self): # get a constraint: # expr == abs(x1) x1 = visitor.var_map[id(m.x1)] - + self.assertIsInstance(expr, gurobipy.Var) grb_model = visitor.grb_model # We don't call update in walk expression for performance reasons, but @@ -495,7 +492,7 @@ def test_write_absolute_value_of_expression(self): self.assertIs(linexpr.getVar(1), x2) self.assertEqual(linexpr.getCoeff(2), 1) self.assertIs(linexpr.getVar(2), aux1) - + def test_write_expression_with_mutable_param(self): m = self.get_model() m.p = Param(initialize=4, mutable=True) @@ -584,12 +581,12 @@ def test_handle_complex_number_sqrt(self): m = self.get_model() m.p = Param(initialize=3, mutable=True) m.c = Constraint(expr=sqrt(-m.p) + m.x1 >= 3) - + visitor = self.get_visitor() with self.assertRaisesRegex( InvalidValueError, r"Invalid number encountered evaluating constant unary expression " - r"sqrt\(- p\): math domain error" + r"sqrt\(- p\): math domain error", ): _, expr = visitor.walk_expression(m.c.body) @@ -597,11 +594,11 @@ def test_handle_invalid_log(self): m = self.get_model() m.p = Param(initialize=0, mutable=True) m.c = Constraint(expr=log(m.p) + m.x1 >= 3) - + visitor = self.get_visitor() with self.assertRaisesRegex( InvalidValueError, r"Invalid number encountered evaluating constant unary expression " - r"log\(p\): math domain error" + r"log\(p\): math domain error", ): _, expr = visitor.walk_expression(m.c.body) diff --git a/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_writer.py b/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_writer.py index cc0d7d77d3f..b443ea3b0bb 100644 --- a/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_writer.py +++ b/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_writer.py @@ -14,7 +14,7 @@ from pyomo.common.dependencies import numpy as np, numpy_available from pyomo.contrib.gurobi_minlp.tests.gurobi_to_pyomo_expressions import ( - grb_nl_to_pyo_expr + grb_nl_to_pyo_expr, ) from pyomo.core.expr.compare import assertExpressionsEqual from pyomo.core.expr.numeric_expr import SumExpression @@ -342,7 +342,7 @@ def test_named_expression_nonlinear(self): m = ConcreteModel() m.x = Var() m.y = Var() - m.e = Expression(expr=log(m.x)**2 + m.y) + m.e = Expression(expr=log(m.x) ** 2 + m.y) m.c = Constraint(expr=m.e <= 7) m.c2 = Constraint(expr=m.e + m.y**3 + log(m.x + m.y) >= -3) m.obj = Objective(expr=0) @@ -354,7 +354,7 @@ def test_named_expression_nonlinear(self): self.assertEqual(len(var_map), 2) x = var_map[id(m.x)] y = var_map[id(m.y)] - reverse_var_map = {grbv : pyov for pyov, grbv in var_map.items()} + reverse_var_map = {grbv: pyov for pyov, grbv in var_map.items()} self.assertEqual(grb_model.numVars, 4) self.assertEqual(grb_model.numIntVars, 0) @@ -392,7 +392,7 @@ def test_named_expression_nonlinear(self): assertExpressionsEqual( self, grb_nl_to_pyo_expr(opcode, data, parent, reverse_var_map), - log(m.x)**2 + m.y + log(m.x) ** 2 + m.y, ) # log(x)**2 + y + y**3 + log(x + y) @@ -404,11 +404,11 @@ def test_named_expression_nonlinear(self): self, pyo_expr, SumExpression( - (SumExpression(( - SumExpression((log(m.x)**2, m.y)), - m.y ** 3.0)), - log(SumExpression((m.x, m.y)))) - ) + ( + SumExpression((SumExpression((log(m.x) ** 2, m.y)), m.y**3.0)), + log(SumExpression((m.x, m.y))), + ) + ), ) # objective @@ -482,9 +482,9 @@ def test_unbounded_because_of_multplying_by_0(self): self.assertIs(res_var, aux_var) self.assertEqual(len(opcode), 6) - self.assertEqual(parent[0], -1) # root + self.assertEqual(parent[0], -1) # root self.assertEqual(opcode[0], GRB.OPCODE_MULTIPLY) - self.assertEqual(data[0], -1) # no additional data + self.assertEqual(data[0], -1) # no additional data # first arg is another multiply with three children self.assertEqual(parent[1], 0) @@ -510,7 +510,7 @@ def test_unbounded_because_of_multplying_by_0(self): self.assertEqual(parent[5], 0) self.assertEqual(opcode[5], GRB.OPCODE_VARIABLE) self.assertIs(data[5], x3) - + opt = SolverFactory('gurobi_direct_minlp') opt.config.raise_exception_on_nonoptimal_result = False results = opt.solve(m) @@ -528,8 +528,10 @@ def test_numpy_trivially_true_constraint(self): m.obj = Objective(expr=m.x1) results = SolverFactory('gurobi_direct_minlp').solve(m) - self.assertEqual(results.termination_condition, - TerminationCondition.convergenceCriteriaSatisfied) + self.assertEqual( + results.termination_condition, + TerminationCondition.convergenceCriteriaSatisfied, + ) self.assertEqual(value(m.obj), 0) self.assertEqual(results.incumbent_objective, 0) self.assertEqual(results.objective_bound, 0) @@ -551,8 +553,10 @@ def test_trivally_true_constraint(self): m.obj = Objective(expr=m.x1) results = SolverFactory('gurobi_direct_minlp').solve(m, tee=True) - self.assertEqual(results.termination_condition, - TerminationCondition.convergenceCriteriaSatisfied) + self.assertEqual( + results.termination_condition, + TerminationCondition.convergenceCriteriaSatisfied, + ) self.assertEqual(value(m.obj), 2) self.assertEqual(results.incumbent_objective, 2) self.assertEqual(results.objective_bound, 2) From 410e5c625f52a7241994e6a104213d73b3c5a671 Mon Sep 17 00:00:00 2001 From: Emma Johnson <12833636+emma58@users.noreply.github.com> Date: Mon, 29 Sep 2025 20:17:01 -0600 Subject: [PATCH 056/103] Moving gurobi MINLP to contrib.solvers, generalizing the current Gurobi direct solution loader, and moving it onto that one --- pyomo/contrib/gurobi_minlp/__init__.py | 0 pyomo/contrib/gurobi_minlp/repn/__init__.py | 0 pyomo/contrib/gurobi_minlp/tests/__init__.py | 0 pyomo/contrib/solver/solvers/gurobi_direct.py | 19 +++-- .../solvers}/gurobi_direct_minlp.py | 83 ++++++++++++------- .../solvers}/gurobi_to_pyomo_expressions.py | 0 .../solvers}/test_gurobi_minlp_walker.py | 4 +- .../solvers}/test_gurobi_minlp_writer.py | 20 ++--- .../solver/tests/solvers/test_solvers.py | 18 +++- 9 files changed, 92 insertions(+), 52 deletions(-) delete mode 100644 pyomo/contrib/gurobi_minlp/__init__.py delete mode 100644 pyomo/contrib/gurobi_minlp/repn/__init__.py delete mode 100644 pyomo/contrib/gurobi_minlp/tests/__init__.py rename pyomo/contrib/{gurobi_minlp/repn => solver/solvers}/gurobi_direct_minlp.py (88%) rename pyomo/contrib/{gurobi_minlp/tests => solver/tests/solvers}/gurobi_to_pyomo_expressions.py (100%) rename pyomo/contrib/{gurobi_minlp/tests => solver/tests/solvers}/test_gurobi_minlp_walker.py (96%) rename pyomo/contrib/{gurobi_minlp/tests => solver/tests/solvers}/test_gurobi_minlp_writer.py (96%) diff --git a/pyomo/contrib/gurobi_minlp/__init__.py b/pyomo/contrib/gurobi_minlp/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/pyomo/contrib/gurobi_minlp/repn/__init__.py b/pyomo/contrib/gurobi_minlp/repn/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/pyomo/contrib/gurobi_minlp/tests/__init__.py b/pyomo/contrib/gurobi_minlp/tests/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/pyomo/contrib/solver/solvers/gurobi_direct.py b/pyomo/contrib/solver/solvers/gurobi_direct.py index 45ea9dcc873..7f492164dec 100644 --- a/pyomo/contrib/solver/solvers/gurobi_direct.py +++ b/pyomo/contrib/solver/solvers/gurobi_direct.py @@ -117,7 +117,7 @@ def load_vars(self, vars_to_load=None, solution_number=0): if self._grb_model.SolCount == 0: raise NoSolutionError() - iterator = zip(self._pyo_vars, self._grb_vars.x.tolist()) + iterator = zip(self._pyo_vars, map(operator.attrgetter('x'), self._grb_vars)) if vars_to_load: vars_to_load = ComponentSet(vars_to_load) iterator = filter(lambda var_val: var_val[0] in vars_to_load, iterator) @@ -130,7 +130,7 @@ def get_primals(self, vars_to_load=None, solution_number=0): if self._grb_model.SolCount == 0: raise NoSolutionError() - iterator = zip(self._pyo_vars, self._grb_vars.x.tolist()) + iterator = zip(self._pyo_vars, map(operator.attrgetter('x'), self._grb_vars)) if vars_to_load: vars_to_load = ComponentSet(vars_to_load) iterator = filter(lambda var_val: var_val[0] in vars_to_load, iterator) @@ -143,24 +143,25 @@ def get_duals(self, cons_to_load=None): def dedup(_iter): last = None for con_info_dual in _iter: - if not con_info_dual[1] and con_info_dual[0][0] is last: + if not con_info_dual[1] and con_info_dual[0] is last: continue - last = con_info_dual[0][0] + last = con_info_dual[0] yield con_info_dual - iterator = dedup(zip(self._pyo_cons, self._grb_cons.getAttr('Pi').tolist())) + iterator = dedup(zip(self._pyo_cons, map(operator.attrgetter('Pi'), + self._grb_cons))) if cons_to_load: cons_to_load = set(cons_to_load) iterator = filter( - lambda con_info_dual: con_info_dual[0][0] in cons_to_load, iterator + lambda con_info_dual: con_info_dual[0] in cons_to_load, iterator ) - return {con_info[0]: dual for con_info, dual in iterator} + return {con_info: dual for con_info, dual in iterator} def get_reduced_costs(self, vars_to_load=None): if self._grb_model.Status != gurobipy.GRB.OPTIMAL: raise NoReducedCostsError() - iterator = zip(self._pyo_vars, self._grb_vars.getAttr('Rc').tolist()) + iterator = zip(self._pyo_vars, map(operator.attrgetter('Rc'), self._grb_vars)) if vars_to_load: vars_to_load = ComponentSet(vars_to_load) iterator = filter(lambda var_rc: var_rc[0] in vars_to_load, iterator) @@ -374,7 +375,7 @@ def solve(self, model, **kwds) -> Results: timer, config, GurobiDirectSolutionLoader( - gurobi_model, A, x, repn.rows, repn.columns, repn.objectives + gurobi_model, A, x.tolist(), list(map(operator.itemgetter(0), repn.rows)), repn.columns, repn.objectives ), ) diff --git a/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py b/pyomo/contrib/solver/solvers/gurobi_direct_minlp.py similarity index 88% rename from pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py rename to pyomo/contrib/solver/solvers/gurobi_direct_minlp.py index 668151ce999..e3527827d99 100755 --- a/pyomo/contrib/gurobi_minlp/repn/gurobi_direct_minlp.py +++ b/pyomo/contrib/solver/solvers/gurobi_direct_minlp.py @@ -33,7 +33,8 @@ from pyomo.contrib.cp.repn.docplex_writer import collect_valid_components from pyomo.contrib.solver.common.factory import SolverFactory from pyomo.contrib.solver.common.solution_loader import SolutionLoaderBase -from pyomo.contrib.solver.solvers.gurobi_direct import GurobiDirect +from pyomo.contrib.solver.common.util import NoSolutionError +from pyomo.contrib.solver.solvers.gurobi_direct import GurobiDirect, GurobiDirectSolutionLoader from pyomo.core.base import ( Binary, @@ -565,6 +566,8 @@ def write(self, model, **options): pyo_obj = [] # write constraints + pyo_cons = [] + grb_cons = [] for cons in components[Constraint]: expr_type, expr, nonlinear, aux = self._create_gurobi_expression( cons.body, cons, 0, grb_model, quadratic_visitor, visitor @@ -583,47 +586,64 @@ def write(self, model, **options): if ub is not None: ub = float(ub) if cons.equality: - grb_model.addConstr(lb == expr) + grb_cons.append(grb_model.addConstr(lb == expr)) + pyo_cons.append(cons) else: if cons.lb is not None: - grb_model.addConstr(lb <= expr) + grb_cons.append(grb_model.addConstr(lb <= expr)) + pyo_cons.append(cons) if cons.ub is not None: - grb_model.addConstr(ub >= expr) + grb_cons.append(grb_model.addConstr(ub >= expr)) + pyo_cons.append(cons) grb_model.update() - return grb_model, visitor.var_map, pyo_obj + return grb_model, visitor.var_map, pyo_obj, grb_cons, pyo_cons -class GurobiMINLPSolutionLoader(SolutionLoaderBase): - def __init__(self, grb_model, var_map, pyo_obj): - self._grb_model = grb_model - self._pyo_to_grb_var_map = var_map - self._pyo_obj = pyo_obj +# class GurobiMINLPSolutionLoader(SolutionLoaderBase): +# def __init__(self, grb_model, var_map, pyo_obj): +# self._grb_model = grb_model +# self._pyo_to_grb_var_map = var_map +# self._pyo_obj = pyo_obj - def load_vars(self, vars_to_load=None, solution_number=0): - assert solution_number == 0 - if self._grb_model.SolCount == 0: - raise NoSolutionError() +# def load_vars(self, vars_to_load=None, solution_number=0): +# assert solution_number == 0 +# if self._grb_model.SolCount == 0: +# raise NoSolutionError() - if vars_to_load: - vars_to_load = ComponentSet(vars_to_load) - else: - vars_to_load = ComponentSet(self._pyo_to_grb_var_map.keys()) +# if vars_to_load: +# vars_to_load = ComponentSet(vars_to_load) +# else: +# vars_to_load = ComponentSet(self._pyo_to_grb_var_map.keys()) + +# for pyo_var, grb_var in self._pyo_to_grb_var_map.items(): +# if pyo_var in vars_to_load: +# pyo_var.set_value(grb_var.x, skip_validation=True) +# StaleFlagManager.mark_all_as_stale(delayed=True) + +# def get_primals(self, vars_to_load=None): +# if self._grb_model.SolCount == 0: +# raise NoSolutionError() - for pyo_var, grb_var in self._pyo_to_grb_var_map.items(): - if pyo_var in vars_to_load: - pyo_var.set_value(grb_var.x, skip_validation=True) - StaleFlagManager.mark_all_as_stale(delayed=True) +# if vars_to_load: +# vars_to_load = ComponentSet(vars_to_load) +# else: +# vars_to_load = ComponentSet(self._pyo_to_grb_var_map.keys()) + +# primal_vars = ComponentMap() +# for pyo_var, grb_var in self._pyo_to_grb_var_map.items(): +# if pyo_var in vars_to_load: +# primal_vars[pyo_var] = grb_var.x + +# return primal_vars -# ESJ TODO: I just did the most convenient inheritance for the moment--if this is the -# right thing to do is a different question. @SolverFactory.register( 'gurobi_direct_minlp', doc='Direct interface to Gurobi version 12 and up ' 'supporting general nonlinear expressions', ) -class GurobiMINLPSolver(GurobiDirect): +class GurobiDirectMINLP(GurobiDirect): def solve(self, model, **kwds): """Solve the model. @@ -640,14 +660,14 @@ def solve(self, model, **kwds): ) if config.timer is None: config.timer = HierarchicalTimer() - timer = config.timer + timer = config.timer StaleFlagManager.mark_all_as_stale() timer.start('compile_model') writer = GurobiMINLPWriter() - grb_model, var_map, pyo_obj = writer.write( + grb_model, var_map, pyo_obj, grb_cons, pyo_cons = writer.write( model, symbolic_solver_labels=config.symbolic_solver_labels ) @@ -678,7 +698,14 @@ def solve(self, model, **kwds): grbsol = grb_model.optimize() res = self._postsolve( - timer, config, GurobiMINLPSolutionLoader(grb_model, var_map, pyo_obj) + timer, config, GurobiDirectSolutionLoader( + grb_model, + grb_cons=grb_cons, + grb_vars=var_map.values(), + pyo_cons=pyo_cons, + pyo_vars=var_map.keys(), + pyo_obj=pyo_obj + ) ) res.solver_config = config diff --git a/pyomo/contrib/gurobi_minlp/tests/gurobi_to_pyomo_expressions.py b/pyomo/contrib/solver/tests/solvers/gurobi_to_pyomo_expressions.py similarity index 100% rename from pyomo/contrib/gurobi_minlp/tests/gurobi_to_pyomo_expressions.py rename to pyomo/contrib/solver/tests/solvers/gurobi_to_pyomo_expressions.py diff --git a/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_walker.py b/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp_walker.py similarity index 96% rename from pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_walker.py rename to pyomo/contrib/solver/tests/solvers/test_gurobi_minlp_walker.py index c7fe6cfbcf6..5281c687937 100644 --- a/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_walker.py +++ b/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp_walker.py @@ -13,8 +13,8 @@ from pyomo.core.expr.compare import assertExpressionsEqual from pyomo.common.errors import InvalidValueError import pyomo.common.unittest as unittest -from pyomo.contrib.gurobi_minlp.repn.gurobi_direct_minlp import GurobiMINLPVisitor -from pyomo.contrib.gurobi_minlp.tests.gurobi_to_pyomo_expressions import ( +from pyomo.contrib.solver.solvers.gurobi_direct_minlp import GurobiMINLPVisitor +from pyomo.contrib.solver.tests.solvers.gurobi_to_pyomo_expressions import ( grb_nl_to_pyo_expr, ) from pyomo.environ import ( diff --git a/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_writer.py b/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp_writer.py similarity index 96% rename from pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_writer.py rename to pyomo/contrib/solver/tests/solvers/test_gurobi_minlp_writer.py index b443ea3b0bb..aa29515d270 100644 --- a/pyomo/contrib/gurobi_minlp/tests/test_gurobi_minlp_writer.py +++ b/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp_writer.py @@ -13,7 +13,7 @@ from pyomo.common.dependencies import attempt_import from pyomo.common.dependencies import numpy as np, numpy_available -from pyomo.contrib.gurobi_minlp.tests.gurobi_to_pyomo_expressions import ( +from pyomo.contrib.solver.tests.solvers.gurobi_to_pyomo_expressions import ( grb_nl_to_pyo_expr, ) from pyomo.core.expr.compare import assertExpressionsEqual @@ -39,13 +39,13 @@ Var, ) from pyomo.opt import WriterFactory -from pyomo.contrib.gurobi_minlp.repn.gurobi_direct_minlp import ( - GurobiMINLPSolver, +from pyomo.contrib.solver.solvers.gurobi_direct_minlp import ( + GurobiDirectMINLP, GurobiMINLPVisitor, ) from pyomo.contrib.solver.common.factory import SolverFactory from pyomo.contrib.solver.common.results import TerminationCondition -from pyomo.contrib.gurobi_minlp.tests.test_gurobi_minlp_walker import CommonTest +from pyomo.contrib.solver.tests.solvers.test_gurobi_minlp_walker import CommonTest ## DEBUG from pytest import set_trace @@ -82,7 +82,7 @@ def test_small_model(self): m = make_model() - grb_model, var_map, obj = WriterFactory('gurobi_minlp').write( + grb_model, var_map, obj, grb_cons, pyo_cons = WriterFactory('gurobi_minlp').write( m, symbolic_solver_labels=True ) @@ -194,7 +194,7 @@ def test_write_NPV_negation_in_RHS(self): m.c = Constraint(expr=-m.x1 == m.p1) m.obj = Objective(expr=m.x1) - grb_model, var_map, obj = WriterFactory('gurobi_minlp').write( + grb_model, var_map, obj, grb_cons, pyo_cons = WriterFactory('gurobi_minlp').write( m, symbolic_solver_labels=True ) @@ -240,7 +240,7 @@ def test_writer_ignores_deactivated_logical_constraints(self): m.whatever = LogicalConstraint(expr=~m.b) m.whatever.deactivate() - grb_model, var_map, obj = WriterFactory('gurobi_minlp').write( + grb_model, var_map, obj, grb_cons, pyo_cons = WriterFactory('gurobi_minlp').write( m, symbolic_solver_labels=True ) @@ -284,7 +284,7 @@ def test_named_expression_quadratic(self): m.c2 = Constraint(expr=m.e >= -3) m.obj = Objective(expr=0) - grb_model, var_map, obj = WriterFactory('gurobi_minlp').write( + grb_model, var_map, obj, grb_cons, pyo_cons = WriterFactory('gurobi_minlp').write( m, symbolic_solver_labels=True ) @@ -347,7 +347,7 @@ def test_named_expression_nonlinear(self): m.c2 = Constraint(expr=m.e + m.y**3 + log(m.x + m.y) >= -3) m.obj = Objective(expr=0) - grb_model, var_map, obj = WriterFactory('gurobi_minlp').write( + grb_model, var_map, obj, grb_cons, pyo_cons = WriterFactory('gurobi_minlp').write( m, symbolic_solver_labels=True ) @@ -445,7 +445,7 @@ def test_unbounded_because_of_multplying_by_0(self): m.c = Constraint(expr=(0 * m.x1 * m.x2) * m.x3 == 0) m.obj = Objective(expr=m.x1) - grb_model, var_map, obj = WriterFactory('gurobi_minlp').write( + grb_model, var_map, obj, grb_cons, pyo_cons = WriterFactory('gurobi_minlp').write( m, symbolic_solver_labels=True ) diff --git a/pyomo/contrib/solver/tests/solvers/test_solvers.py b/pyomo/contrib/solver/tests/solvers/test_solvers.py index a3225f43d8a..f3290bc92e0 100644 --- a/pyomo/contrib/solver/tests/solvers/test_solvers.py +++ b/pyomo/contrib/solver/tests/solvers/test_solvers.py @@ -24,6 +24,7 @@ from pyomo.contrib.solver.common.factory import SolverFactory from pyomo.contrib.solver.solvers.gurobi_persistent import GurobiPersistent from pyomo.contrib.solver.solvers.gurobi_direct import GurobiDirect +from pyomo.contrib.solver.solvers.gurobi_direct_minlp import GurobiDirectMINLP from pyomo.contrib.solver.solvers.highs import Highs from pyomo.contrib.solver.solvers.ipopt import Ipopt from pyomo.contrib.solver.common.results import ( @@ -52,18 +53,29 @@ all_solvers = [ ('gurobi_persistent', GurobiPersistent), ('gurobi_direct', GurobiDirect), + ('gurobi_direct_minlp', GurobiDirectMINLP), ('ipopt', Ipopt), ('highs', Highs), ] mip_solvers = [ ('gurobi_persistent', GurobiPersistent), ('gurobi_direct', GurobiDirect), + ('gurobi_direct_minlp', GurobiDirectMINLP), ('highs', Highs), ] -nlp_solvers = [('ipopt', Ipopt)] -qcp_solvers = [('gurobi_persistent', GurobiPersistent), ('ipopt', Ipopt)] +nlp_solvers = [ + ('gurobi_direct_minlp', GurobiDirectMINLP), + ('ipopt', Ipopt), +] +qcp_solvers = [ + ('gurobi_persistent', GurobiPersistent), + ('gurobi_direct_minlp', GurobiDirectMINLP), + ('ipopt', Ipopt)] qp_solvers = qcp_solvers + [("highs", Highs)] -miqcqp_solvers = [('gurobi_persistent', GurobiPersistent)] +miqcqp_solvers = [ + ('gurobi_persistent', GurobiPersistent), + ('gurobi_direct_minlp', GurobiDirectMINLP), +] nl_solvers = [('ipopt', Ipopt)] nl_solvers_set = {i[0] for i in nl_solvers} From d133fe1a42f86c85d594080dc6522bfa03b95e43 Mon Sep 17 00:00:00 2001 From: Emma Johnson <12833636+emma58@users.noreply.github.com> Date: Mon, 29 Sep 2025 20:19:04 -0600 Subject: [PATCH 057/103] NFC: Fixing typos --- .../solver/tests/solvers/test_gurobi_minlp_writer.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp_writer.py b/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp_writer.py index aa29515d270..95626b26de1 100644 --- a/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp_writer.py +++ b/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp_writer.py @@ -433,8 +433,8 @@ def test_solve_model(self): self.assertEqual(results.incumbent_objective, 2) self.assertEqual(results.objective_bound, 2) - def test_unbounded_because_of_multplying_by_0(self): - # Gurobi belives that the expression in m.c is nonlinear, so we have + def test_unbounded_because_of_multiplying_by_0(self): + # Gurobi believes that the expression in m.c is nonlinear, so we have # to pass it that way for this to work. Because this is in fact an # unbounded model. @@ -540,7 +540,7 @@ def test_numpy_trivially_true_constraint(self): # expression type I'm passing through, not with the numpy # situation now. - def test_trivally_true_constraint(self): + def test_trivially_true_constraint(self): """ We can pass trivially true things to Gurobi and it's fine """ From 8ca6b2699745130dc2e1365e2dafb4e7ddee10d9 Mon Sep 17 00:00:00 2001 From: Emma Johnson <12833636+emma58@users.noreply.github.com> Date: Mon, 29 Sep 2025 20:20:32 -0600 Subject: [PATCH 058/103] NFC: Black --- pyomo/contrib/solver/solvers/gurobi_direct.py | 12 +++++-- .../solver/solvers/gurobi_direct_minlp.py | 13 ++++--- .../tests/solvers/test_gurobi_minlp_writer.py | 36 +++++++++---------- .../solver/tests/solvers/test_solvers.py | 8 ++--- 4 files changed, 39 insertions(+), 30 deletions(-) diff --git a/pyomo/contrib/solver/solvers/gurobi_direct.py b/pyomo/contrib/solver/solvers/gurobi_direct.py index 7f492164dec..4912d0b8966 100644 --- a/pyomo/contrib/solver/solvers/gurobi_direct.py +++ b/pyomo/contrib/solver/solvers/gurobi_direct.py @@ -148,8 +148,9 @@ def dedup(_iter): last = con_info_dual[0] yield con_info_dual - iterator = dedup(zip(self._pyo_cons, map(operator.attrgetter('Pi'), - self._grb_cons))) + iterator = dedup( + zip(self._pyo_cons, map(operator.attrgetter('Pi'), self._grb_cons)) + ) if cons_to_load: cons_to_load = set(cons_to_load) iterator = filter( @@ -375,7 +376,12 @@ def solve(self, model, **kwds) -> Results: timer, config, GurobiDirectSolutionLoader( - gurobi_model, A, x.tolist(), list(map(operator.itemgetter(0), repn.rows)), repn.columns, repn.objectives + gurobi_model, + A, + x.tolist(), + list(map(operator.itemgetter(0), repn.rows)), + repn.columns, + repn.objectives, ), ) diff --git a/pyomo/contrib/solver/solvers/gurobi_direct_minlp.py b/pyomo/contrib/solver/solvers/gurobi_direct_minlp.py index e3527827d99..91e8a2291ac 100755 --- a/pyomo/contrib/solver/solvers/gurobi_direct_minlp.py +++ b/pyomo/contrib/solver/solvers/gurobi_direct_minlp.py @@ -34,7 +34,10 @@ from pyomo.contrib.solver.common.factory import SolverFactory from pyomo.contrib.solver.common.solution_loader import SolutionLoaderBase from pyomo.contrib.solver.common.util import NoSolutionError -from pyomo.contrib.solver.solvers.gurobi_direct import GurobiDirect, GurobiDirectSolutionLoader +from pyomo.contrib.solver.solvers.gurobi_direct import ( + GurobiDirect, + GurobiDirectSolutionLoader, +) from pyomo.core.base import ( Binary, @@ -698,14 +701,16 @@ def solve(self, model, **kwds): grbsol = grb_model.optimize() res = self._postsolve( - timer, config, GurobiDirectSolutionLoader( + timer, + config, + GurobiDirectSolutionLoader( grb_model, grb_cons=grb_cons, grb_vars=var_map.values(), pyo_cons=pyo_cons, pyo_vars=var_map.keys(), - pyo_obj=pyo_obj - ) + pyo_obj=pyo_obj, + ), ) res.solver_config = config diff --git a/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp_writer.py b/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp_writer.py index 95626b26de1..e08dc643817 100644 --- a/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp_writer.py +++ b/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp_writer.py @@ -82,9 +82,9 @@ def test_small_model(self): m = make_model() - grb_model, var_map, obj, grb_cons, pyo_cons = WriterFactory('gurobi_minlp').write( - m, symbolic_solver_labels=True - ) + grb_model, var_map, obj, grb_cons, pyo_cons = WriterFactory( + 'gurobi_minlp' + ).write(m, symbolic_solver_labels=True) self.assertEqual(len(var_map), 7) x1 = var_map[id(m.x1)] @@ -194,9 +194,9 @@ def test_write_NPV_negation_in_RHS(self): m.c = Constraint(expr=-m.x1 == m.p1) m.obj = Objective(expr=m.x1) - grb_model, var_map, obj, grb_cons, pyo_cons = WriterFactory('gurobi_minlp').write( - m, symbolic_solver_labels=True - ) + grb_model, var_map, obj, grb_cons, pyo_cons = WriterFactory( + 'gurobi_minlp' + ).write(m, symbolic_solver_labels=True) self.assertEqual(len(var_map), 1) x1 = var_map[id(m.x1)] @@ -240,9 +240,9 @@ def test_writer_ignores_deactivated_logical_constraints(self): m.whatever = LogicalConstraint(expr=~m.b) m.whatever.deactivate() - grb_model, var_map, obj, grb_cons, pyo_cons = WriterFactory('gurobi_minlp').write( - m, symbolic_solver_labels=True - ) + grb_model, var_map, obj, grb_cons, pyo_cons = WriterFactory( + 'gurobi_minlp' + ).write(m, symbolic_solver_labels=True) self.assertEqual(len(var_map), 1) x1 = var_map[id(m.x1)] @@ -284,9 +284,9 @@ def test_named_expression_quadratic(self): m.c2 = Constraint(expr=m.e >= -3) m.obj = Objective(expr=0) - grb_model, var_map, obj, grb_cons, pyo_cons = WriterFactory('gurobi_minlp').write( - m, symbolic_solver_labels=True - ) + grb_model, var_map, obj, grb_cons, pyo_cons = WriterFactory( + 'gurobi_minlp' + ).write(m, symbolic_solver_labels=True) self.assertEqual(len(var_map), 2) x = var_map[id(m.x)] @@ -347,9 +347,9 @@ def test_named_expression_nonlinear(self): m.c2 = Constraint(expr=m.e + m.y**3 + log(m.x + m.y) >= -3) m.obj = Objective(expr=0) - grb_model, var_map, obj, grb_cons, pyo_cons = WriterFactory('gurobi_minlp').write( - m, symbolic_solver_labels=True - ) + grb_model, var_map, obj, grb_cons, pyo_cons = WriterFactory( + 'gurobi_minlp' + ).write(m, symbolic_solver_labels=True) self.assertEqual(len(var_map), 2) x = var_map[id(m.x)] @@ -445,9 +445,9 @@ def test_unbounded_because_of_multiplying_by_0(self): m.c = Constraint(expr=(0 * m.x1 * m.x2) * m.x3 == 0) m.obj = Objective(expr=m.x1) - grb_model, var_map, obj, grb_cons, pyo_cons = WriterFactory('gurobi_minlp').write( - m, symbolic_solver_labels=True - ) + grb_model, var_map, obj, grb_cons, pyo_cons = WriterFactory( + 'gurobi_minlp' + ).write(m, symbolic_solver_labels=True) self.assertEqual(len(var_map), 3) x1 = var_map[id(m.x1)] diff --git a/pyomo/contrib/solver/tests/solvers/test_solvers.py b/pyomo/contrib/solver/tests/solvers/test_solvers.py index f3290bc92e0..3341eb84eb0 100644 --- a/pyomo/contrib/solver/tests/solvers/test_solvers.py +++ b/pyomo/contrib/solver/tests/solvers/test_solvers.py @@ -63,14 +63,12 @@ ('gurobi_direct_minlp', GurobiDirectMINLP), ('highs', Highs), ] -nlp_solvers = [ - ('gurobi_direct_minlp', GurobiDirectMINLP), - ('ipopt', Ipopt), -] +nlp_solvers = [('gurobi_direct_minlp', GurobiDirectMINLP), ('ipopt', Ipopt)] qcp_solvers = [ ('gurobi_persistent', GurobiPersistent), ('gurobi_direct_minlp', GurobiDirectMINLP), - ('ipopt', Ipopt)] + ('ipopt', Ipopt), +] qp_solvers = qcp_solvers + [("highs", Highs)] miqcqp_solvers = [ ('gurobi_persistent', GurobiPersistent), From e9e9467ac9b6b5905aaf95398763362c7b7ebeab Mon Sep 17 00:00:00 2001 From: Emma Johnson <12833636+emma58@users.noreply.github.com> Date: Mon, 29 Sep 2025 20:22:21 -0600 Subject: [PATCH 059/103] Removing my homemade solution loader --- .../solver/solvers/gurobi_direct_minlp.py | 38 ------------------- 1 file changed, 38 deletions(-) diff --git a/pyomo/contrib/solver/solvers/gurobi_direct_minlp.py b/pyomo/contrib/solver/solvers/gurobi_direct_minlp.py index 91e8a2291ac..01724b729ab 100755 --- a/pyomo/contrib/solver/solvers/gurobi_direct_minlp.py +++ b/pyomo/contrib/solver/solvers/gurobi_direct_minlp.py @@ -603,44 +603,6 @@ def write(self, model, **options): return grb_model, visitor.var_map, pyo_obj, grb_cons, pyo_cons -# class GurobiMINLPSolutionLoader(SolutionLoaderBase): -# def __init__(self, grb_model, var_map, pyo_obj): -# self._grb_model = grb_model -# self._pyo_to_grb_var_map = var_map -# self._pyo_obj = pyo_obj - -# def load_vars(self, vars_to_load=None, solution_number=0): -# assert solution_number == 0 -# if self._grb_model.SolCount == 0: -# raise NoSolutionError() - -# if vars_to_load: -# vars_to_load = ComponentSet(vars_to_load) -# else: -# vars_to_load = ComponentSet(self._pyo_to_grb_var_map.keys()) - -# for pyo_var, grb_var in self._pyo_to_grb_var_map.items(): -# if pyo_var in vars_to_load: -# pyo_var.set_value(grb_var.x, skip_validation=True) -# StaleFlagManager.mark_all_as_stale(delayed=True) - -# def get_primals(self, vars_to_load=None): -# if self._grb_model.SolCount == 0: -# raise NoSolutionError() - -# if vars_to_load: -# vars_to_load = ComponentSet(vars_to_load) -# else: -# vars_to_load = ComponentSet(self._pyo_to_grb_var_map.keys()) - -# primal_vars = ComponentMap() -# for pyo_var, grb_var in self._pyo_to_grb_var_map.items(): -# if pyo_var in vars_to_load: -# primal_vars[pyo_var] = grb_var.x - -# return primal_vars - - @SolverFactory.register( 'gurobi_direct_minlp', doc='Direct interface to Gurobi version 12 and up ' From c59b52773f559a09dadc484117195da13b52d17d Mon Sep 17 00:00:00 2001 From: Emma Johnson <12833636+emma58@users.noreply.github.com> Date: Mon, 29 Sep 2025 20:36:43 -0600 Subject: [PATCH 060/103] Moving new tests to contrib.solvers, reverting changes in old Gurobi direct --- .../tests/solvers}/test_gurobi_minlp.py | 0 .../solvers/plugins/solvers/gurobi_direct.py | 82 +------------------ 2 files changed, 1 insertion(+), 81 deletions(-) rename pyomo/{solvers/tests => contrib/solver/tests/solvers}/test_gurobi_minlp.py (100%) diff --git a/pyomo/solvers/tests/test_gurobi_minlp.py b/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp.py similarity index 100% rename from pyomo/solvers/tests/test_gurobi_minlp.py rename to pyomo/contrib/solver/tests/solvers/test_gurobi_minlp.py diff --git a/pyomo/solvers/plugins/solvers/gurobi_direct.py b/pyomo/solvers/plugins/solvers/gurobi_direct.py index 1c428ae7836..6d8a9137aa2 100644 --- a/pyomo/solvers/plugins/solvers/gurobi_direct.py +++ b/pyomo/solvers/plugins/solvers/gurobi_direct.py @@ -13,7 +13,6 @@ import re import sys -from gurobipy import GRB, LinExpr, NLExpr, nlfunc from pyomo.common.collections import ComponentSet, ComponentMap, Bunch from pyomo.common.dependencies import attempt_import from pyomo.common.errors import ApplicationError @@ -34,8 +33,6 @@ from pyomo.opt.base import SolverFactory from pyomo.core.base.suffix import Suffix -from pyomo.core.expr.numeric_expr import DivisionExpression, PowExpression, ProductExpression, NumericExpression, UnaryFunctionExpression, SumExpression - logger = logging.getLogger('pyomo.solvers') @@ -43,9 +40,6 @@ class DegreeError(ValueError): pass -class NonLinearError(ValueError): - pass - def _is_numeric(x): try: @@ -286,14 +280,12 @@ def _get_expr_from_pyomo_repn(self, repn, max_degree=2): referenced_vars = ComponentSet() degree = repn.polynomial_degree() - """ if (degree is None) or (degree > max_degree): raise DegreeError( 'GurobiDirect does not support expressions of degree {0}.'.format( degree ) ) - """ if len(repn.linear_vars) > 0: referenced_vars.update(repn.linear_vars) @@ -316,70 +308,8 @@ def _get_expr_from_pyomo_repn(self, repn, max_degree=2): new_expr += repn.constant - if repn.nonlinear_expr is not None: - nlexpr = repn.nonlinear_expr - nl_grb_expr, nl_ref_vars = self._get_nlexpr_from_pyomo_expr(nlexpr) - for var in nl_ref_vars: - referenced_vars.add(var) - new_expr += nl_grb_expr - return new_expr, referenced_vars, degree - def _get_nlexpr_from_pyomo_expr(self, nlexpr): - referenced_vars = ComponentSet() - grb_args = [] - if isinstance(nlexpr, NumericExpression): - for arg in nlexpr.args: - grb_arg, arg_vars, _ = self._get_expr_from_pyomo_expr(arg) - grb_args.append(grb_arg) - for var in arg_vars: - referenced_vars.add(var) - - if type(nlexpr) is ProductExpression: - result = 1 - for g in grb_args: - result = result * g - return result, referenced_vars - - if type(nlexpr) is DivisionExpression: - if len(grb_args) != 2: - raise NonLinearError(f'Unexpected argument count for division') - result = grb_args[0] / grb_args[1] - return result, referenced_vars - - if type(nlexpr) is SumExpression: - result = LinExpr() - for g in grb_args: - result += g - return result, referenced_vars - - if type(nlexpr) is PowExpression: - if len(grb_args) != 2: - raise NonLinearError(f'Unexpected argument count for power function') - return grb_args[0] ** grb_args[1], referenced_vars - - if type(nlexpr) is UnaryFunctionExpression: - if len(grb_args) != 1: - raise NonLinearError(f'Unexpected argument count for unary function') - if nlexpr.name == 'exp': - return nlfunc.exp(grb_args[0]), referenced_vars - if nlexpr.name == 'sin': - return nlfunc.sin(grb_args[0]), referenced_vars - if nlexpr.name == 'cos': - return nlfunc.cos(grb_args[0]), referenced_vars - if nlexpr.name == 'sqrt': - return nlfunc.sqrt(grb_args[0]), referenced_vars - if nlexpr.name == 'tan': - return nlfunc.tan(grb_args[0]), referenced_vars - if nlexpr.name == 'log': - return nlfunc.log(grb_args[0]), referenced_vars - if nlexpr.name == 'log10': - return nlfunc.log10(grb_args[0]), referenced_vars - - - raise NonLinearError(f'Unsupported nonlinear feature {nlexpr} ({nlexpr.name})') - - def _get_expr_from_pyomo_expr(self, expr, max_degree=2): if max_degree == 2: repn = generate_standard_repn(expr, quadratic=True) @@ -564,11 +494,6 @@ def _add_block(self, block): def _addConstr(self, degree, lhs, sense=None, rhs=None, name=""): if degree == 2: con = self._solver_model.addQConstr(lhs, sense, rhs, name) - elif type(lhs) is NLExpr: - sm = self._solver_model - v = sm.addVar(lb=-GRB.INFINITY, ub=GRB.INFINITY) - sm.addConstr(v == lhs) - con = self._solver_model.addQConstr(v, sense, rhs, name) else: con = self._solver_model.addLConstr(lhs, sense, rhs, name) return con @@ -723,11 +648,6 @@ def _set_objective(self, obj): obj.expr, self._max_obj_degree ) - if isinstance(gurobi_expr, NLExpr): - grb_obj_var = self._solver_model.addVar(lb=-GRB.INFINITY) - self._solver_model.addConstr(grb_obj_var == gurobi_expr) - gurobi_expr = grb_obj_var - for var in referenced_vars: self._referenced_variables[var] += 1 @@ -1232,4 +1152,4 @@ def load_slacks(self, cons_to_load=None): def _update(self): self._solver_model.update() - self._needs_updated = False \ No newline at end of file + self._needs_updated = False From 350e9612c49794080d152659fbcf21e1950607a5 Mon Sep 17 00:00:00 2001 From: Emma Johnson <12833636+emma58@users.noreply.github.com> Date: Mon, 29 Sep 2025 21:02:23 -0600 Subject: [PATCH 061/103] Updating Ronald's tests to new solver interface, adjusting a few style things --- .../solver/tests/solvers/test_gurobi_minlp.py | 99 +++++++++---------- 1 file changed, 47 insertions(+), 52 deletions(-) diff --git a/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp.py b/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp.py index e922f496e16..a7bff33d54d 100644 --- a/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp.py +++ b/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp.py @@ -1,91 +1,93 @@ from math import pi import pyomo.common.unittest as unittest +from pyomo.contrib.solver.common.factory import SolverFactory +from pyomo.contrib.solver.common.results import TerminationCondition, SolutionStatus +from pyomo.contrib.solver.solvers.gurobi_direct_minlp import GurobiDirectMINLP from pyomo.core.base.constraint import Constraint -from pyomo.environ import * - -gurobi_direct = SolverFactory('gurobi_direct') - -class TestGurobiMINLP(unittest.TestCase): - @unittest.skipUnless( - gurobi_direct.available(exception_flag=False) and gurobi_direct.license_is_valid(), - "needs Gurobi Direct interface", +from pyomo.environ import ( + ConcreteModel, + Var, + VarList, + Constraint, + ConstraintList, + value, + Binary, + NonNegativeReals, + Objective, + maximize, + minimize, + cos, + sin, + tan, + log, + log10, + exp, + sqrt, +) + +gurobi_direct = SolverFactory('gurobi_direct_minlp') + +@unittest.skipUnless( + gurobi_direct.available(), + "needs Gurobi Direct MINLP interface", ) +class TestGurobiMINLP(unittest.TestCase): def test_gurobi_minlp_sincosexp(self): m = ConcreteModel(name="test") m.x = Var(bounds=(-1, 4)) m.o = Objective(expr=sin(m.x) + cos(2*m.x) + 1) m.c = Constraint(expr=0.25 * exp(m.x) - m.x <= 0) gurobi_direct.solve(m) - self.assertAlmostEqual(1, m.o(), delta=1e-3) - self.assertAlmostEqual(1.571, m.x(), delta=1e-3) + self.assertAlmostEqual(1, value(m.o), delta=1e-3) + self.assertAlmostEqual(1.571, value(m.x), delta=1e-3) - @unittest.skipUnless( - gurobi_direct.available(exception_flag=False) and gurobi_direct.license_is_valid(), - "needs Gurobi Direct interface", - ) def test_gurobi_minlp_tan(self): m = ConcreteModel(name="test") m.x = Var(bounds=(0, pi/2)) m.o = Objective(expr=tan(m.x)/(m.x**2)) gurobi_direct.solve(m) - self.assertAlmostEqual(0.948, m.x(), delta=1e-3) - self.assertAlmostEqual(1.549, m.o(), delta=1e-3) + self.assertAlmostEqual(0.948, value(m.x), delta=1e-3) + self.assertAlmostEqual(1.549, value(m.o), delta=1e-3) - @unittest.skipUnless( - gurobi_direct.available(exception_flag=False) and gurobi_direct.license_is_valid(), - "needs Gurobi Direct interface", - ) def test_gurobi_minlp_sqrt(self): m = ConcreteModel(name="test") m.x = Var(bounds=(0, 2)) m.o = Objective(expr=sqrt(m.x)-(m.x**2)/3, sense=maximize) gurobi_direct.solve(m) - self.assertAlmostEqual(0.825, m.x(), delta=1e-3) - self.assertAlmostEqual(0.681, m.o(), delta=1e-3) + self.assertAlmostEqual(0.825, value(m.x), delta=1e-3) + self.assertAlmostEqual(0.681, value(m.o), delta=1e-3) - @unittest.skipUnless( - gurobi_direct.available(exception_flag=False) and gurobi_direct.license_is_valid(), - "needs Gurobi Direct interface", - ) def test_gurobi_minlp_log(self): m = ConcreteModel(name="test") m.x = Var(bounds=(1, 2)) m.o = Objective(expr=(m.x*m.x)/log(m.x)) gurobi_direct.solve(m) - self.assertAlmostEqual(1.396, m.x(), delta=1e-3) - self.assertAlmostEqual(8.155, m.o(), delta=1e-3) + self.assertAlmostEqual(1.396, value(m.x), delta=1e-3) + self.assertAlmostEqual(8.155, value(m.o), delta=1e-3) - @unittest.skipUnless( - gurobi_direct.available(exception_flag=False) and gurobi_direct.license_is_valid(), - "needs Gurobi Direct interface", - ) def test_gurobi_minlp_log10(self): m = ConcreteModel(name="test") m.x = Var(bounds=(1, 2)) m.o = Objective(expr=(m.x*m.x)/log10(m.x)) gurobi_direct.solve(m) - self.assertAlmostEqual(1.649, m.x(), delta=1e-3) - self.assertAlmostEqual(12.518, m.o(), delta=1e-3) + self.assertAlmostEqual(1.649, value(m.x), delta=1e-3) + self.assertAlmostEqual(12.518, value(m.o), delta=1e-3) - @unittest.skipUnless( - gurobi_direct.available(exception_flag=False) and gurobi_direct.license_is_valid(), - "needs Gurobi Direct interface", - ) def test_gurobi_minlp_sigmoid(self): m = ConcreteModel(name="test") m.x = Var(bounds=(0, 4)) m.y = Var(bounds=(0, 4)) m.o = Objective(expr=m.y-m.x) m.c = Constraint(expr=1/(1+exp(-m.x)) <= m.y) - gurobi_direct.solve(m, tee=True, options={'logfile':'gurobi.log'}) - self.assertAlmostEqual(m.o(), -3.017, delta=1e-3) + gurobi_direct.solve(m) + self.assertAlmostEqual(value(m.o), -3.017, delta=1e-3) def _build_divpwr_model(self, divide: bool, min: bool): model = ConcreteModel(name="test") model.x1 = Var(domain=NonNegativeReals, bounds=(0.5, 0.6)) model.x2 = Var(domain=NonNegativeReals, bounds=(0.1, 0.2)) - model.y = Var(domain=Boolean, initialize=1) + model.y = Var(domain=Binary, initialize=1) y_mult = 1.3 if divide: @@ -100,10 +102,6 @@ def _build_divpwr_model(self, divide: bool, min: bool): return model - @unittest.skipUnless( - gurobi_direct.available(exception_flag=False) and gurobi_direct.license_is_valid(), - "needs Gurobi Direct interface", - ) def test_gurobi_minlp_divpwr(self): params = [ {"min": False, "divide": False, "obj": 13 }, @@ -114,13 +112,9 @@ def test_gurobi_minlp_divpwr(self): for p in params: model = self._build_divpwr_model(p['divide'], p['min']) gurobi_direct.solve(model) - self.assertEqual(p["obj"], model.OBJ()) + self.assertEqual(p["obj"], value(model.OBJ)) self.assertEqual(1, model.y.value) - @unittest.skipUnless( - gurobi_direct.available(exception_flag=False) and gurobi_direct.license_is_valid(), - "needs Gurobi Direct interface", - ) def test_gurobi_minlp_acopf(self): # Based on https://docs.gurobi.com/projects/examples/en/current/examples/python/acopf_4buses.html @@ -189,9 +183,10 @@ def test_gurobi_minlp_acopf(self): m.define_Q.add(m.Q[i+1] == m.v[i+1] * sum(m.v[j+1] * (G[i][j] * sin(m.theta[i+1] - m.theta[j+1]) - B[i][j] * cos(m.theta[i+1] - m.theta[j+1])) for j in range(N))) results = gurobi_direct.solve(m, tee=True) - self.assertEqual(SolverStatus.ok, results.solver.status) + self.assertEqual(results.termination_condition, TerminationCondition.convergenceCriteriaSatisfied) + self.assertEqual(results.solution_status, SolutionStatus.optimal) for i in range(N): self.assertAlmostEqual(exp_P[i], m.P[i+1].value, delta=1e-3, msg=f'P[{i}]') self.assertAlmostEqual(exp_Q[i], m.Q[i+1].value, delta=1e-3, msg=f'Q[{i}]') self.assertAlmostEqual(exp_v[i], m.v[i+1].value, delta=1e-3, msg=f'v[{i}]') - self.assertAlmostEqual(exp_theta[i], m.theta[i+1].value * 180 / pi, delta=1e-3, msg=f'theta[{i}]') \ No newline at end of file + self.assertAlmostEqual(exp_theta[i], m.theta[i+1].value * 180 / pi, delta=1e-3, msg=f'theta[{i}]') From 5b4517751c35d527dbcb1509461f73baf2cef9db Mon Sep 17 00:00:00 2001 From: Emma Johnson <12833636+emma58@users.noreply.github.com> Date: Mon, 29 Sep 2025 21:03:26 -0600 Subject: [PATCH 062/103] NFC: black --- .../solver/tests/solvers/test_gurobi_minlp.py | 116 +++++++++++------- 1 file changed, 75 insertions(+), 41 deletions(-) diff --git a/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp.py b/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp.py index a7bff33d54d..5557b6b0beb 100644 --- a/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp.py +++ b/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp.py @@ -28,15 +28,13 @@ gurobi_direct = SolverFactory('gurobi_direct_minlp') -@unittest.skipUnless( - gurobi_direct.available(), - "needs Gurobi Direct MINLP interface", - ) + +@unittest.skipUnless(gurobi_direct.available(), "needs Gurobi Direct MINLP interface") class TestGurobiMINLP(unittest.TestCase): def test_gurobi_minlp_sincosexp(self): m = ConcreteModel(name="test") m.x = Var(bounds=(-1, 4)) - m.o = Objective(expr=sin(m.x) + cos(2*m.x) + 1) + m.o = Objective(expr=sin(m.x) + cos(2 * m.x) + 1) m.c = Constraint(expr=0.25 * exp(m.x) - m.x <= 0) gurobi_direct.solve(m) self.assertAlmostEqual(1, value(m.o), delta=1e-3) @@ -44,8 +42,8 @@ def test_gurobi_minlp_sincosexp(self): def test_gurobi_minlp_tan(self): m = ConcreteModel(name="test") - m.x = Var(bounds=(0, pi/2)) - m.o = Objective(expr=tan(m.x)/(m.x**2)) + m.x = Var(bounds=(0, pi / 2)) + m.o = Objective(expr=tan(m.x) / (m.x**2)) gurobi_direct.solve(m) self.assertAlmostEqual(0.948, value(m.x), delta=1e-3) self.assertAlmostEqual(1.549, value(m.o), delta=1e-3) @@ -53,7 +51,7 @@ def test_gurobi_minlp_tan(self): def test_gurobi_minlp_sqrt(self): m = ConcreteModel(name="test") m.x = Var(bounds=(0, 2)) - m.o = Objective(expr=sqrt(m.x)-(m.x**2)/3, sense=maximize) + m.o = Objective(expr=sqrt(m.x) - (m.x**2) / 3, sense=maximize) gurobi_direct.solve(m) self.assertAlmostEqual(0.825, value(m.x), delta=1e-3) self.assertAlmostEqual(0.681, value(m.o), delta=1e-3) @@ -61,7 +59,7 @@ def test_gurobi_minlp_sqrt(self): def test_gurobi_minlp_log(self): m = ConcreteModel(name="test") m.x = Var(bounds=(1, 2)) - m.o = Objective(expr=(m.x*m.x)/log(m.x)) + m.o = Objective(expr=(m.x * m.x) / log(m.x)) gurobi_direct.solve(m) self.assertAlmostEqual(1.396, value(m.x), delta=1e-3) self.assertAlmostEqual(8.155, value(m.o), delta=1e-3) @@ -69,7 +67,7 @@ def test_gurobi_minlp_log(self): def test_gurobi_minlp_log10(self): m = ConcreteModel(name="test") m.x = Var(bounds=(1, 2)) - m.o = Objective(expr=(m.x*m.x)/log10(m.x)) + m.o = Objective(expr=(m.x * m.x) / log10(m.x)) gurobi_direct.solve(m) self.assertAlmostEqual(1.649, value(m.x), delta=1e-3) self.assertAlmostEqual(12.518, value(m.o), delta=1e-3) @@ -78,8 +76,8 @@ def test_gurobi_minlp_sigmoid(self): m = ConcreteModel(name="test") m.x = Var(bounds=(0, 4)) m.y = Var(bounds=(0, 4)) - m.o = Objective(expr=m.y-m.x) - m.c = Constraint(expr=1/(1+exp(-m.x)) <= m.y) + m.o = Objective(expr=m.y - m.x) + m.c = Constraint(expr=1 / (1 + exp(-m.x)) <= m.y) gurobi_direct.solve(m) self.assertAlmostEqual(value(m.o), -3.017, delta=1e-3) @@ -93,47 +91,47 @@ def _build_divpwr_model(self, divide: bool, min: bool): if divide: obj = (1 - model.y) / model.x1 + model.y * y_mult / model.x2 else: - obj = (1 - model.y) * (model.x1 ** -1) + model.y * y_mult * (model.x2 ** -1) + obj = (1 - model.y) * (model.x1**-1) + model.y * y_mult * (model.x2**-1) if min: - model.OBJ = Objective(expr = -1 * obj, sense=minimize) + model.OBJ = Objective(expr=-1 * obj, sense=minimize) else: - model.OBJ = Objective(expr = obj, sense=maximize) + model.OBJ = Objective(expr=obj, sense=maximize) return model def test_gurobi_minlp_divpwr(self): params = [ - {"min": False, "divide": False, "obj": 13 }, - {"min": False, "divide": True, "obj": 13 }, - {"min": True, "divide": False, "obj": -13}, - {"min": True, "divide": True, "obj": -13}, + {"min": False, "divide": False, "obj": 13}, + {"min": False, "divide": True, "obj": 13}, + {"min": True, "divide": False, "obj": -13}, + {"min": True, "divide": True, "obj": -13}, ] for p in params: model = self._build_divpwr_model(p['divide'], p['min']) gurobi_direct.solve(model) self.assertEqual(p["obj"], value(model.OBJ)) self.assertEqual(1, model.y.value) - + def test_gurobi_minlp_acopf(self): - # Based on https://docs.gurobi.com/projects/examples/en/current/examples/python/acopf_4buses.html + # Based on https://docs.gurobi.com/projects/examples/en/current/examples/python/acopf_4buses.html # Number of Buses (Nodes) N = 4 # Conductance/susceptance components G = [ - [1.7647, -0.5882, 0.0, -1.1765], - [-0.5882, 1.5611, -0.3846, -0.5882], - [0.0, -0.3846, 1.5611, -1.1765], - [-1.1765, -0.5882, -1.1765, 2.9412], - ] + [1.7647, -0.5882, 0.0, -1.1765], + [-0.5882, 1.5611, -0.3846, -0.5882], + [0.0, -0.3846, 1.5611, -1.1765], + [-1.1765, -0.5882, -1.1765, 2.9412], + ] B = [ - [-7.0588, 2.3529, 0.0, 4.7059], - [2.3529, -6.629, 1.9231, 2.3529], - [0.0, 1.9231, -6.629, 4.7059], - [4.7059, 2.3529, 4.7059, -11.7647], - ] + [-7.0588, 2.3529, 0.0, 4.7059], + [2.3529, -6.629, 1.9231, 2.3529], + [0.0, 1.9231, -6.629, 4.7059], + [4.7059, 2.3529, 4.7059, -11.7647], + ] # Assign bounds where fixings are needed v_lb = [1.0, 0.0, 1.0, 0.0] @@ -151,7 +149,7 @@ def test_gurobi_minlp_acopf(self): exp_Q = [0.212, -0.2, 0.173, -0.15] m = ConcreteModel(name="acopf") - + m.P = VarList() m.Q = VarList() m.v = VarList() @@ -161,7 +159,7 @@ def test_gurobi_minlp_acopf(self): p = m.P.add() p.lb = P_lb[i] p.ub = P_ub[i] - + q = m.Q.add() q.lb = Q_lb[i] q.ub = Q_ub[i] @@ -179,14 +177,50 @@ def test_gurobi_minlp_acopf(self): m.define_P = ConstraintList() m.define_Q = ConstraintList() for i in range(N): - m.define_P.add(m.P[i+1] == m.v[i+1] * sum(m.v[j+1] * (G[i][j] * cos(m.theta[i+1] - m.theta[j+1]) + B[i][j] * sin(m.theta[i+1] - m.theta[j+1])) for j in range(N))) - m.define_Q.add(m.Q[i+1] == m.v[i+1] * sum(m.v[j+1] * (G[i][j] * sin(m.theta[i+1] - m.theta[j+1]) - B[i][j] * cos(m.theta[i+1] - m.theta[j+1])) for j in range(N))) - + m.define_P.add( + m.P[i + 1] + == m.v[i + 1] + * sum( + m.v[j + 1] + * ( + G[i][j] * cos(m.theta[i + 1] - m.theta[j + 1]) + + B[i][j] * sin(m.theta[i + 1] - m.theta[j + 1]) + ) + for j in range(N) + ) + ) + m.define_Q.add( + m.Q[i + 1] + == m.v[i + 1] + * sum( + m.v[j + 1] + * ( + G[i][j] * sin(m.theta[i + 1] - m.theta[j + 1]) + - B[i][j] * cos(m.theta[i + 1] - m.theta[j + 1]) + ) + for j in range(N) + ) + ) + results = gurobi_direct.solve(m, tee=True) - self.assertEqual(results.termination_condition, TerminationCondition.convergenceCriteriaSatisfied) + self.assertEqual( + results.termination_condition, + TerminationCondition.convergenceCriteriaSatisfied, + ) self.assertEqual(results.solution_status, SolutionStatus.optimal) for i in range(N): - self.assertAlmostEqual(exp_P[i], m.P[i+1].value, delta=1e-3, msg=f'P[{i}]') - self.assertAlmostEqual(exp_Q[i], m.Q[i+1].value, delta=1e-3, msg=f'Q[{i}]') - self.assertAlmostEqual(exp_v[i], m.v[i+1].value, delta=1e-3, msg=f'v[{i}]') - self.assertAlmostEqual(exp_theta[i], m.theta[i+1].value * 180 / pi, delta=1e-3, msg=f'theta[{i}]') + self.assertAlmostEqual( + exp_P[i], m.P[i + 1].value, delta=1e-3, msg=f'P[{i}]' + ) + self.assertAlmostEqual( + exp_Q[i], m.Q[i + 1].value, delta=1e-3, msg=f'Q[{i}]' + ) + self.assertAlmostEqual( + exp_v[i], m.v[i + 1].value, delta=1e-3, msg=f'v[{i}]' + ) + self.assertAlmostEqual( + exp_theta[i], + m.theta[i + 1].value * 180 / pi, + delta=1e-3, + msg=f'theta[{i}]', + ) From a5e3a949915d5305bb91aa06b40b3cb83c5cbfbd Mon Sep 17 00:00:00 2001 From: Emma Johnson <12833636+emma58@users.noreply.github.com> Date: Tue, 30 Sep 2025 11:56:22 -0600 Subject: [PATCH 063/103] Adding some more tolerance for one of the solvers tests --- pyomo/contrib/solver/tests/solvers/test_solvers.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyomo/contrib/solver/tests/solvers/test_solvers.py b/pyomo/contrib/solver/tests/solvers/test_solvers.py index 3341eb84eb0..1209678dba6 100644 --- a/pyomo/contrib/solver/tests/solvers/test_solvers.py +++ b/pyomo/contrib/solver/tests/solvers/test_solvers.py @@ -1424,8 +1424,8 @@ def test_fixed_vars_4( self.assertAlmostEqual(m.x.value, 2) m.y.unfix() res = opt.solve(m) - self.assertAlmostEqual(m.x.value, 2**0.5) - self.assertAlmostEqual(m.y.value, 2**0.5) + self.assertAlmostEqual(m.x.value, 2**0.5, delta=1e-3) + self.assertAlmostEqual(m.y.value, 2**0.5, delta=1e-3) @parameterized.expand(input=_load_tests(all_solvers)) def test_mutable_param_with_range( From 7bc0b7c6c38ad96c4649a045b0507a93d58ad19b Mon Sep 17 00:00:00 2001 From: Emma Johnson <12833636+emma58@users.noreply.github.com> Date: Tue, 30 Sep 2025 14:06:31 -0600 Subject: [PATCH 064/103] Fixing a bug where I need to explicitly define the auxiliary variables as unbounded (since they default to nonnegative) --- pyomo/contrib/solver/solvers/gurobi_direct_minlp.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/pyomo/contrib/solver/solvers/gurobi_direct_minlp.py b/pyomo/contrib/solver/solvers/gurobi_direct_minlp.py index 01724b729ab..d36dfbb82a7 100755 --- a/pyomo/contrib/solver/solvers/gurobi_direct_minlp.py +++ b/pyomo/contrib/solver/solvers/gurobi_direct_minlp.py @@ -316,6 +316,7 @@ def _handle_abs_constant(visitor, node, arg1): def _handle_abs_var(visitor, node, arg1): + # This auxiliary variable actually is non-negative, yay absolute value! aux_abs = visitor.grb_model.addVar() visitor.grb_model.addConstr(aux_abs == gurobipy.abs_(arg1[1])) @@ -324,8 +325,9 @@ def _handle_abs_var(visitor, node, arg1): def _handle_abs_expression(visitor, node, arg1): # we need auxiliary variable - aux_arg = visitor.grb_model.addVar() + aux_arg = visitor.grb_model.addVar(lb=-GRB.INFINITY, ub=GRB.INFINITY) visitor.grb_model.addConstr(aux_arg == arg1[1]) + # This one truly is non-negative because it's an absolute value aux_abs = visitor.grb_model.addVar() visitor.grb_model.addConstr(aux_abs == gurobipy.abs_(aux_arg)) @@ -489,7 +491,7 @@ def _create_gurobi_expression( if expr_type is not _GENERAL: return expr_type, grb_expr, False, None else: - aux = grb_model.addVar() + aux = grb_model.addVar(lb=-GRB.INFINITY, ub=GRB.INFINITY) return expr_type, grb_expr, True, aux def write(self, model, **options): From 4905284064c453091a94573b15182b9bf85c18e5 Mon Sep 17 00:00:00 2001 From: Emma Johnson <12833636+emma58@users.noreply.github.com> Date: Tue, 30 Sep 2025 14:30:26 -0600 Subject: [PATCH 065/103] The log test actually wasn't at the global min, I agree with Gurobi --- .../solver/tests/solvers/test_gurobi_minlp.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp.py b/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp.py index 5557b6b0beb..1218dfcd8de 100644 --- a/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp.py +++ b/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp.py @@ -1,4 +1,4 @@ -from math import pi +import math import pyomo.common.unittest as unittest from pyomo.contrib.solver.common.factory import SolverFactory from pyomo.contrib.solver.common.results import TerminationCondition, SolutionStatus @@ -42,7 +42,7 @@ def test_gurobi_minlp_sincosexp(self): def test_gurobi_minlp_tan(self): m = ConcreteModel(name="test") - m.x = Var(bounds=(0, pi / 2)) + m.x = Var(bounds=(0, math.pi / 2)) m.o = Objective(expr=tan(m.x) / (m.x**2)) gurobi_direct.solve(m) self.assertAlmostEqual(0.948, value(m.x), delta=1e-3) @@ -61,8 +61,8 @@ def test_gurobi_minlp_log(self): m.x = Var(bounds=(1, 2)) m.o = Objective(expr=(m.x * m.x) / log(m.x)) gurobi_direct.solve(m) - self.assertAlmostEqual(1.396, value(m.x), delta=1e-3) - self.assertAlmostEqual(8.155, value(m.o), delta=1e-3) + self.assertAlmostEqual(sqrt(math.e), value(m.x), delta=1e-3) + self.assertAlmostEqual(2 * math.e, value(m.o), delta=1e-3) def test_gurobi_minlp_log10(self): m = ConcreteModel(name="test") @@ -140,8 +140,8 @@ def test_gurobi_minlp_acopf(self): P_ub = [3.0, -0.3, 0.3, -0.2] Q_lb = [-3.0, -0.2, -3.0, -0.15] Q_ub = [3.0, -0.2, 3.0, -0.15] - theta_lb = [0.0, -pi / 2, -pi / 2, -pi / 2] - theta_ub = [0.0, pi / 2, pi / 2, pi / 2] + theta_lb = [0.0, -math.pi / 2, -math.pi / 2, -math.pi / 2] + theta_ub = [0.0, math.pi / 2, math.pi / 2, math.pi / 2] exp_v = [1.0, 0.949, 1.0, 0.973] exp_theta = [0.0, -2.176, 1.046, -0.768] @@ -220,7 +220,7 @@ def test_gurobi_minlp_acopf(self): ) self.assertAlmostEqual( exp_theta[i], - m.theta[i + 1].value * 180 / pi, + m.theta[i + 1].value * 180 / math.pi, delta=1e-3, msg=f'theta[{i}]', ) From 409d32d21a76e94a340b113788ad3789a9ed6245 Mon Sep 17 00:00:00 2001 From: Emma Johnson <12833636+emma58@users.noreply.github.com> Date: Wed, 1 Oct 2025 09:59:27 -0600 Subject: [PATCH 066/103] debuging... --- pyomo/contrib/solver/solvers/gurobi_direct_minlp.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pyomo/contrib/solver/solvers/gurobi_direct_minlp.py b/pyomo/contrib/solver/solvers/gurobi_direct_minlp.py index d36dfbb82a7..9868bcf5682 100755 --- a/pyomo/contrib/solver/solvers/gurobi_direct_minlp.py +++ b/pyomo/contrib/solver/solvers/gurobi_direct_minlp.py @@ -595,6 +595,10 @@ def write(self, model, **options): pyo_cons.append(cons) else: if cons.lb is not None: + print(lb <= expr) + print(type(lb <= expr)) + print(type(lb)) + print(type(expr)) grb_cons.append(grb_model.addConstr(lb <= expr)) pyo_cons.append(cons) if cons.ub is not None: From 6e20d03b7e3705c2dcebaf0daa3764b624b98b06 Mon Sep 17 00:00:00 2001 From: Emma Johnson <12833636+emma58@users.noreply.github.com> Date: Wed, 1 Oct 2025 14:13:20 -0600 Subject: [PATCH 067/103] Adding more tolerance to another test that is currently failing on GHA --- pyomo/contrib/solver/tests/solvers/test_solvers.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyomo/contrib/solver/tests/solvers/test_solvers.py b/pyomo/contrib/solver/tests/solvers/test_solvers.py index 1209678dba6..b29c5624809 100644 --- a/pyomo/contrib/solver/tests/solvers/test_solvers.py +++ b/pyomo/contrib/solver/tests/solvers/test_solvers.py @@ -1593,8 +1593,8 @@ def test_exp(self, name: str, opt_class: Type[SolverBase], use_presolve: bool): m.obj = pyo.Objective(expr=m.x**2 + m.y**2) m.c1 = pyo.Constraint(expr=m.y >= pyo.exp(m.x)) res = opt.solve(m) - self.assertAlmostEqual(m.x.value, -0.42630274815985264) - self.assertAlmostEqual(m.y.value, 0.6529186341994245) + self.assertAlmostEqual(m.x.value, -0.42630274815985264, delta=1e-3) + self.assertAlmostEqual(m.y.value, 0.6529186341994245, delta=1e-3) @parameterized.expand(input=_load_tests(nlp_solvers)) def test_log(self, name: str, opt_class: Type[SolverBase], use_presolve: bool): From 9e56f8327de2de244f8665762829aaa8aca0908b Mon Sep 17 00:00:00 2001 From: Emma Johnson <12833636+emma58@users.noreply.github.com> Date: Wed, 1 Oct 2025 14:14:18 -0600 Subject: [PATCH 068/103] More debugging--I can't seem to reproduce this numpy thing locally, even matching version... --- pyomo/contrib/solver/solvers/gurobi_direct_minlp.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/pyomo/contrib/solver/solvers/gurobi_direct_minlp.py b/pyomo/contrib/solver/solvers/gurobi_direct_minlp.py index 9868bcf5682..4006a617e07 100755 --- a/pyomo/contrib/solver/solvers/gurobi_direct_minlp.py +++ b/pyomo/contrib/solver/solvers/gurobi_direct_minlp.py @@ -595,9 +595,11 @@ def write(self, model, **options): pyo_cons.append(cons) else: if cons.lb is not None: - print(lb <= expr) - print(type(lb <= expr)) + # print(lb <= expr) + # print(type(lb <= expr)) + print(lb) print(type(lb)) + print(expr) print(type(expr)) grb_cons.append(grb_model.addConstr(lb <= expr)) pyo_cons.append(cons) From 9651d0cea10e8082e7b627442db353420205e966 Mon Sep 17 00:00:00 2001 From: Emma Johnson <12833636+emma58@users.noreply.github.com> Date: Thu, 2 Oct 2025 08:46:21 -0600 Subject: [PATCH 069/103] Removing debugging--no idea what's going on still --- pyomo/contrib/solver/solvers/gurobi_direct_minlp.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/pyomo/contrib/solver/solvers/gurobi_direct_minlp.py b/pyomo/contrib/solver/solvers/gurobi_direct_minlp.py index 4006a617e07..d36dfbb82a7 100755 --- a/pyomo/contrib/solver/solvers/gurobi_direct_minlp.py +++ b/pyomo/contrib/solver/solvers/gurobi_direct_minlp.py @@ -595,12 +595,6 @@ def write(self, model, **options): pyo_cons.append(cons) else: if cons.lb is not None: - # print(lb <= expr) - # print(type(lb <= expr)) - print(lb) - print(type(lb)) - print(expr) - print(type(expr)) grb_cons.append(grb_model.addConstr(lb <= expr)) pyo_cons.append(cons) if cons.ub is not None: From 864b98aa3e3104394ddaca88fb924a7f3af2dc69 Mon Sep 17 00:00:00 2001 From: Emma Johnson <12833636+emma58@users.noreply.github.com> Date: Thu, 2 Oct 2025 20:47:13 -0600 Subject: [PATCH 070/103] Fixing a bug with older versions of numpy where we hit the wrong operator overloading--putting the gurobi expressions first when constructing constraints --- pyomo/contrib/solver/solvers/gurobi_direct_minlp.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pyomo/contrib/solver/solvers/gurobi_direct_minlp.py b/pyomo/contrib/solver/solvers/gurobi_direct_minlp.py index d36dfbb82a7..1198fc59dcb 100755 --- a/pyomo/contrib/solver/solvers/gurobi_direct_minlp.py +++ b/pyomo/contrib/solver/solvers/gurobi_direct_minlp.py @@ -591,14 +591,14 @@ def write(self, model, **options): if ub is not None: ub = float(ub) if cons.equality: - grb_cons.append(grb_model.addConstr(lb == expr)) + grb_cons.append(grb_model.addConstr(expr == lb)) pyo_cons.append(cons) else: if cons.lb is not None: - grb_cons.append(grb_model.addConstr(lb <= expr)) + grb_cons.append(grb_model.addConstr(expr >= lb)) pyo_cons.append(cons) if cons.ub is not None: - grb_cons.append(grb_model.addConstr(ub >= expr)) + grb_cons.append(grb_model.addConstr(expr <= ub)) pyo_cons.append(cons) grb_model.update() From f017b6ddf81eaeb006779be6dc3dfde1ab90e9f3 Mon Sep 17 00:00:00 2001 From: Emma Johnson <12833636+emma58@users.noreply.github.com> Date: Thu, 2 Oct 2025 20:58:06 -0600 Subject: [PATCH 071/103] Unix style line endings, whoops --- .../solver/solvers/gurobi_direct_minlp.py | 1378 ++++++++--------- .../tests/solvers/test_gurobi_minlp_walker.py | 1208 +++++++-------- 2 files changed, 1293 insertions(+), 1293 deletions(-) diff --git a/pyomo/contrib/solver/solvers/gurobi_direct_minlp.py b/pyomo/contrib/solver/solvers/gurobi_direct_minlp.py index 1198fc59dcb..25229219d69 100755 --- a/pyomo/contrib/solver/solvers/gurobi_direct_minlp.py +++ b/pyomo/contrib/solver/solvers/gurobi_direct_minlp.py @@ -1,689 +1,689 @@ -# ___________________________________________________________________________ -# -# Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 -# National Technology and Engineering Solutions of Sandia, LLC -# Under the terms of Contract DE-NA0003525 with National Technology and -# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain -# rights in this software. -# This software is distributed under the 3-clause BSD License. -# ___________________________________________________________________________ - - -## TODO - -# Look into if I can piggyback off of ipopt writer and just plug in my walker -# Why did I have to make a custom solution loader? -# Move into contrib.solver: doc/onlinedoc/explanation/experimental has information about future solvers. Put some docs here. -# Is there a half-matrix half-explicit way to give MINLPs to Gurobi? Soren thinks yes... -# Open a PR into Miranda's fork. - -import datetime -import io -from operator import attrgetter, itemgetter - -from pyomo.common.dependencies import attempt_import -from pyomo.common.collections import ComponentMap, ComponentSet -from pyomo.common.config import ConfigDict, ConfigValue -from pyomo.common.errors import InvalidValueError -from pyomo.common.numeric_types import native_complex_types -from pyomo.common.timing import HierarchicalTimer - -# ESJ TODO: We should move this somewhere sensible -from pyomo.contrib.cp.repn.docplex_writer import collect_valid_components -from pyomo.contrib.solver.common.factory import SolverFactory -from pyomo.contrib.solver.common.solution_loader import SolutionLoaderBase -from pyomo.contrib.solver.common.util import NoSolutionError -from pyomo.contrib.solver.solvers.gurobi_direct import ( - GurobiDirect, - GurobiDirectSolutionLoader, -) - -from pyomo.core.base import ( - Binary, - Block, - BooleanVar, - Constraint, - Expression, - Integers, - minimize, - maximize, - NonNegativeIntegers, - NonNegativeReals, - NonPositiveIntegers, - NonPositiveReals, - Objective, - Param, - Reals, - SortComponents, - Suffix, - Var, - value, -) -import pyomo.core.expr as EXPR -from pyomo.core.expr.numeric_expr import ( - NegationExpression, - ProductExpression, - DivisionExpression, - PowExpression, - AbsExpression, - UnaryFunctionExpression, - Expr_ifExpression, - LinearExpression, - MonomialTermExpression, - SumExpression, -) -from pyomo.core.expr.visitor import StreamBasedExpressionVisitor, _EvaluationVisitor -from pyomo.core.staleflag import StaleFlagManager - -from pyomo.opt import WriterFactory -from pyomo.repn.quadratic import QuadraticRepnVisitor -from pyomo.repn.util import ( - apply_node_operation, - ExprType, - ExitNodeDispatcher, - BeforeChildDispatcher, - complex_number_error, - initialize_exit_node_dispatcher, - InvalidNumber, - nan, - OrderedVarRecorder, -) - -import sys - -## DEBUG -from pytest import set_trace - -""" -Even in Gurobi 12: - -If you have f(x) == 0, you must write it as z == f(x) and then write z == 0. -Basically, you must introduce auxiliary variables for all the general nonlinear -parts. (And no worries about additively separable or anything--they do that -under the hood). - -Radhakrishna thinks we should replace the *entire* LHS of the constraint with the -auxiliary variable rather than just the nonlinear part. Otherwise we would really -need to keep track of what nonlinear subexpressions we had already replaced and make -sure to use the same auxiliary variables. - -Conclusion: So I think I should actually build on top of the linear walker and then -replace anything that has a nonlinear part... - -Model.addConstr() doesn't have the three-arg version anymore. - -Let's not use the '.nl' attribute at all for now--seems like the exception rather than -the rule that you would want to specifically tell Gurobi *not* to expand the expression. -""" - -_CONSTANT = ExprType.CONSTANT -_GENERAL = ExprType.GENERAL -_LINEAR = ExprType.LINEAR -_QUADRATIC = ExprType.QUADRATIC -_VARIABLE = ExprType.VARIABLE - -_function_map = {} - -gurobipy, gurobipy_available = attempt_import('gurobipy', minimum_version='12.0.0') -if gurobipy_available: - from gurobipy import GRB, nlfunc - - _function_map.update( - { - 'exp': (_GENERAL, nlfunc.exp), - 'log': (_GENERAL, nlfunc.log), - 'log10': (_GENERAL, nlfunc.log10), - 'sin': (_GENERAL, nlfunc.sin), - 'cos': (_GENERAL, nlfunc.cos), - 'tan': (_GENERAL, nlfunc.tan), - 'sqrt': (_GENERAL, nlfunc.sqrt), - # Not supporting any of these right now--we'd have to build them from the - # above: - # 'asin': None, - # 'sinh': None, - # 'asinh': None, - # 'acos': None, - # 'cosh': None, - # 'acosh': None, - # 'atan': None, - # 'tanh': None, - # 'atanh': None, - # 'ceil': None, - # 'floor': None, - } - ) - -### FIXME: Remove the following as soon as non-active components no -### longer report active==True -from pyomo.network import Port -from pyomo.core.base import RangeSet, Set - -### - - -_domain_map = ComponentMap( - ( - (Binary, (GRB.BINARY, -float('inf'), float('inf'))), - (Integers, (GRB.INTEGER, -float('inf'), float('inf'))), - (NonNegativeIntegers, (GRB.INTEGER, 0, float('inf'))), - (NonPositiveIntegers, (GRB.INTEGER, -float('inf'), 0)), - (NonNegativeReals, (GRB.CONTINUOUS, 0, float('inf'))), - (NonPositiveReals, (GRB.CONTINUOUS, -float('inf'), 0)), - (Reals, (GRB.CONTINUOUS, -float('inf'), float('inf'))), - ) -) - - -def _create_grb_var(visitor, pyomo_var, name=""): - pyo_domain = pyomo_var.domain - if pyo_domain in _domain_map: - domain, domain_lb, domain_ub = _domain_map[pyo_domain] - else: - raise ValueError( - "Unsupported domain for Var '%s': %s" % (pyomo_var.name, pyo_domain) - ) - lb = max(domain_lb, pyomo_var.lb) if pyomo_var.lb is not None else domain_lb - ub = min(domain_ub, pyomo_var.ub) if pyomo_var.ub is not None else domain_ub - return visitor.grb_model.addVar(lb=lb, ub=ub, vtype=domain, name=name) - - -class GurobiMINLPBeforeChildDispatcher(BeforeChildDispatcher): - @staticmethod - def _before_var(visitor, child): - if child not in visitor.var_map: - if child.fixed: - # ESJ TODO: I want the linear walker implementation of - # check_constant... Could it be in the base class or something? - return False, (_CONSTANT, visitor.check_constant(child.value, child)) - grb_var = _create_grb_var( - visitor, - child, - name=child.name if visitor.symbolic_solver_labels else "", - ) - visitor.var_map[child] = grb_var - return False, (_VARIABLE, visitor.var_map[child]) - - @staticmethod - def _before_named_expression(visitor, child): - _id = id(child) - if _id in visitor.subexpression_cache: - _type, expr = visitor.subexpression_cache[_id] - return False, (_type, expr) - else: - return True, None - - -def _handle_node_with_eval_expr_visitor_invariant(visitor, node, data): - """ - Calls expression evaluation visitor on nodes that have an invariant - expression type in the return. - """ - return (data[0], visitor._eval_expr_visitor.visit(node, (data[1],))) - - -def _handle_node_with_eval_expr_visitor_unknown(visitor, node, *data): - # The expression type is whatever the highest one of the incoming arguments - # was. - expr_type = max(map(itemgetter(0), data)) - return ( - expr_type, - visitor._eval_expr_visitor.visit(node, tuple(map(itemgetter(1), data))), - ) - - -def _handle_node_with_eval_expr_visitor_constant(visitor, node, *data): - return ( - _CONSTANT, - visitor._eval_expr_visitor.visit(node, tuple(map(itemgetter(1), data))), - ) - - -def _handle_node_with_eval_expr_visitor_linear(visitor, node, *data): - return ( - _LINEAR, - visitor._eval_expr_visitor.visit(node, tuple(map(itemgetter(1), data))), - ) - - -def _handle_node_with_eval_expr_visitor_quadratic(visitor, node, *data): - return ( - _QUADRATIC, - visitor._eval_expr_visitor.visit(node, tuple(map(itemgetter(1), data))), - ) - - -def _handle_node_with_eval_expr_visitor_nonlinear(visitor, node, *data): - # ESJ: _apply_operation for DivisionExpression expects that result is indexed, so - # I'm making it a tuple rather than a map. - return ( - _GENERAL, - visitor._eval_expr_visitor.visit(node, tuple(map(itemgetter(1), data))), - ) - - -def _handle_linear_constant_pow_expr(visitor, node, arg1, arg2): - expr_type = _GENERAL - if arg2[1] == 1: - expr_type = _LINEAR - if arg2[1] == 2: - expr_type = _QUADRATIC - return ( - expr_type, - visitor._eval_expr_visitor.visit(node, tuple(map(itemgetter(1), (arg1, arg2)))), - ) - - -def _handle_quadratic_constant_pow_expr(visitor, node, arg1, arg2): - expr_type = _GENERAL - if arg2[1] == 1: - expr_type = _QUADRATIC - return ( - expr_type, - visitor._eval_expr_visitor.visit(node, tuple(map(itemgetter(1), (arg1, arg2)))), - ) - - -def _handle_unary(visitor, node, data): - if node._name in _function_map: - expr_type, fcn = _function_map[node._name] - return expr_type, fcn(data[1]) - raise ValueError( - "The unary function '%s' is not supported by the Gurobi MINLP writer." - % node._name - ) - - -def _handle_unary_constant(visitor, node, data): - try: - return _CONSTANT, node._fcn(value(data[1])) - except: - raise InvalidValueError( - f"Invalid number encountered evaluating constant unary expression " - f"{node}: {sys.exc_info()[1]}" - ) - - -def _handle_named_expression(visitor, node, arg1): - # Record this common expression - visitor.subexpression_cache[id(node)] = arg1 - _type, arg1 = arg1 - return _type, arg1 - - -def _handle_abs_constant(visitor, node, arg1): - return (_CONSTANT, abs(arg1[1])) - - -def _handle_abs_var(visitor, node, arg1): - # This auxiliary variable actually is non-negative, yay absolute value! - aux_abs = visitor.grb_model.addVar() - visitor.grb_model.addConstr(aux_abs == gurobipy.abs_(arg1[1])) - - return (_VARIABLE, aux_abs) - - -def _handle_abs_expression(visitor, node, arg1): - # we need auxiliary variable - aux_arg = visitor.grb_model.addVar(lb=-GRB.INFINITY, ub=GRB.INFINITY) - visitor.grb_model.addConstr(aux_arg == arg1[1]) - # This one truly is non-negative because it's an absolute value - aux_abs = visitor.grb_model.addVar() - visitor.grb_model.addConstr(aux_abs == gurobipy.abs_(aux_arg)) - - return (_VARIABLE, aux_abs) - - -def define_exit_node_handlers(_exit_node_handlers=None): - if _exit_node_handlers is None: - _exit_node_handlers = {} - - # We can rely on operator overloading for many, but not all expressions. - _exit_node_handlers[SumExpression] = { - None: _handle_node_with_eval_expr_visitor_unknown - } - _exit_node_handlers[LinearExpression] = { - # Can come back LINEAR or CONSTANT, so we use the 'unknown' version - None: _handle_node_with_eval_expr_visitor_unknown - } - _exit_node_handlers[NegationExpression] = { - None: _handle_node_with_eval_expr_visitor_invariant - } - _exit_node_handlers[ProductExpression] = { - None: _handle_node_with_eval_expr_visitor_nonlinear, - (_CONSTANT, _CONSTANT): _handle_node_with_eval_expr_visitor_constant, - (_CONSTANT, _LINEAR): _handle_node_with_eval_expr_visitor_linear, - (_CONSTANT, _QUADRATIC): _handle_node_with_eval_expr_visitor_quadratic, - (_CONSTANT, _VARIABLE): _handle_node_with_eval_expr_visitor_linear, - (_LINEAR, _CONSTANT): _handle_node_with_eval_expr_visitor_linear, - (_LINEAR, _LINEAR): _handle_node_with_eval_expr_visitor_quadratic, - (_LINEAR, _VARIABLE): _handle_node_with_eval_expr_visitor_quadratic, - (_VARIABLE, _CONSTANT): _handle_node_with_eval_expr_visitor_linear, - (_VARIABLE, _LINEAR): _handle_node_with_eval_expr_visitor_quadratic, - (_VARIABLE, _VARIABLE): _handle_node_with_eval_expr_visitor_quadratic, - } - _exit_node_handlers[MonomialTermExpression] = _exit_node_handlers[ProductExpression] - _exit_node_handlers[DivisionExpression] = { - None: _handle_node_with_eval_expr_visitor_nonlinear, - (_CONSTANT, _CONSTANT): _handle_node_with_eval_expr_visitor_constant, - (_LINEAR, _CONSTANT): _handle_node_with_eval_expr_visitor_linear, - (_VARIABLE, _CONSTANT): _handle_node_with_eval_expr_visitor_linear, - (_QUADRATIC, _CONSTANT): _handle_node_with_eval_expr_visitor_quadratic, - } - _exit_node_handlers[PowExpression] = { - None: _handle_node_with_eval_expr_visitor_nonlinear, - (_CONSTANT, _CONSTANT): _handle_node_with_eval_expr_visitor_constant, - (_VARIABLE, _CONSTANT): _handle_linear_constant_pow_expr, - (_LINEAR, _CONSTANT): _handle_linear_constant_pow_expr, - (_QUADRATIC, _CONSTANT): _handle_quadratic_constant_pow_expr, - } - _exit_node_handlers[UnaryFunctionExpression] = { - None: _handle_unary, - (_CONSTANT,): _handle_unary_constant, - } - - ## TODO: ExprIf, RangedExpressions (if we do exprif... - _exit_node_handlers[Expression] = {None: _handle_named_expression} - - # These are special because of quirks of Gurobi's current support for general - # nonlinear: - _exit_node_handlers[AbsExpression] = { - None: _handle_abs_expression, - (_CONSTANT,): _handle_abs_constant, - (_VARIABLE,): _handle_abs_var, - } - - return _exit_node_handlers - - -class GurobiMINLPVisitor(StreamBasedExpressionVisitor): - before_child_dispatcher = GurobiMINLPBeforeChildDispatcher() - exit_node_dispatcher = ExitNodeDispatcher( - initialize_exit_node_dispatcher(define_exit_node_handlers()) - ) - - def __init__(self, grb_model, symbolic_solver_labels=False): - super().__init__() - self.grb_model = grb_model - self.symbolic_solver_labels = symbolic_solver_labels - self.var_map = ComponentMap() - self.subexpression_cache = {} - self._eval_expr_visitor = _EvaluationVisitor(True) - self.evaluate = self._eval_expr_visitor.dfs_postorder_stack - - def initializeWalker(self, expr): - walk, result = self.beforeChild(None, expr, 0) - if not walk: - return False, self.finalizeResult(result) - return True, expr - - def beforeChild(self, node, child, child_idx): - return self.before_child_dispatcher[child.__class__](self, child) - - def exitNode(self, node, data): - return self.exit_node_dispatcher[(node.__class__, *map(itemgetter(0), data))]( - self, node, *data - ) - - def finalizeResult(self, result): - return result - - # ESJ TODO: THIS IS COPIED FROM THE LINEAR WALKER--CAN WE PUT IT IN UTIL OR - # SOMETHING? - def check_constant(self, ans, obj): - if ans.__class__ not in EXPR.native_numeric_types: - # None can be returned from uninitialized Var/Param objects - if ans is None: - return InvalidNumber( - None, f"'{obj}' evaluated to a nonnumeric value '{ans}'" - ) - if ans.__class__ is InvalidNumber: - return ans - elif ans.__class__ in native_complex_types: - return complex_number_error(ans, self, obj) - else: - # It is possible to get other non-numeric types. Most - # common are bool and 1-element numpy.array(). We will - # attempt to convert the value to a float before - # proceeding. - # - # TODO: we should check bool and warn/error (while bool is - # convertible to float in Python, they have very - # different semantic meanings in Pyomo). - try: - ans = float(ans) - except: - return InvalidNumber( - ans, f"'{obj}' evaluated to a nonnumeric value '{ans}'" - ) - if ans != ans: - return InvalidNumber( - nan, f"'{obj}' evaluated to a nonnumeric value '{ans}'" - ) - return ans - - -@WriterFactory.register( - 'gurobi_minlp', - 'Direct interface to Gurobi that allows for general nonlinear expressions', -) -class GurobiMINLPWriter: - CONFIG = ConfigDict('gurobi_minlp_writer') - CONFIG.declare( - 'symbolic_solver_labels', - ConfigValue( - default=False, - domain=bool, - description='Write Pyomo Var and Constraint names to Gurobi model', - ), - ) - - def __init__(self): - self.config = self.CONFIG() - - def _create_gurobi_expression( - self, expr, src, src_index, grb_model, quadratic_visitor, grb_visitor - ): - """ - Returns a gurobipy representation of the expression - """ - expr_type, grb_expr = grb_visitor.walk_expression(expr) - if expr_type is not _GENERAL: - return expr_type, grb_expr, False, None - else: - aux = grb_model.addVar(lb=-GRB.INFINITY, ub=GRB.INFINITY) - return expr_type, grb_expr, True, aux - - def write(self, model, **options): - config = options.pop('config', self.config)(options) - - components, unknown = collect_valid_components( - model, - active=True, - sort=SortComponents.deterministic, - valid={ - Block, - Objective, - Constraint, - Expression, - Var, - BooleanVar, - Param, - Suffix, - # FIXME: Non-active components should not report as Active - Set, - RangeSet, - Port, - }, - targets={Objective, Constraint}, - ) - if unknown: - raise ValueError( - "The model ('%s') contains the following active components " - "that the Gurobi MINLP writer does not know how to " - "process:\n\t%s" - % ( - model.name, - "\n\t".join( - "%s:\n\t\t%s" % (k, "\n\t\t".join(map(attrgetter('name'), v))) - for k, v in unknown.items() - ), - ) - ) - - # Get a quadratic walker instance - quadratic_visitor = QuadraticRepnVisitor( - subexpression_cache={}, var_recorder=OrderedVarRecorder({}, {}, None) - ) - - # create Gurobi model - grb_model = gurobipy.Model() - visitor = GurobiMINLPVisitor( - grb_model, symbolic_solver_labels=config.symbolic_solver_labels - ) - - active_objs = components[Objective] - if len(active_objs) > 1: - raise ValueError( - "More than one active objective defined for " - "input model '%s': Cannot write to gurobipy." % model.name - ) - elif len(active_objs) == 1: - obj = active_objs[0] - pyo_obj = [obj] - if obj.sense is minimize: - sense = GRB.MINIMIZE - else: - sense = GRB.MAXIMIZE - expr_type, obj_expr, nonlinear, aux = self._create_gurobi_expression( - obj.expr, obj, 0, grb_model, quadratic_visitor, visitor - ) - if nonlinear: - # The objective must be linear or quadratic, so we move the nonlinear - # one to the constraints - grb_model.setObjective(aux, sense=sense) - grb_model.addConstr(aux == obj_expr) - else: - grb_model.setObjective(obj_expr, sense=sense) - # else it's fine--Gurobi doesn't require us to give an objective, so we don't - # either, but we do have to pass the info through for the results object - else: - pyo_obj = [] - - # write constraints - pyo_cons = [] - grb_cons = [] - for cons in components[Constraint]: - expr_type, expr, nonlinear, aux = self._create_gurobi_expression( - cons.body, cons, 0, grb_model, quadratic_visitor, visitor - ) - if nonlinear: - grb_model.addConstr(aux == expr) - expr = aux - lb = value(cons.lb) - ub = value(cons.ub) - if expr_type == _CONSTANT: - # cast everything to a float in case there are numpy - # types because you can't do addConstr(np.True_) - expr = float(expr) - if lb is not None: - lb = float(lb) - if ub is not None: - ub = float(ub) - if cons.equality: - grb_cons.append(grb_model.addConstr(expr == lb)) - pyo_cons.append(cons) - else: - if cons.lb is not None: - grb_cons.append(grb_model.addConstr(expr >= lb)) - pyo_cons.append(cons) - if cons.ub is not None: - grb_cons.append(grb_model.addConstr(expr <= ub)) - pyo_cons.append(cons) - - grb_model.update() - return grb_model, visitor.var_map, pyo_obj, grb_cons, pyo_cons - - -@SolverFactory.register( - 'gurobi_direct_minlp', - doc='Direct interface to Gurobi version 12 and up ' - 'supporting general nonlinear expressions', -) -class GurobiDirectMINLP(GurobiDirect): - def solve(self, model, **kwds): - """Solve the model. - - Args: - model (Block): a Pyomo model or Block to be solved - """ - start_timestamp = datetime.datetime.now(datetime.timezone.utc) - config = self.config(value=kwds, preserve_implicit=True) - if not self.available(): - c = self.__class__ - raise ApplicationError( - f'Solver {c.__module__}.{c.__qualname__} is not available ' - f'({self.available()}).' - ) - if config.timer is None: - config.timer = HierarchicalTimer() - timer = config.timer - - StaleFlagManager.mark_all_as_stale() - - timer.start('compile_model') - - writer = GurobiMINLPWriter() - grb_model, var_map, pyo_obj, grb_cons, pyo_cons = writer.write( - model, symbolic_solver_labels=config.symbolic_solver_labels - ) - - timer.stop('compile_model') - - ostreams = [io.StringIO()] + config.tee - - # set options - options = config.solver_options - - grb_model.setParam('LogToConsole', 1) - - if config.threads is not None: - grb_model.setParam('Threads', config.threads) - if config.time_limit is not None: - grb_model.setParam('TimeLimit', config.time_limit) - if config.rel_gap is not None: - grb_model.setParam('MIPGap', config.rel_gap) - if config.abs_gap is not None: - grb_model.setParam('MIPGapAbs', config.abs_gap) - - if config.use_mipstart: - raise MouseTrap("MIPSTART not yet supported") - - for key, option in options.items(): - grb_model.setParam(key, option) - - grbsol = grb_model.optimize() - - res = self._postsolve( - timer, - config, - GurobiDirectSolutionLoader( - grb_model, - grb_cons=grb_cons, - grb_vars=var_map.values(), - pyo_cons=pyo_cons, - pyo_vars=var_map.keys(), - pyo_obj=pyo_obj, - ), - ) - - res.solver_config = config - res.solver_name = 'Gurobi' - res.solver_version = self.version() - res.solver_log = ostreams[0].getvalue() - - end_timestamp = datetime.datetime.now(datetime.timezone.utc) - res.timing_info.start_timestamp = start_timestamp - res.timing_info.wall_time = (end_timestamp - start_timestamp).total_seconds() - res.timing_info.timer = timer - return res +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2024 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + + +## TODO + +# Look into if I can piggyback off of ipopt writer and just plug in my walker +# Why did I have to make a custom solution loader? +# Move into contrib.solver: doc/onlinedoc/explanation/experimental has information about future solvers. Put some docs here. +# Is there a half-matrix half-explicit way to give MINLPs to Gurobi? Soren thinks yes... +# Open a PR into Miranda's fork. + +import datetime +import io +from operator import attrgetter, itemgetter + +from pyomo.common.dependencies import attempt_import +from pyomo.common.collections import ComponentMap, ComponentSet +from pyomo.common.config import ConfigDict, ConfigValue +from pyomo.common.errors import InvalidValueError +from pyomo.common.numeric_types import native_complex_types +from pyomo.common.timing import HierarchicalTimer + +# ESJ TODO: We should move this somewhere sensible +from pyomo.contrib.cp.repn.docplex_writer import collect_valid_components +from pyomo.contrib.solver.common.factory import SolverFactory +from pyomo.contrib.solver.common.solution_loader import SolutionLoaderBase +from pyomo.contrib.solver.common.util import NoSolutionError +from pyomo.contrib.solver.solvers.gurobi_direct import ( + GurobiDirect, + GurobiDirectSolutionLoader, +) + +from pyomo.core.base import ( + Binary, + Block, + BooleanVar, + Constraint, + Expression, + Integers, + minimize, + maximize, + NonNegativeIntegers, + NonNegativeReals, + NonPositiveIntegers, + NonPositiveReals, + Objective, + Param, + Reals, + SortComponents, + Suffix, + Var, + value, +) +import pyomo.core.expr as EXPR +from pyomo.core.expr.numeric_expr import ( + NegationExpression, + ProductExpression, + DivisionExpression, + PowExpression, + AbsExpression, + UnaryFunctionExpression, + Expr_ifExpression, + LinearExpression, + MonomialTermExpression, + SumExpression, +) +from pyomo.core.expr.visitor import StreamBasedExpressionVisitor, _EvaluationVisitor +from pyomo.core.staleflag import StaleFlagManager + +from pyomo.opt import WriterFactory +from pyomo.repn.quadratic import QuadraticRepnVisitor +from pyomo.repn.util import ( + apply_node_operation, + ExprType, + ExitNodeDispatcher, + BeforeChildDispatcher, + complex_number_error, + initialize_exit_node_dispatcher, + InvalidNumber, + nan, + OrderedVarRecorder, +) + +import sys + +## DEBUG +from pytest import set_trace + +""" +Even in Gurobi 12: + +If you have f(x) == 0, you must write it as z == f(x) and then write z == 0. +Basically, you must introduce auxiliary variables for all the general nonlinear +parts. (And no worries about additively separable or anything--they do that +under the hood). + +Radhakrishna thinks we should replace the *entire* LHS of the constraint with the +auxiliary variable rather than just the nonlinear part. Otherwise we would really +need to keep track of what nonlinear subexpressions we had already replaced and make +sure to use the same auxiliary variables. + +Conclusion: So I think I should actually build on top of the linear walker and then +replace anything that has a nonlinear part... + +Model.addConstr() doesn't have the three-arg version anymore. + +Let's not use the '.nl' attribute at all for now--seems like the exception rather than +the rule that you would want to specifically tell Gurobi *not* to expand the expression. +""" + +_CONSTANT = ExprType.CONSTANT +_GENERAL = ExprType.GENERAL +_LINEAR = ExprType.LINEAR +_QUADRATIC = ExprType.QUADRATIC +_VARIABLE = ExprType.VARIABLE + +_function_map = {} + +gurobipy, gurobipy_available = attempt_import('gurobipy', minimum_version='12.0.0') +if gurobipy_available: + from gurobipy import GRB, nlfunc + + _function_map.update( + { + 'exp': (_GENERAL, nlfunc.exp), + 'log': (_GENERAL, nlfunc.log), + 'log10': (_GENERAL, nlfunc.log10), + 'sin': (_GENERAL, nlfunc.sin), + 'cos': (_GENERAL, nlfunc.cos), + 'tan': (_GENERAL, nlfunc.tan), + 'sqrt': (_GENERAL, nlfunc.sqrt), + # Not supporting any of these right now--we'd have to build them from the + # above: + # 'asin': None, + # 'sinh': None, + # 'asinh': None, + # 'acos': None, + # 'cosh': None, + # 'acosh': None, + # 'atan': None, + # 'tanh': None, + # 'atanh': None, + # 'ceil': None, + # 'floor': None, + } + ) + +### FIXME: Remove the following as soon as non-active components no +### longer report active==True +from pyomo.network import Port +from pyomo.core.base import RangeSet, Set + +### + + +_domain_map = ComponentMap( + ( + (Binary, (GRB.BINARY, -float('inf'), float('inf'))), + (Integers, (GRB.INTEGER, -float('inf'), float('inf'))), + (NonNegativeIntegers, (GRB.INTEGER, 0, float('inf'))), + (NonPositiveIntegers, (GRB.INTEGER, -float('inf'), 0)), + (NonNegativeReals, (GRB.CONTINUOUS, 0, float('inf'))), + (NonPositiveReals, (GRB.CONTINUOUS, -float('inf'), 0)), + (Reals, (GRB.CONTINUOUS, -float('inf'), float('inf'))), + ) +) + + +def _create_grb_var(visitor, pyomo_var, name=""): + pyo_domain = pyomo_var.domain + if pyo_domain in _domain_map: + domain, domain_lb, domain_ub = _domain_map[pyo_domain] + else: + raise ValueError( + "Unsupported domain for Var '%s': %s" % (pyomo_var.name, pyo_domain) + ) + lb = max(domain_lb, pyomo_var.lb) if pyomo_var.lb is not None else domain_lb + ub = min(domain_ub, pyomo_var.ub) if pyomo_var.ub is not None else domain_ub + return visitor.grb_model.addVar(lb=lb, ub=ub, vtype=domain, name=name) + + +class GurobiMINLPBeforeChildDispatcher(BeforeChildDispatcher): + @staticmethod + def _before_var(visitor, child): + if child not in visitor.var_map: + if child.fixed: + # ESJ TODO: I want the linear walker implementation of + # check_constant... Could it be in the base class or something? + return False, (_CONSTANT, visitor.check_constant(child.value, child)) + grb_var = _create_grb_var( + visitor, + child, + name=child.name if visitor.symbolic_solver_labels else "", + ) + visitor.var_map[child] = grb_var + return False, (_VARIABLE, visitor.var_map[child]) + + @staticmethod + def _before_named_expression(visitor, child): + _id = id(child) + if _id in visitor.subexpression_cache: + _type, expr = visitor.subexpression_cache[_id] + return False, (_type, expr) + else: + return True, None + + +def _handle_node_with_eval_expr_visitor_invariant(visitor, node, data): + """ + Calls expression evaluation visitor on nodes that have an invariant + expression type in the return. + """ + return (data[0], visitor._eval_expr_visitor.visit(node, (data[1],))) + + +def _handle_node_with_eval_expr_visitor_unknown(visitor, node, *data): + # The expression type is whatever the highest one of the incoming arguments + # was. + expr_type = max(map(itemgetter(0), data)) + return ( + expr_type, + visitor._eval_expr_visitor.visit(node, tuple(map(itemgetter(1), data))), + ) + + +def _handle_node_with_eval_expr_visitor_constant(visitor, node, *data): + return ( + _CONSTANT, + visitor._eval_expr_visitor.visit(node, tuple(map(itemgetter(1), data))), + ) + + +def _handle_node_with_eval_expr_visitor_linear(visitor, node, *data): + return ( + _LINEAR, + visitor._eval_expr_visitor.visit(node, tuple(map(itemgetter(1), data))), + ) + + +def _handle_node_with_eval_expr_visitor_quadratic(visitor, node, *data): + return ( + _QUADRATIC, + visitor._eval_expr_visitor.visit(node, tuple(map(itemgetter(1), data))), + ) + + +def _handle_node_with_eval_expr_visitor_nonlinear(visitor, node, *data): + # ESJ: _apply_operation for DivisionExpression expects that result is indexed, so + # I'm making it a tuple rather than a map. + return ( + _GENERAL, + visitor._eval_expr_visitor.visit(node, tuple(map(itemgetter(1), data))), + ) + + +def _handle_linear_constant_pow_expr(visitor, node, arg1, arg2): + expr_type = _GENERAL + if arg2[1] == 1: + expr_type = _LINEAR + if arg2[1] == 2: + expr_type = _QUADRATIC + return ( + expr_type, + visitor._eval_expr_visitor.visit(node, tuple(map(itemgetter(1), (arg1, arg2)))), + ) + + +def _handle_quadratic_constant_pow_expr(visitor, node, arg1, arg2): + expr_type = _GENERAL + if arg2[1] == 1: + expr_type = _QUADRATIC + return ( + expr_type, + visitor._eval_expr_visitor.visit(node, tuple(map(itemgetter(1), (arg1, arg2)))), + ) + + +def _handle_unary(visitor, node, data): + if node._name in _function_map: + expr_type, fcn = _function_map[node._name] + return expr_type, fcn(data[1]) + raise ValueError( + "The unary function '%s' is not supported by the Gurobi MINLP writer." + % node._name + ) + + +def _handle_unary_constant(visitor, node, data): + try: + return _CONSTANT, node._fcn(value(data[1])) + except: + raise InvalidValueError( + f"Invalid number encountered evaluating constant unary expression " + f"{node}: {sys.exc_info()[1]}" + ) + + +def _handle_named_expression(visitor, node, arg1): + # Record this common expression + visitor.subexpression_cache[id(node)] = arg1 + _type, arg1 = arg1 + return _type, arg1 + + +def _handle_abs_constant(visitor, node, arg1): + return (_CONSTANT, abs(arg1[1])) + + +def _handle_abs_var(visitor, node, arg1): + # This auxiliary variable actually is non-negative, yay absolute value! + aux_abs = visitor.grb_model.addVar() + visitor.grb_model.addConstr(aux_abs == gurobipy.abs_(arg1[1])) + + return (_VARIABLE, aux_abs) + + +def _handle_abs_expression(visitor, node, arg1): + # we need auxiliary variable + aux_arg = visitor.grb_model.addVar(lb=-GRB.INFINITY, ub=GRB.INFINITY) + visitor.grb_model.addConstr(aux_arg == arg1[1]) + # This one truly is non-negative because it's an absolute value + aux_abs = visitor.grb_model.addVar() + visitor.grb_model.addConstr(aux_abs == gurobipy.abs_(aux_arg)) + + return (_VARIABLE, aux_abs) + + +def define_exit_node_handlers(_exit_node_handlers=None): + if _exit_node_handlers is None: + _exit_node_handlers = {} + + # We can rely on operator overloading for many, but not all expressions. + _exit_node_handlers[SumExpression] = { + None: _handle_node_with_eval_expr_visitor_unknown + } + _exit_node_handlers[LinearExpression] = { + # Can come back LINEAR or CONSTANT, so we use the 'unknown' version + None: _handle_node_with_eval_expr_visitor_unknown + } + _exit_node_handlers[NegationExpression] = { + None: _handle_node_with_eval_expr_visitor_invariant + } + _exit_node_handlers[ProductExpression] = { + None: _handle_node_with_eval_expr_visitor_nonlinear, + (_CONSTANT, _CONSTANT): _handle_node_with_eval_expr_visitor_constant, + (_CONSTANT, _LINEAR): _handle_node_with_eval_expr_visitor_linear, + (_CONSTANT, _QUADRATIC): _handle_node_with_eval_expr_visitor_quadratic, + (_CONSTANT, _VARIABLE): _handle_node_with_eval_expr_visitor_linear, + (_LINEAR, _CONSTANT): _handle_node_with_eval_expr_visitor_linear, + (_LINEAR, _LINEAR): _handle_node_with_eval_expr_visitor_quadratic, + (_LINEAR, _VARIABLE): _handle_node_with_eval_expr_visitor_quadratic, + (_VARIABLE, _CONSTANT): _handle_node_with_eval_expr_visitor_linear, + (_VARIABLE, _LINEAR): _handle_node_with_eval_expr_visitor_quadratic, + (_VARIABLE, _VARIABLE): _handle_node_with_eval_expr_visitor_quadratic, + } + _exit_node_handlers[MonomialTermExpression] = _exit_node_handlers[ProductExpression] + _exit_node_handlers[DivisionExpression] = { + None: _handle_node_with_eval_expr_visitor_nonlinear, + (_CONSTANT, _CONSTANT): _handle_node_with_eval_expr_visitor_constant, + (_LINEAR, _CONSTANT): _handle_node_with_eval_expr_visitor_linear, + (_VARIABLE, _CONSTANT): _handle_node_with_eval_expr_visitor_linear, + (_QUADRATIC, _CONSTANT): _handle_node_with_eval_expr_visitor_quadratic, + } + _exit_node_handlers[PowExpression] = { + None: _handle_node_with_eval_expr_visitor_nonlinear, + (_CONSTANT, _CONSTANT): _handle_node_with_eval_expr_visitor_constant, + (_VARIABLE, _CONSTANT): _handle_linear_constant_pow_expr, + (_LINEAR, _CONSTANT): _handle_linear_constant_pow_expr, + (_QUADRATIC, _CONSTANT): _handle_quadratic_constant_pow_expr, + } + _exit_node_handlers[UnaryFunctionExpression] = { + None: _handle_unary, + (_CONSTANT,): _handle_unary_constant, + } + + ## TODO: ExprIf, RangedExpressions (if we do exprif... + _exit_node_handlers[Expression] = {None: _handle_named_expression} + + # These are special because of quirks of Gurobi's current support for general + # nonlinear: + _exit_node_handlers[AbsExpression] = { + None: _handle_abs_expression, + (_CONSTANT,): _handle_abs_constant, + (_VARIABLE,): _handle_abs_var, + } + + return _exit_node_handlers + + +class GurobiMINLPVisitor(StreamBasedExpressionVisitor): + before_child_dispatcher = GurobiMINLPBeforeChildDispatcher() + exit_node_dispatcher = ExitNodeDispatcher( + initialize_exit_node_dispatcher(define_exit_node_handlers()) + ) + + def __init__(self, grb_model, symbolic_solver_labels=False): + super().__init__() + self.grb_model = grb_model + self.symbolic_solver_labels = symbolic_solver_labels + self.var_map = ComponentMap() + self.subexpression_cache = {} + self._eval_expr_visitor = _EvaluationVisitor(True) + self.evaluate = self._eval_expr_visitor.dfs_postorder_stack + + def initializeWalker(self, expr): + walk, result = self.beforeChild(None, expr, 0) + if not walk: + return False, self.finalizeResult(result) + return True, expr + + def beforeChild(self, node, child, child_idx): + return self.before_child_dispatcher[child.__class__](self, child) + + def exitNode(self, node, data): + return self.exit_node_dispatcher[(node.__class__, *map(itemgetter(0), data))]( + self, node, *data + ) + + def finalizeResult(self, result): + return result + + # ESJ TODO: THIS IS COPIED FROM THE LINEAR WALKER--CAN WE PUT IT IN UTIL OR + # SOMETHING? + def check_constant(self, ans, obj): + if ans.__class__ not in EXPR.native_numeric_types: + # None can be returned from uninitialized Var/Param objects + if ans is None: + return InvalidNumber( + None, f"'{obj}' evaluated to a nonnumeric value '{ans}'" + ) + if ans.__class__ is InvalidNumber: + return ans + elif ans.__class__ in native_complex_types: + return complex_number_error(ans, self, obj) + else: + # It is possible to get other non-numeric types. Most + # common are bool and 1-element numpy.array(). We will + # attempt to convert the value to a float before + # proceeding. + # + # TODO: we should check bool and warn/error (while bool is + # convertible to float in Python, they have very + # different semantic meanings in Pyomo). + try: + ans = float(ans) + except: + return InvalidNumber( + ans, f"'{obj}' evaluated to a nonnumeric value '{ans}'" + ) + if ans != ans: + return InvalidNumber( + nan, f"'{obj}' evaluated to a nonnumeric value '{ans}'" + ) + return ans + + +@WriterFactory.register( + 'gurobi_minlp', + 'Direct interface to Gurobi that allows for general nonlinear expressions', +) +class GurobiMINLPWriter: + CONFIG = ConfigDict('gurobi_minlp_writer') + CONFIG.declare( + 'symbolic_solver_labels', + ConfigValue( + default=False, + domain=bool, + description='Write Pyomo Var and Constraint names to Gurobi model', + ), + ) + + def __init__(self): + self.config = self.CONFIG() + + def _create_gurobi_expression( + self, expr, src, src_index, grb_model, quadratic_visitor, grb_visitor + ): + """ + Returns a gurobipy representation of the expression + """ + expr_type, grb_expr = grb_visitor.walk_expression(expr) + if expr_type is not _GENERAL: + return expr_type, grb_expr, False, None + else: + aux = grb_model.addVar(lb=-GRB.INFINITY, ub=GRB.INFINITY) + return expr_type, grb_expr, True, aux + + def write(self, model, **options): + config = options.pop('config', self.config)(options) + + components, unknown = collect_valid_components( + model, + active=True, + sort=SortComponents.deterministic, + valid={ + Block, + Objective, + Constraint, + Expression, + Var, + BooleanVar, + Param, + Suffix, + # FIXME: Non-active components should not report as Active + Set, + RangeSet, + Port, + }, + targets={Objective, Constraint}, + ) + if unknown: + raise ValueError( + "The model ('%s') contains the following active components " + "that the Gurobi MINLP writer does not know how to " + "process:\n\t%s" + % ( + model.name, + "\n\t".join( + "%s:\n\t\t%s" % (k, "\n\t\t".join(map(attrgetter('name'), v))) + for k, v in unknown.items() + ), + ) + ) + + # Get a quadratic walker instance + quadratic_visitor = QuadraticRepnVisitor( + subexpression_cache={}, var_recorder=OrderedVarRecorder({}, {}, None) + ) + + # create Gurobi model + grb_model = gurobipy.Model() + visitor = GurobiMINLPVisitor( + grb_model, symbolic_solver_labels=config.symbolic_solver_labels + ) + + active_objs = components[Objective] + if len(active_objs) > 1: + raise ValueError( + "More than one active objective defined for " + "input model '%s': Cannot write to gurobipy." % model.name + ) + elif len(active_objs) == 1: + obj = active_objs[0] + pyo_obj = [obj] + if obj.sense is minimize: + sense = GRB.MINIMIZE + else: + sense = GRB.MAXIMIZE + expr_type, obj_expr, nonlinear, aux = self._create_gurobi_expression( + obj.expr, obj, 0, grb_model, quadratic_visitor, visitor + ) + if nonlinear: + # The objective must be linear or quadratic, so we move the nonlinear + # one to the constraints + grb_model.setObjective(aux, sense=sense) + grb_model.addConstr(aux == obj_expr) + else: + grb_model.setObjective(obj_expr, sense=sense) + # else it's fine--Gurobi doesn't require us to give an objective, so we don't + # either, but we do have to pass the info through for the results object + else: + pyo_obj = [] + + # write constraints + pyo_cons = [] + grb_cons = [] + for cons in components[Constraint]: + expr_type, expr, nonlinear, aux = self._create_gurobi_expression( + cons.body, cons, 0, grb_model, quadratic_visitor, visitor + ) + if nonlinear: + grb_model.addConstr(aux == expr) + expr = aux + lb = value(cons.lb) + ub = value(cons.ub) + if expr_type == _CONSTANT: + # cast everything to a float in case there are numpy + # types because you can't do addConstr(np.True_) + expr = float(expr) + if lb is not None: + lb = float(lb) + if ub is not None: + ub = float(ub) + if cons.equality: + grb_cons.append(grb_model.addConstr(expr == lb)) + pyo_cons.append(cons) + else: + if cons.lb is not None: + grb_cons.append(grb_model.addConstr(expr >= lb)) + pyo_cons.append(cons) + if cons.ub is not None: + grb_cons.append(grb_model.addConstr(expr <= ub)) + pyo_cons.append(cons) + + grb_model.update() + return grb_model, visitor.var_map, pyo_obj, grb_cons, pyo_cons + + +@SolverFactory.register( + 'gurobi_direct_minlp', + doc='Direct interface to Gurobi version 12 and up ' + 'supporting general nonlinear expressions', +) +class GurobiDirectMINLP(GurobiDirect): + def solve(self, model, **kwds): + """Solve the model. + + Args: + model (Block): a Pyomo model or Block to be solved + """ + start_timestamp = datetime.datetime.now(datetime.timezone.utc) + config = self.config(value=kwds, preserve_implicit=True) + if not self.available(): + c = self.__class__ + raise ApplicationError( + f'Solver {c.__module__}.{c.__qualname__} is not available ' + f'({self.available()}).' + ) + if config.timer is None: + config.timer = HierarchicalTimer() + timer = config.timer + + StaleFlagManager.mark_all_as_stale() + + timer.start('compile_model') + + writer = GurobiMINLPWriter() + grb_model, var_map, pyo_obj, grb_cons, pyo_cons = writer.write( + model, symbolic_solver_labels=config.symbolic_solver_labels + ) + + timer.stop('compile_model') + + ostreams = [io.StringIO()] + config.tee + + # set options + options = config.solver_options + + grb_model.setParam('LogToConsole', 1) + + if config.threads is not None: + grb_model.setParam('Threads', config.threads) + if config.time_limit is not None: + grb_model.setParam('TimeLimit', config.time_limit) + if config.rel_gap is not None: + grb_model.setParam('MIPGap', config.rel_gap) + if config.abs_gap is not None: + grb_model.setParam('MIPGapAbs', config.abs_gap) + + if config.use_mipstart: + raise MouseTrap("MIPSTART not yet supported") + + for key, option in options.items(): + grb_model.setParam(key, option) + + grbsol = grb_model.optimize() + + res = self._postsolve( + timer, + config, + GurobiDirectSolutionLoader( + grb_model, + grb_cons=grb_cons, + grb_vars=var_map.values(), + pyo_cons=pyo_cons, + pyo_vars=var_map.keys(), + pyo_obj=pyo_obj, + ), + ) + + res.solver_config = config + res.solver_name = 'Gurobi' + res.solver_version = self.version() + res.solver_log = ostreams[0].getvalue() + + end_timestamp = datetime.datetime.now(datetime.timezone.utc) + res.timing_info.start_timestamp = start_timestamp + res.timing_info.wall_time = (end_timestamp - start_timestamp).total_seconds() + res.timing_info.timer = timer + return res diff --git a/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp_walker.py b/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp_walker.py index 5281c687937..8848e6df5d2 100644 --- a/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp_walker.py +++ b/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp_walker.py @@ -1,604 +1,604 @@ -# ___________________________________________________________________________ -# -# Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 -# National Technology and Engineering Solutions of Sandia, LLC -# Under the terms of Contract DE-NA0003525 with National Technology and -# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain -# rights in this software. -# This software is distributed under the 3-clause BSD License. -# ___________________________________________________________________________ - -from pyomo.common.dependencies import attempt_import -from pyomo.core.expr.compare import assertExpressionsEqual -from pyomo.common.errors import InvalidValueError -import pyomo.common.unittest as unittest -from pyomo.contrib.solver.solvers.gurobi_direct_minlp import GurobiMINLPVisitor -from pyomo.contrib.solver.tests.solvers.gurobi_to_pyomo_expressions import ( - grb_nl_to_pyo_expr, -) -from pyomo.environ import ( - Binary, - ConcreteModel, - Constraint, - Integers, - log, - NonNegativeIntegers, - NonNegativeReals, - NonPositiveIntegers, - NonPositiveReals, - Objective, - Param, - Reals, - sqrt, - Var, -) - -gurobipy, gurobipy_available = attempt_import('gurobipy', minimum_version='12.0.0') - -if gurobipy_available: - from gurobipy import GRB - -## DEBUG -from pytest import set_trace - - -class CommonTest(unittest.TestCase): - def get_model(self): - m = ConcreteModel() - m.x1 = Var(domain=NonNegativeReals) - m.x2 = Var(domain=Reals) - m.x3 = Var(domain=NonPositiveReals) - m.y1 = Var(domain=Integers) - m.y2 = Var(domain=NonNegativeIntegers) - m.y3 = Var(domain=NonPositiveIntegers) - m.z1 = Var(domain=Binary) - - return m - - def get_visitor(self): - grb_model = gurobipy.Model() - return GurobiMINLPVisitor(grb_model, symbolic_solver_labels=True) - - -@unittest.skipUnless(gurobipy_available, "gurobipy is not available") -class TestGurobiMINLPWalker(CommonTest): - def test_var_domains(self): - m = self.get_model() - e = m.x1 + m.x2 + m.x3 + m.y1 + m.y2 + m.y3 + m.z1 - visitor = self.get_visitor() - _, expr = visitor.walk_expression(e) - - # We don't call update in walk expression for performance reasons, but - # we need to update here in order to be able to test expr. - visitor.grb_model.update() - - x1 = visitor.var_map[id(m.x1)] - x2 = visitor.var_map[id(m.x2)] - x3 = visitor.var_map[id(m.x3)] - y1 = visitor.var_map[id(m.y1)] - y2 = visitor.var_map[id(m.y2)] - y3 = visitor.var_map[id(m.y3)] - z1 = visitor.var_map[id(m.z1)] - - self.assertEqual(x1.lb, 0) - self.assertEqual(x1.ub, float('inf')) - self.assertEqual(x1.vtype, GRB.CONTINUOUS) - - self.assertEqual(x2.lb, -float('inf')) - self.assertEqual(x2.ub, float('inf')) - self.assertEqual(x2.vtype, GRB.CONTINUOUS) - - self.assertEqual(x3.lb, -float('inf')) - self.assertEqual(x3.ub, 0) - self.assertEqual(x3.vtype, GRB.CONTINUOUS) - - self.assertEqual(y1.lb, -float('inf')) - self.assertEqual(y1.ub, float('inf')) - self.assertEqual(y1.vtype, GRB.INTEGER) - - self.assertEqual(y2.lb, 0) - self.assertEqual(y2.ub, float('inf')) - self.assertEqual(y2.vtype, GRB.INTEGER) - - self.assertEqual(y3.lb, -float('inf')) - self.assertEqual(y3.ub, 0) - self.assertEqual(y3.vtype, GRB.INTEGER) - - self.assertEqual(z1.vtype, GRB.BINARY) - - def test_var_bounds(self): - m = self.get_model() - m.x2.setlb(-34) - m.x2.setub(45) - m.x3.setub(5) - m.y1.setlb(-2) - m.y1.setub(3) - m.y2.setlb(-5) - m.z1.setub(4) - m.z1.setlb(-3) - - e = m.x1 + m.x2 + m.x3 + m.y1 + m.y2 + m.y3 + m.z1 - visitor = self.get_visitor() - _, expr = visitor.walk_expression(e) - - # We don't call update in walk expression for performance reasons, but - # we need to update here in order to be able to test expr. - visitor.grb_model.update() - - x2 = visitor.var_map[id(m.x2)] - x3 = visitor.var_map[id(m.x3)] - y1 = visitor.var_map[id(m.y1)] - y2 = visitor.var_map[id(m.y2)] - z1 = visitor.var_map[id(m.z1)] - - self.assertEqual(x2.lb, -34) - self.assertEqual(x2.ub, 45) - self.assertEqual(x2.vtype, GRB.CONTINUOUS) - - self.assertEqual(x3.lb, -float('inf')) - self.assertEqual(x3.ub, 0) - self.assertEqual(x3.vtype, GRB.CONTINUOUS) - - self.assertEqual(y1.lb, -2) - self.assertEqual(y1.ub, 3) - self.assertEqual(y1.vtype, GRB.INTEGER) - - self.assertEqual(y2.lb, 0) - self.assertEqual(y2.ub, float('inf')) - self.assertEqual(y2.vtype, GRB.INTEGER) - - self.assertEqual(z1.vtype, GRB.BINARY) - - def test_write_addition(self): - m = self.get_model() - m.c = Constraint(expr=m.x1 + m.x2 >= 3) - visitor = self.get_visitor() - _, expr = visitor.walk_expression(m.c.body) - - x1 = visitor.var_map[id(m.x1)] - x2 = visitor.var_map[id(m.x2)] - - # This is a linear expression - self.assertEqual(expr.size(), 2) - self.assertEqual(expr.getCoeff(0), 1.0) - self.assertEqual(expr.getCoeff(1), 1.0) - self.assertIs(expr.getVar(0), x1) - self.assertIs(expr.getVar(1), x2) - self.assertEqual(expr.getConstant(), 0.0) - - def test_write_subtraction(self): - m = self.get_model() - m.c = Constraint(expr=m.x1 - m.x2 >= 3) - visitor = self.get_visitor() - _, expr = visitor.walk_expression(m.c.body) - - x1 = visitor.var_map[id(m.x1)] - x2 = visitor.var_map[id(m.x2)] - - # Also linear, whoot! - self.assertEqual(expr.size(), 2) - self.assertEqual(expr.getCoeff(0), 1.0) - self.assertEqual(expr.getCoeff(1), -1.0) - self.assertIs(expr.getVar(0), x1) - self.assertIs(expr.getVar(1), x2) - self.assertEqual(expr.getConstant(), 0.0) - - def test_write_product(self): - m = self.get_model() - m.c = Constraint(expr=m.x1 * m.x2 >= 3) - visitor = self.get_visitor() - _, expr = visitor.walk_expression(m.c.body) - - x1 = visitor.var_map[id(m.x1)] - x2 = visitor.var_map[id(m.x2)] - - # This is quadratic - self.assertEqual(expr.size(), 1) - lin_expr = expr.getLinExpr() - self.assertEqual(lin_expr.size(), 0) - self.assertIs(expr.getVar1(0), x1) - self.assertIs(expr.getVar2(0), x2) - self.assertEqual(expr.getCoeff(0), 1.0) - - def test_write_product_with_fixed_var(self): - m = self.get_model() - m.x2.fix(4) - m.c = Constraint(expr=m.x1 * m.x2 == 1) - - visitor = self.get_visitor() - _, expr = visitor.walk_expression(m.c.body) - - x1 = visitor.var_map[id(m.x1)] - - # this is linear - self.assertEqual(expr.size(), 1) - self.assertEqual(expr.getCoeff(0), 4.0) - self.assertIs(expr.getVar(0), x1) - self.assertEqual(expr.getConstant(), 0.0) - - def test_write_product_with_0(self): - m = self.get_model() - m.c = Constraint(expr=(0 * m.x1 * m.x2) * m.x3 == 0) - - visitor = self.get_visitor() - _, expr = visitor.walk_expression(m.c.body) - - x1 = visitor.var_map[m.x1] - x2 = visitor.var_map[m.x2] - x3 = visitor.var_map[m.x3] - - # this is a "nonlinear" - opcode, data, parent = self._get_nl_expr_tree(visitor, expr) - - self.assertEqual(len(opcode), 6) - self.assertEqual(parent[0], -1) # root - self.assertEqual(opcode[0], GRB.OPCODE_MULTIPLY) - self.assertEqual(data[0], -1) # no additional data - - # first arg is another multiply with three children - self.assertEqual(parent[1], 0) - self.assertEqual(opcode[1], GRB.OPCODE_MULTIPLY) - self.assertEqual(data[0], -1) - - # second arg is the constant - self.assertEqual(parent[2], 1) - self.assertEqual(opcode[2], GRB.OPCODE_CONSTANT) - self.assertEqual(data[2], 0) - - # third arg is x1 - self.assertEqual(parent[3], 1) - self.assertEqual(opcode[3], GRB.OPCODE_VARIABLE) - self.assertIs(data[3], x1) - - # fourth arg is x2 - self.assertEqual(parent[4], 1) - self.assertEqual(opcode[4], GRB.OPCODE_VARIABLE) - self.assertIs(data[4], x2) - - # fifth arg is x3, whose parent is the root - self.assertEqual(parent[5], 0) - self.assertEqual(opcode[5], GRB.OPCODE_VARIABLE) - self.assertIs(data[5], x3) - - def test_write_division(self): - m = self.get_model() - m.c = Constraint(expr=1 / m.x1 == 1) - - visitor = self.get_visitor() - _, expr = visitor.walk_expression(m.c.body) - - visitor.grb_model.update() - grb_to_pyo_var_map = { - grb_var: py_var for py_var, grb_var in visitor.var_map.items() - } - - opcode, data, parent = self._get_nl_expr_tree(visitor, expr) - - pyo_expr = grb_nl_to_pyo_expr(opcode, data, parent, grb_to_pyo_var_map) - assertExpressionsEqual(self, pyo_expr, 1.0 / m.x1) - - def test_write_division_linear(self): - m = self.get_model() - m.p = Param(initialize=3, mutable=True) - m.c = Constraint(expr=(m.x1 + m.x2) * m.p / 10 == 1) - - visitor = self.get_visitor() - _, expr = visitor.walk_expression(m.c.body) - - x1 = visitor.var_map[id(m.x1)] - x2 = visitor.var_map[id(m.x2)] - - # linear - self.assertEqual(expr.size(), 2) - self.assertEqual(expr.getConstant(), 0) - self.assertAlmostEqual(expr.getCoeff(0), 3 / 10) - self.assertIs(expr.getVar(0), x1) - self.assertAlmostEqual(expr.getCoeff(1), 3 / 10) - self.assertIs(expr.getVar(1), x2) - - def test_write_quadratic_power_expression_var_const(self): - m = self.get_model() - m.c = Constraint(expr=m.x1**2 >= 3) - visitor = self.get_visitor() - _, expr = visitor.walk_expression(m.c.body) - - # This is also quadratic - x1 = visitor.var_map[id(m.x1)] - - self.assertEqual(expr.size(), 1) - lin_expr = expr.getLinExpr() - self.assertEqual(lin_expr.size(), 0) - self.assertEqual(lin_expr.getConstant(), 0) - self.assertIs(expr.getVar1(0), x1) - self.assertIs(expr.getVar2(0), x1) - self.assertEqual(expr.getCoeff(0), 1.0) - - def _get_nl_expr_tree(self, visitor, expr): - # This is a bit hacky, but the only way that I know to get the expression tree - # publicly is from a general nonlinear constraint. So we can create it, and - # then pull out the expression we just used to test it - grb_model = visitor.grb_model - aux = grb_model.addVar() - grb_model.addConstr(aux == expr) - grb_model.update() - constrs = grb_model.getGenConstrs() - self.assertEqual(len(constrs), 1) - - aux_var, opcode, data, parent = grb_model.getGenConstrNLAdv(constrs[0]) - self.assertIs(aux_var, aux) - return opcode, data, parent - - def test_write_nonquadratic_power_expression_var_const(self): - m = self.get_model() - m.c = Constraint(expr=m.x1**3 >= 3) - visitor = self.get_visitor() - _, expr = visitor.walk_expression(m.c.body) - - # This is general nonlinear - x1 = visitor.var_map[id(m.x1)] - - opcode, data, parent = self._get_nl_expr_tree(visitor, expr) - - # three nodes - self.assertEqual(len(opcode), 3) - - # the root is a power expression - self.assertEqual(parent[0], -1) # means root - self.assertEqual(opcode[0], GRB.OPCODE_POW) - # pow has no additional data - self.assertEqual(data[0], -1) - - # first child is x1 - self.assertEqual(parent[1], 0) - self.assertIs(data[1], x1) - self.assertEqual(opcode[1], GRB.OPCODE_VARIABLE) - - # second child is 3 - self.assertEqual(parent[2], 0) - self.assertEqual(opcode[2], GRB.OPCODE_CONSTANT) - self.assertEqual(data[2], 3.0) # the data is the constant's value - - def test_write_power_expression_var_var(self): - m = self.get_model() - m.c = Constraint(expr=m.x1**m.x2 >= 3) - visitor = self.get_visitor() - _, expr = visitor.walk_expression(m.c.body) - - # You can't actually use this in a model in Gurobi 12, but you can build the - # expression... (It fails during the solve for some reason.) - - x1 = visitor.var_map[id(m.x1)] - x2 = visitor.var_map[id(m.x2)] - - opcode, data, parent = self._get_nl_expr_tree(visitor, expr) - - # three nodes - self.assertEqual(len(opcode), 3) - - # the root is a power expression - self.assertEqual(parent[0], -1) # means root - self.assertEqual(opcode[0], GRB.OPCODE_POW) - # pow has no additional data - self.assertEqual(data[0], -1) - - # first child is x1 - self.assertEqual(parent[1], 0) - self.assertIs(data[1], x1) - self.assertEqual(opcode[1], GRB.OPCODE_VARIABLE) - - # second child is x2 - self.assertEqual(parent[2], 0) - self.assertEqual(opcode[2], GRB.OPCODE_VARIABLE) - self.assertIs(data[2], x2) - - def test_write_power_expression_const_var(self): - m = self.get_model() - m.c = Constraint(expr=2**m.x2 >= 3) - visitor = self.get_visitor() - _, expr = visitor.walk_expression(m.c.body) - - x2 = visitor.var_map[id(m.x2)] - - opcode, data, parent = self._get_nl_expr_tree(visitor, expr) - - # three nodes - self.assertEqual(len(opcode), 3) - - # the root is a power expression - self.assertEqual(parent[0], -1) # means root - self.assertEqual(opcode[0], GRB.OPCODE_POW) - # pow has no additional data - self.assertEqual(data[0], -1) - - # first child is 2 - self.assertEqual(parent[1], 0) - self.assertEqual(data[1], 2.0) - self.assertEqual(opcode[1], GRB.OPCODE_CONSTANT) - - # second child is x2 - self.assertEqual(parent[2], 0) - self.assertEqual(opcode[2], GRB.OPCODE_VARIABLE) - self.assertIs(data[2], x2) - - def test_write_absolute_value_of_var(self): - # Gurobi doesn't support abs of expressions, so we have to do a factorable - # programming thing... - m = self.get_model() - m.c = Constraint(expr=abs(m.x1) >= 3) - visitor = self.get_visitor() - _, expr = visitor.walk_expression(m.c.body) - - # expr is actually an auxiliary variable. We should - # get a constraint: - # expr == abs(x1) - x1 = visitor.var_map[id(m.x1)] - - self.assertIsInstance(expr, gurobipy.Var) - grb_model = visitor.grb_model - # We don't call update in walk expression for performance reasons, but - # we need to update here in order to be able to test expr. - grb_model.update() - self.assertEqual(grb_model.numVars, 2) - self.assertEqual(grb_model.numGenConstrs, 1) - self.assertEqual(grb_model.numConstrs, 0) - self.assertEqual(grb_model.numQConstrs, 0) - - cons = grb_model.getGenConstrs()[0] - aux, v = grb_model.getGenConstrAbs(cons) - self.assertIs(aux, expr) - self.assertIs(v, x1) - - def test_write_absolute_value_of_expression(self): - m = self.get_model() - m.c = Constraint(expr=abs(m.x1 + 2 * m.x2) >= 3) - visitor = self.get_visitor() - _, expr = visitor.walk_expression(m.c.body) - - # expr is actually an auxiliary variable. We should - # get three constraints: - # aux1 == x1 + 2 * x2 - # expr == abs(aux1) - - x1 = visitor.var_map[m.x1] - x2 = visitor.var_map[m.x2] - - # we're going to have to write the resulting model to an lp file to test that we - # have what we expect - self.assertIsInstance(expr, gurobipy.Var) - grb_model = visitor.grb_model - # We don't call update in walk expression for performance reasons, but - # we need to update here in order to be able to test expr. - grb_model.update() - self.assertEqual(grb_model.numVars, 4) - self.assertEqual(grb_model.numGenConstrs, 1) - self.assertEqual(grb_model.numConstrs, 1) - self.assertEqual(grb_model.numQConstrs, 0) - - cons = grb_model.getGenConstrs()[0] - aux2, aux1 = grb_model.getGenConstrAbs(cons) - self.assertIs(aux2, expr) - - cons = grb_model.getConstrs()[0] - # this guy is linear equality - self.assertEqual(cons.RHS, 0) - self.assertEqual(cons.Sense, '=') - linexpr = grb_model.getRow(cons) - self.assertEqual(linexpr.getConstant(), 0) - self.assertEqual(linexpr.size(), 3) - self.assertEqual(linexpr.getCoeff(0), -1) - self.assertIs(linexpr.getVar(0), x1) - self.assertEqual(linexpr.getCoeff(1), -2) - self.assertIs(linexpr.getVar(1), x2) - self.assertEqual(linexpr.getCoeff(2), 1) - self.assertIs(linexpr.getVar(2), aux1) - - def test_write_expression_with_mutable_param(self): - m = self.get_model() - m.p = Param(initialize=4, mutable=True) - m.c = Constraint(expr=m.p**m.x2 >= 3) - visitor = self.get_visitor() - _, expr = visitor.walk_expression(m.c.body) - - # expr is nonlinear - x2 = visitor.var_map[id(m.x2)] - - opcode, data, parent = self._get_nl_expr_tree(visitor, expr) - - # three nodes - self.assertEqual(len(opcode), 3) - - # the root is a power expression - self.assertEqual(parent[0], -1) # means root - self.assertEqual(opcode[0], GRB.OPCODE_POW) - # pow has no additional data - self.assertEqual(data[0], -1) - - # first child is 4 - self.assertEqual(parent[1], 0) - self.assertEqual(data[1], 4.0) - self.assertEqual(opcode[1], GRB.OPCODE_CONSTANT) - - # second child is x2 - self.assertEqual(parent[2], 0) - self.assertEqual(opcode[2], GRB.OPCODE_VARIABLE) - self.assertIs(data[2], x2) - - def test_monomial_expression(self): - m = self.get_model() - m.p = Param(initialize=4, mutable=True) - - const_expr = 3 * m.x1 - nested_expr = (1 / m.p) * m.x1 - pow_expr = (m.p ** (0.5)) * m.x1 - - visitor = self.get_visitor() - _, expr = visitor.walk_expression(const_expr) - x1 = visitor.var_map[id(m.x1)] - self.assertEqual(expr.size(), 1) - self.assertEqual(expr.getConstant(), 0.0) - self.assertIs(expr.getVar(0), x1) - self.assertEqual(expr.getCoeff(0), 3) - - _, expr = visitor.walk_expression(nested_expr) - self.assertEqual(expr.size(), 1) - self.assertEqual(expr.getConstant(), 0.0) - self.assertIs(expr.getVar(0), x1) - self.assertAlmostEqual(expr.getCoeff(0), 1 / 4) - - _, expr = visitor.walk_expression(pow_expr) - self.assertEqual(expr.size(), 1) - self.assertEqual(expr.getConstant(), 0.0) - self.assertIs(expr.getVar(0), x1) - self.assertEqual(expr.getCoeff(0), 2) - - def test_log_expression(self): - m = self.get_model() - m.c = Constraint(expr=log(m.x1) >= 3) - m.pprint() - visitor = self.get_visitor() - _, expr = visitor.walk_expression(m.c.body) - - # expr is nonlinear - x1 = visitor.var_map[id(m.x1)] - - opcode, data, parent = self._get_nl_expr_tree(visitor, expr) - - # two nodes - self.assertEqual(len(opcode), 2) - - # the root is a power expression - self.assertEqual(parent[0], -1) # means root - self.assertEqual(opcode[0], GRB.OPCODE_LOG) - self.assertEqual(data[0], -1) - - # child is x1 - self.assertEqual(parent[1], 0) - self.assertIs(data[1], x1) - self.assertEqual(opcode[1], GRB.OPCODE_VARIABLE) - - def test_handle_complex_number_sqrt(self): - m = self.get_model() - m.p = Param(initialize=3, mutable=True) - m.c = Constraint(expr=sqrt(-m.p) + m.x1 >= 3) - - visitor = self.get_visitor() - with self.assertRaisesRegex( - InvalidValueError, - r"Invalid number encountered evaluating constant unary expression " - r"sqrt\(- p\): math domain error", - ): - _, expr = visitor.walk_expression(m.c.body) - - def test_handle_invalid_log(self): - m = self.get_model() - m.p = Param(initialize=0, mutable=True) - m.c = Constraint(expr=log(m.p) + m.x1 >= 3) - - visitor = self.get_visitor() - with self.assertRaisesRegex( - InvalidValueError, - r"Invalid number encountered evaluating constant unary expression " - r"log\(p\): math domain error", - ): - _, expr = visitor.walk_expression(m.c.body) +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2024 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + +from pyomo.common.dependencies import attempt_import +from pyomo.core.expr.compare import assertExpressionsEqual +from pyomo.common.errors import InvalidValueError +import pyomo.common.unittest as unittest +from pyomo.contrib.solver.solvers.gurobi_direct_minlp import GurobiMINLPVisitor +from pyomo.contrib.solver.tests.solvers.gurobi_to_pyomo_expressions import ( + grb_nl_to_pyo_expr, +) +from pyomo.environ import ( + Binary, + ConcreteModel, + Constraint, + Integers, + log, + NonNegativeIntegers, + NonNegativeReals, + NonPositiveIntegers, + NonPositiveReals, + Objective, + Param, + Reals, + sqrt, + Var, +) + +gurobipy, gurobipy_available = attempt_import('gurobipy', minimum_version='12.0.0') + +if gurobipy_available: + from gurobipy import GRB + +## DEBUG +from pytest import set_trace + + +class CommonTest(unittest.TestCase): + def get_model(self): + m = ConcreteModel() + m.x1 = Var(domain=NonNegativeReals) + m.x2 = Var(domain=Reals) + m.x3 = Var(domain=NonPositiveReals) + m.y1 = Var(domain=Integers) + m.y2 = Var(domain=NonNegativeIntegers) + m.y3 = Var(domain=NonPositiveIntegers) + m.z1 = Var(domain=Binary) + + return m + + def get_visitor(self): + grb_model = gurobipy.Model() + return GurobiMINLPVisitor(grb_model, symbolic_solver_labels=True) + + +@unittest.skipUnless(gurobipy_available, "gurobipy is not available") +class TestGurobiMINLPWalker(CommonTest): + def test_var_domains(self): + m = self.get_model() + e = m.x1 + m.x2 + m.x3 + m.y1 + m.y2 + m.y3 + m.z1 + visitor = self.get_visitor() + _, expr = visitor.walk_expression(e) + + # We don't call update in walk expression for performance reasons, but + # we need to update here in order to be able to test expr. + visitor.grb_model.update() + + x1 = visitor.var_map[id(m.x1)] + x2 = visitor.var_map[id(m.x2)] + x3 = visitor.var_map[id(m.x3)] + y1 = visitor.var_map[id(m.y1)] + y2 = visitor.var_map[id(m.y2)] + y3 = visitor.var_map[id(m.y3)] + z1 = visitor.var_map[id(m.z1)] + + self.assertEqual(x1.lb, 0) + self.assertEqual(x1.ub, float('inf')) + self.assertEqual(x1.vtype, GRB.CONTINUOUS) + + self.assertEqual(x2.lb, -float('inf')) + self.assertEqual(x2.ub, float('inf')) + self.assertEqual(x2.vtype, GRB.CONTINUOUS) + + self.assertEqual(x3.lb, -float('inf')) + self.assertEqual(x3.ub, 0) + self.assertEqual(x3.vtype, GRB.CONTINUOUS) + + self.assertEqual(y1.lb, -float('inf')) + self.assertEqual(y1.ub, float('inf')) + self.assertEqual(y1.vtype, GRB.INTEGER) + + self.assertEqual(y2.lb, 0) + self.assertEqual(y2.ub, float('inf')) + self.assertEqual(y2.vtype, GRB.INTEGER) + + self.assertEqual(y3.lb, -float('inf')) + self.assertEqual(y3.ub, 0) + self.assertEqual(y3.vtype, GRB.INTEGER) + + self.assertEqual(z1.vtype, GRB.BINARY) + + def test_var_bounds(self): + m = self.get_model() + m.x2.setlb(-34) + m.x2.setub(45) + m.x3.setub(5) + m.y1.setlb(-2) + m.y1.setub(3) + m.y2.setlb(-5) + m.z1.setub(4) + m.z1.setlb(-3) + + e = m.x1 + m.x2 + m.x3 + m.y1 + m.y2 + m.y3 + m.z1 + visitor = self.get_visitor() + _, expr = visitor.walk_expression(e) + + # We don't call update in walk expression for performance reasons, but + # we need to update here in order to be able to test expr. + visitor.grb_model.update() + + x2 = visitor.var_map[id(m.x2)] + x3 = visitor.var_map[id(m.x3)] + y1 = visitor.var_map[id(m.y1)] + y2 = visitor.var_map[id(m.y2)] + z1 = visitor.var_map[id(m.z1)] + + self.assertEqual(x2.lb, -34) + self.assertEqual(x2.ub, 45) + self.assertEqual(x2.vtype, GRB.CONTINUOUS) + + self.assertEqual(x3.lb, -float('inf')) + self.assertEqual(x3.ub, 0) + self.assertEqual(x3.vtype, GRB.CONTINUOUS) + + self.assertEqual(y1.lb, -2) + self.assertEqual(y1.ub, 3) + self.assertEqual(y1.vtype, GRB.INTEGER) + + self.assertEqual(y2.lb, 0) + self.assertEqual(y2.ub, float('inf')) + self.assertEqual(y2.vtype, GRB.INTEGER) + + self.assertEqual(z1.vtype, GRB.BINARY) + + def test_write_addition(self): + m = self.get_model() + m.c = Constraint(expr=m.x1 + m.x2 >= 3) + visitor = self.get_visitor() + _, expr = visitor.walk_expression(m.c.body) + + x1 = visitor.var_map[id(m.x1)] + x2 = visitor.var_map[id(m.x2)] + + # This is a linear expression + self.assertEqual(expr.size(), 2) + self.assertEqual(expr.getCoeff(0), 1.0) + self.assertEqual(expr.getCoeff(1), 1.0) + self.assertIs(expr.getVar(0), x1) + self.assertIs(expr.getVar(1), x2) + self.assertEqual(expr.getConstant(), 0.0) + + def test_write_subtraction(self): + m = self.get_model() + m.c = Constraint(expr=m.x1 - m.x2 >= 3) + visitor = self.get_visitor() + _, expr = visitor.walk_expression(m.c.body) + + x1 = visitor.var_map[id(m.x1)] + x2 = visitor.var_map[id(m.x2)] + + # Also linear, whoot! + self.assertEqual(expr.size(), 2) + self.assertEqual(expr.getCoeff(0), 1.0) + self.assertEqual(expr.getCoeff(1), -1.0) + self.assertIs(expr.getVar(0), x1) + self.assertIs(expr.getVar(1), x2) + self.assertEqual(expr.getConstant(), 0.0) + + def test_write_product(self): + m = self.get_model() + m.c = Constraint(expr=m.x1 * m.x2 >= 3) + visitor = self.get_visitor() + _, expr = visitor.walk_expression(m.c.body) + + x1 = visitor.var_map[id(m.x1)] + x2 = visitor.var_map[id(m.x2)] + + # This is quadratic + self.assertEqual(expr.size(), 1) + lin_expr = expr.getLinExpr() + self.assertEqual(lin_expr.size(), 0) + self.assertIs(expr.getVar1(0), x1) + self.assertIs(expr.getVar2(0), x2) + self.assertEqual(expr.getCoeff(0), 1.0) + + def test_write_product_with_fixed_var(self): + m = self.get_model() + m.x2.fix(4) + m.c = Constraint(expr=m.x1 * m.x2 == 1) + + visitor = self.get_visitor() + _, expr = visitor.walk_expression(m.c.body) + + x1 = visitor.var_map[id(m.x1)] + + # this is linear + self.assertEqual(expr.size(), 1) + self.assertEqual(expr.getCoeff(0), 4.0) + self.assertIs(expr.getVar(0), x1) + self.assertEqual(expr.getConstant(), 0.0) + + def test_write_product_with_0(self): + m = self.get_model() + m.c = Constraint(expr=(0 * m.x1 * m.x2) * m.x3 == 0) + + visitor = self.get_visitor() + _, expr = visitor.walk_expression(m.c.body) + + x1 = visitor.var_map[m.x1] + x2 = visitor.var_map[m.x2] + x3 = visitor.var_map[m.x3] + + # this is a "nonlinear" + opcode, data, parent = self._get_nl_expr_tree(visitor, expr) + + self.assertEqual(len(opcode), 6) + self.assertEqual(parent[0], -1) # root + self.assertEqual(opcode[0], GRB.OPCODE_MULTIPLY) + self.assertEqual(data[0], -1) # no additional data + + # first arg is another multiply with three children + self.assertEqual(parent[1], 0) + self.assertEqual(opcode[1], GRB.OPCODE_MULTIPLY) + self.assertEqual(data[0], -1) + + # second arg is the constant + self.assertEqual(parent[2], 1) + self.assertEqual(opcode[2], GRB.OPCODE_CONSTANT) + self.assertEqual(data[2], 0) + + # third arg is x1 + self.assertEqual(parent[3], 1) + self.assertEqual(opcode[3], GRB.OPCODE_VARIABLE) + self.assertIs(data[3], x1) + + # fourth arg is x2 + self.assertEqual(parent[4], 1) + self.assertEqual(opcode[4], GRB.OPCODE_VARIABLE) + self.assertIs(data[4], x2) + + # fifth arg is x3, whose parent is the root + self.assertEqual(parent[5], 0) + self.assertEqual(opcode[5], GRB.OPCODE_VARIABLE) + self.assertIs(data[5], x3) + + def test_write_division(self): + m = self.get_model() + m.c = Constraint(expr=1 / m.x1 == 1) + + visitor = self.get_visitor() + _, expr = visitor.walk_expression(m.c.body) + + visitor.grb_model.update() + grb_to_pyo_var_map = { + grb_var: py_var for py_var, grb_var in visitor.var_map.items() + } + + opcode, data, parent = self._get_nl_expr_tree(visitor, expr) + + pyo_expr = grb_nl_to_pyo_expr(opcode, data, parent, grb_to_pyo_var_map) + assertExpressionsEqual(self, pyo_expr, 1.0 / m.x1) + + def test_write_division_linear(self): + m = self.get_model() + m.p = Param(initialize=3, mutable=True) + m.c = Constraint(expr=(m.x1 + m.x2) * m.p / 10 == 1) + + visitor = self.get_visitor() + _, expr = visitor.walk_expression(m.c.body) + + x1 = visitor.var_map[id(m.x1)] + x2 = visitor.var_map[id(m.x2)] + + # linear + self.assertEqual(expr.size(), 2) + self.assertEqual(expr.getConstant(), 0) + self.assertAlmostEqual(expr.getCoeff(0), 3 / 10) + self.assertIs(expr.getVar(0), x1) + self.assertAlmostEqual(expr.getCoeff(1), 3 / 10) + self.assertIs(expr.getVar(1), x2) + + def test_write_quadratic_power_expression_var_const(self): + m = self.get_model() + m.c = Constraint(expr=m.x1**2 >= 3) + visitor = self.get_visitor() + _, expr = visitor.walk_expression(m.c.body) + + # This is also quadratic + x1 = visitor.var_map[id(m.x1)] + + self.assertEqual(expr.size(), 1) + lin_expr = expr.getLinExpr() + self.assertEqual(lin_expr.size(), 0) + self.assertEqual(lin_expr.getConstant(), 0) + self.assertIs(expr.getVar1(0), x1) + self.assertIs(expr.getVar2(0), x1) + self.assertEqual(expr.getCoeff(0), 1.0) + + def _get_nl_expr_tree(self, visitor, expr): + # This is a bit hacky, but the only way that I know to get the expression tree + # publicly is from a general nonlinear constraint. So we can create it, and + # then pull out the expression we just used to test it + grb_model = visitor.grb_model + aux = grb_model.addVar() + grb_model.addConstr(aux == expr) + grb_model.update() + constrs = grb_model.getGenConstrs() + self.assertEqual(len(constrs), 1) + + aux_var, opcode, data, parent = grb_model.getGenConstrNLAdv(constrs[0]) + self.assertIs(aux_var, aux) + return opcode, data, parent + + def test_write_nonquadratic_power_expression_var_const(self): + m = self.get_model() + m.c = Constraint(expr=m.x1**3 >= 3) + visitor = self.get_visitor() + _, expr = visitor.walk_expression(m.c.body) + + # This is general nonlinear + x1 = visitor.var_map[id(m.x1)] + + opcode, data, parent = self._get_nl_expr_tree(visitor, expr) + + # three nodes + self.assertEqual(len(opcode), 3) + + # the root is a power expression + self.assertEqual(parent[0], -1) # means root + self.assertEqual(opcode[0], GRB.OPCODE_POW) + # pow has no additional data + self.assertEqual(data[0], -1) + + # first child is x1 + self.assertEqual(parent[1], 0) + self.assertIs(data[1], x1) + self.assertEqual(opcode[1], GRB.OPCODE_VARIABLE) + + # second child is 3 + self.assertEqual(parent[2], 0) + self.assertEqual(opcode[2], GRB.OPCODE_CONSTANT) + self.assertEqual(data[2], 3.0) # the data is the constant's value + + def test_write_power_expression_var_var(self): + m = self.get_model() + m.c = Constraint(expr=m.x1**m.x2 >= 3) + visitor = self.get_visitor() + _, expr = visitor.walk_expression(m.c.body) + + # You can't actually use this in a model in Gurobi 12, but you can build the + # expression... (It fails during the solve for some reason.) + + x1 = visitor.var_map[id(m.x1)] + x2 = visitor.var_map[id(m.x2)] + + opcode, data, parent = self._get_nl_expr_tree(visitor, expr) + + # three nodes + self.assertEqual(len(opcode), 3) + + # the root is a power expression + self.assertEqual(parent[0], -1) # means root + self.assertEqual(opcode[0], GRB.OPCODE_POW) + # pow has no additional data + self.assertEqual(data[0], -1) + + # first child is x1 + self.assertEqual(parent[1], 0) + self.assertIs(data[1], x1) + self.assertEqual(opcode[1], GRB.OPCODE_VARIABLE) + + # second child is x2 + self.assertEqual(parent[2], 0) + self.assertEqual(opcode[2], GRB.OPCODE_VARIABLE) + self.assertIs(data[2], x2) + + def test_write_power_expression_const_var(self): + m = self.get_model() + m.c = Constraint(expr=2**m.x2 >= 3) + visitor = self.get_visitor() + _, expr = visitor.walk_expression(m.c.body) + + x2 = visitor.var_map[id(m.x2)] + + opcode, data, parent = self._get_nl_expr_tree(visitor, expr) + + # three nodes + self.assertEqual(len(opcode), 3) + + # the root is a power expression + self.assertEqual(parent[0], -1) # means root + self.assertEqual(opcode[0], GRB.OPCODE_POW) + # pow has no additional data + self.assertEqual(data[0], -1) + + # first child is 2 + self.assertEqual(parent[1], 0) + self.assertEqual(data[1], 2.0) + self.assertEqual(opcode[1], GRB.OPCODE_CONSTANT) + + # second child is x2 + self.assertEqual(parent[2], 0) + self.assertEqual(opcode[2], GRB.OPCODE_VARIABLE) + self.assertIs(data[2], x2) + + def test_write_absolute_value_of_var(self): + # Gurobi doesn't support abs of expressions, so we have to do a factorable + # programming thing... + m = self.get_model() + m.c = Constraint(expr=abs(m.x1) >= 3) + visitor = self.get_visitor() + _, expr = visitor.walk_expression(m.c.body) + + # expr is actually an auxiliary variable. We should + # get a constraint: + # expr == abs(x1) + x1 = visitor.var_map[id(m.x1)] + + self.assertIsInstance(expr, gurobipy.Var) + grb_model = visitor.grb_model + # We don't call update in walk expression for performance reasons, but + # we need to update here in order to be able to test expr. + grb_model.update() + self.assertEqual(grb_model.numVars, 2) + self.assertEqual(grb_model.numGenConstrs, 1) + self.assertEqual(grb_model.numConstrs, 0) + self.assertEqual(grb_model.numQConstrs, 0) + + cons = grb_model.getGenConstrs()[0] + aux, v = grb_model.getGenConstrAbs(cons) + self.assertIs(aux, expr) + self.assertIs(v, x1) + + def test_write_absolute_value_of_expression(self): + m = self.get_model() + m.c = Constraint(expr=abs(m.x1 + 2 * m.x2) >= 3) + visitor = self.get_visitor() + _, expr = visitor.walk_expression(m.c.body) + + # expr is actually an auxiliary variable. We should + # get three constraints: + # aux1 == x1 + 2 * x2 + # expr == abs(aux1) + + x1 = visitor.var_map[m.x1] + x2 = visitor.var_map[m.x2] + + # we're going to have to write the resulting model to an lp file to test that we + # have what we expect + self.assertIsInstance(expr, gurobipy.Var) + grb_model = visitor.grb_model + # We don't call update in walk expression for performance reasons, but + # we need to update here in order to be able to test expr. + grb_model.update() + self.assertEqual(grb_model.numVars, 4) + self.assertEqual(grb_model.numGenConstrs, 1) + self.assertEqual(grb_model.numConstrs, 1) + self.assertEqual(grb_model.numQConstrs, 0) + + cons = grb_model.getGenConstrs()[0] + aux2, aux1 = grb_model.getGenConstrAbs(cons) + self.assertIs(aux2, expr) + + cons = grb_model.getConstrs()[0] + # this guy is linear equality + self.assertEqual(cons.RHS, 0) + self.assertEqual(cons.Sense, '=') + linexpr = grb_model.getRow(cons) + self.assertEqual(linexpr.getConstant(), 0) + self.assertEqual(linexpr.size(), 3) + self.assertEqual(linexpr.getCoeff(0), -1) + self.assertIs(linexpr.getVar(0), x1) + self.assertEqual(linexpr.getCoeff(1), -2) + self.assertIs(linexpr.getVar(1), x2) + self.assertEqual(linexpr.getCoeff(2), 1) + self.assertIs(linexpr.getVar(2), aux1) + + def test_write_expression_with_mutable_param(self): + m = self.get_model() + m.p = Param(initialize=4, mutable=True) + m.c = Constraint(expr=m.p**m.x2 >= 3) + visitor = self.get_visitor() + _, expr = visitor.walk_expression(m.c.body) + + # expr is nonlinear + x2 = visitor.var_map[id(m.x2)] + + opcode, data, parent = self._get_nl_expr_tree(visitor, expr) + + # three nodes + self.assertEqual(len(opcode), 3) + + # the root is a power expression + self.assertEqual(parent[0], -1) # means root + self.assertEqual(opcode[0], GRB.OPCODE_POW) + # pow has no additional data + self.assertEqual(data[0], -1) + + # first child is 4 + self.assertEqual(parent[1], 0) + self.assertEqual(data[1], 4.0) + self.assertEqual(opcode[1], GRB.OPCODE_CONSTANT) + + # second child is x2 + self.assertEqual(parent[2], 0) + self.assertEqual(opcode[2], GRB.OPCODE_VARIABLE) + self.assertIs(data[2], x2) + + def test_monomial_expression(self): + m = self.get_model() + m.p = Param(initialize=4, mutable=True) + + const_expr = 3 * m.x1 + nested_expr = (1 / m.p) * m.x1 + pow_expr = (m.p ** (0.5)) * m.x1 + + visitor = self.get_visitor() + _, expr = visitor.walk_expression(const_expr) + x1 = visitor.var_map[id(m.x1)] + self.assertEqual(expr.size(), 1) + self.assertEqual(expr.getConstant(), 0.0) + self.assertIs(expr.getVar(0), x1) + self.assertEqual(expr.getCoeff(0), 3) + + _, expr = visitor.walk_expression(nested_expr) + self.assertEqual(expr.size(), 1) + self.assertEqual(expr.getConstant(), 0.0) + self.assertIs(expr.getVar(0), x1) + self.assertAlmostEqual(expr.getCoeff(0), 1 / 4) + + _, expr = visitor.walk_expression(pow_expr) + self.assertEqual(expr.size(), 1) + self.assertEqual(expr.getConstant(), 0.0) + self.assertIs(expr.getVar(0), x1) + self.assertEqual(expr.getCoeff(0), 2) + + def test_log_expression(self): + m = self.get_model() + m.c = Constraint(expr=log(m.x1) >= 3) + m.pprint() + visitor = self.get_visitor() + _, expr = visitor.walk_expression(m.c.body) + + # expr is nonlinear + x1 = visitor.var_map[id(m.x1)] + + opcode, data, parent = self._get_nl_expr_tree(visitor, expr) + + # two nodes + self.assertEqual(len(opcode), 2) + + # the root is a power expression + self.assertEqual(parent[0], -1) # means root + self.assertEqual(opcode[0], GRB.OPCODE_LOG) + self.assertEqual(data[0], -1) + + # child is x1 + self.assertEqual(parent[1], 0) + self.assertIs(data[1], x1) + self.assertEqual(opcode[1], GRB.OPCODE_VARIABLE) + + def test_handle_complex_number_sqrt(self): + m = self.get_model() + m.p = Param(initialize=3, mutable=True) + m.c = Constraint(expr=sqrt(-m.p) + m.x1 >= 3) + + visitor = self.get_visitor() + with self.assertRaisesRegex( + InvalidValueError, + r"Invalid number encountered evaluating constant unary expression " + r"sqrt\(- p\): math domain error", + ): + _, expr = visitor.walk_expression(m.c.body) + + def test_handle_invalid_log(self): + m = self.get_model() + m.p = Param(initialize=0, mutable=True) + m.c = Constraint(expr=log(m.p) + m.x1 >= 3) + + visitor = self.get_visitor() + with self.assertRaisesRegex( + InvalidValueError, + r"Invalid number encountered evaluating constant unary expression " + r"log\(p\): math domain error", + ): + _, expr = visitor.walk_expression(m.c.body) From cf18d8af1d5ad5f1bcea233751de5e03333a5152 Mon Sep 17 00:00:00 2001 From: Emma Johnson <12833636+emma58@users.noreply.github.com> Date: Thu, 2 Oct 2025 21:16:07 -0600 Subject: [PATCH 072/103] Incorporating John's to_bounded_expression suggestion --- .../contrib/solver/solvers/gurobi_direct_minlp.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/pyomo/contrib/solver/solvers/gurobi_direct_minlp.py b/pyomo/contrib/solver/solvers/gurobi_direct_minlp.py index 25229219d69..660b94dc25d 100755 --- a/pyomo/contrib/solver/solvers/gurobi_direct_minlp.py +++ b/pyomo/contrib/solver/solvers/gurobi_direct_minlp.py @@ -574,15 +574,14 @@ def write(self, model, **options): pyo_cons = [] grb_cons = [] for cons in components[Constraint]: + lb, body, ub = cons.to_bounded_expression(evaluate_bounds=True) expr_type, expr, nonlinear, aux = self._create_gurobi_expression( - cons.body, cons, 0, grb_model, quadratic_visitor, visitor + body, cons, 0, grb_model, quadratic_visitor, visitor ) if nonlinear: grb_model.addConstr(aux == expr) expr = aux - lb = value(cons.lb) - ub = value(cons.ub) - if expr_type == _CONSTANT: + elif expr_type == _CONSTANT: # cast everything to a float in case there are numpy # types because you can't do addConstr(np.True_) expr = float(expr) @@ -594,10 +593,13 @@ def write(self, model, **options): grb_cons.append(grb_model.addConstr(expr == lb)) pyo_cons.append(cons) else: - if cons.lb is not None: + # TODO: should be have special handling if expr is a + # GRB.LinExpr so that we can use the ranged linear + # constraint syntax (expr == [lb, ub])? + if lb is not None: grb_cons.append(grb_model.addConstr(expr >= lb)) pyo_cons.append(cons) - if cons.ub is not None: + if ub is not None: grb_cons.append(grb_model.addConstr(expr <= ub)) pyo_cons.append(cons) From b5241690a1949db99d65a7ff8dc6c438df8f953b Mon Sep 17 00:00:00 2001 From: Emma Johnson <12833636+emma58@users.noreply.github.com> Date: Fri, 3 Oct 2025 16:50:19 -0600 Subject: [PATCH 073/103] Removing debugging imports and some outdated comments --- pyomo/contrib/solver/solvers/gurobi_direct_minlp.py | 3 --- pyomo/contrib/solver/tests/solvers/test_gurobi_minlp_walker.py | 3 --- pyomo/contrib/solver/tests/solvers/test_gurobi_minlp_writer.py | 3 --- 3 files changed, 9 deletions(-) diff --git a/pyomo/contrib/solver/solvers/gurobi_direct_minlp.py b/pyomo/contrib/solver/solvers/gurobi_direct_minlp.py index 660b94dc25d..7f081fe6364 100755 --- a/pyomo/contrib/solver/solvers/gurobi_direct_minlp.py +++ b/pyomo/contrib/solver/solvers/gurobi_direct_minlp.py @@ -13,7 +13,6 @@ ## TODO # Look into if I can piggyback off of ipopt writer and just plug in my walker -# Why did I have to make a custom solution loader? # Move into contrib.solver: doc/onlinedoc/explanation/experimental has information about future solvers. Put some docs here. # Is there a half-matrix half-explicit way to give MINLPs to Gurobi? Soren thinks yes... # Open a PR into Miranda's fork. @@ -92,8 +91,6 @@ import sys -## DEBUG -from pytest import set_trace """ Even in Gurobi 12: diff --git a/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp_walker.py b/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp_walker.py index 8848e6df5d2..dff6322f3c9 100644 --- a/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp_walker.py +++ b/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp_walker.py @@ -39,9 +39,6 @@ if gurobipy_available: from gurobipy import GRB -## DEBUG -from pytest import set_trace - class CommonTest(unittest.TestCase): def get_model(self): diff --git a/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp_writer.py b/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp_writer.py index e08dc643817..c39a1fccf27 100644 --- a/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp_writer.py +++ b/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp_writer.py @@ -47,9 +47,6 @@ from pyomo.contrib.solver.common.results import TerminationCondition from pyomo.contrib.solver.tests.solvers.test_gurobi_minlp_walker import CommonTest -## DEBUG -from pytest import set_trace - gurobipy, gurobipy_available = attempt_import('gurobipy', minimum_version='12.0.0') if gurobipy_available: from gurobipy import GRB From a1f7c8bc9202936433f7eae93e34ee4f7f635cbe Mon Sep 17 00:00:00 2001 From: Emma Johnson <12833636+emma58@users.noreply.github.com> Date: Mon, 6 Oct 2025 10:59:56 -0600 Subject: [PATCH 074/103] NFC: Cleaning up some comments --- .../solver/solvers/gurobi_direct_minlp.py | 29 ++++++------------- .../tests/solvers/test_gurobi_minlp_walker.py | 2 +- .../tests/solvers/test_gurobi_minlp_writer.py | 7 ----- 3 files changed, 10 insertions(+), 28 deletions(-) diff --git a/pyomo/contrib/solver/solvers/gurobi_direct_minlp.py b/pyomo/contrib/solver/solvers/gurobi_direct_minlp.py index 7f081fe6364..e0825c0c8c7 100755 --- a/pyomo/contrib/solver/solvers/gurobi_direct_minlp.py +++ b/pyomo/contrib/solver/solvers/gurobi_direct_minlp.py @@ -10,13 +10,6 @@ # ___________________________________________________________________________ -## TODO - -# Look into if I can piggyback off of ipopt writer and just plug in my walker -# Move into contrib.solver: doc/onlinedoc/explanation/experimental has information about future solvers. Put some docs here. -# Is there a half-matrix half-explicit way to give MINLPs to Gurobi? Soren thinks yes... -# Open a PR into Miranda's fork. - import datetime import io from operator import attrgetter, itemgetter @@ -28,7 +21,7 @@ from pyomo.common.numeric_types import native_complex_types from pyomo.common.timing import HierarchicalTimer -# ESJ TODO: We should move this somewhere sensible +# ESJ TODO: Could we move this to util or somewhere less bizarre? from pyomo.contrib.cp.repn.docplex_writer import collect_valid_components from pyomo.contrib.solver.common.factory import SolverFactory from pyomo.contrib.solver.common.solution_loader import SolutionLoaderBase @@ -93,25 +86,21 @@ """ -Even in Gurobi 12: +In Gurobi 12: If you have f(x) == 0, you must write it as z == f(x) and then write z == 0. Basically, you must introduce auxiliary variables for all the general nonlinear parts. (And no worries about additively separable or anything--they do that under the hood). -Radhakrishna thinks we should replace the *entire* LHS of the constraint with the +In this implementation, we replace the *entire* LHS of the constraint with the auxiliary variable rather than just the nonlinear part. Otherwise we would really need to keep track of what nonlinear subexpressions we had already replaced and make -sure to use the same auxiliary variables. - -Conclusion: So I think I should actually build on top of the linear walker and then -replace anything that has a nonlinear part... - -Model.addConstr() doesn't have the three-arg version anymore. +sure to use the same auxiliary variables, and from what we know, this is probably not +worth it. -Let's not use the '.nl' attribute at all for now--seems like the exception rather than -the rule that you would want to specifically tell Gurobi *not* to expand the expression. +We are not using Gurobi's '.nl' attribute at all for now--its usage seems like the +exception rather than the rule, so we will let Gurobi expand the expressions for now. """ _CONSTANT = ExprType.CONSTANT @@ -321,7 +310,7 @@ def _handle_abs_var(visitor, node, arg1): def _handle_abs_expression(visitor, node, arg1): - # we need auxiliary variable + # we need an auxiliary variable aux_arg = visitor.grb_model.addVar(lb=-GRB.INFINITY, ub=GRB.INFINITY) visitor.grb_model.addConstr(aux_arg == arg1[1]) # This one truly is non-negative because it's an absolute value @@ -379,7 +368,7 @@ def define_exit_node_handlers(_exit_node_handlers=None): (_CONSTANT,): _handle_unary_constant, } - ## TODO: ExprIf, RangedExpressions (if we do exprif... + ## TODO: ExprIf, RangedExpressions (if we do exprif...) _exit_node_handlers[Expression] = {None: _handle_named_expression} # These are special because of quirks of Gurobi's current support for general diff --git a/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp_walker.py b/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp_walker.py index dff6322f3c9..0162a2b4143 100644 --- a/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp_walker.py +++ b/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp_walker.py @@ -363,7 +363,7 @@ def test_write_power_expression_var_var(self): _, expr = visitor.walk_expression(m.c.body) # You can't actually use this in a model in Gurobi 12, but you can build the - # expression... (It fails during the solve for some reason.) + # expression... (It fails during the solve.) x1 = visitor.var_map[id(m.x1)] x2 = visitor.var_map[id(m.x2)] diff --git a/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp_writer.py b/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp_writer.py index c39a1fccf27..53469197364 100644 --- a/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp_writer.py +++ b/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp_writer.py @@ -533,10 +533,6 @@ def test_numpy_trivially_true_constraint(self): self.assertEqual(results.incumbent_objective, 0) self.assertEqual(results.objective_bound, 0) - # ESJ TODO: There's still a bug here, but it's with the - # expression type I'm passing through, not with the numpy - # situation now. - def test_trivially_true_constraint(self): """ We can pass trivially true things to Gurobi and it's fine @@ -558,6 +554,3 @@ def test_trivially_true_constraint(self): self.assertEqual(results.incumbent_objective, 2) self.assertEqual(results.objective_bound, 2) - -# ESJ: Note: It appears they don't allow x1 ** x2...? Well, they wait and give the -# error in the solver log, so not sure what we want to do about that? From 383af85f202193cc395d356c2b1e44d0c50f2cb0 Mon Sep 17 00:00:00 2001 From: Emma Johnson <12833636+emma58@users.noreply.github.com> Date: Mon, 6 Oct 2025 11:29:58 -0600 Subject: [PATCH 075/103] Oh my goodness black --- pyomo/contrib/solver/tests/solvers/test_gurobi_minlp_writer.py | 1 - 1 file changed, 1 deletion(-) diff --git a/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp_writer.py b/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp_writer.py index 53469197364..390fe509c60 100644 --- a/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp_writer.py +++ b/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp_writer.py @@ -553,4 +553,3 @@ def test_trivially_true_constraint(self): self.assertEqual(value(m.obj), 2) self.assertEqual(results.incumbent_objective, 2) self.assertEqual(results.objective_bound, 2) - From 08390a02c86efcd729f8f5b0c3a43fd967652907 Mon Sep 17 00:00:00 2001 From: Emma Johnson <12833636+emma58@users.noreply.github.com> Date: Mon, 6 Oct 2025 11:46:54 -0600 Subject: [PATCH 076/103] Fixing an optional dependency import issue --- .../solver/solvers/gurobi_direct_minlp.py | 66 +++++++++---------- 1 file changed, 33 insertions(+), 33 deletions(-) diff --git a/pyomo/contrib/solver/solvers/gurobi_direct_minlp.py b/pyomo/contrib/solver/solvers/gurobi_direct_minlp.py index e0825c0c8c7..727b6ca9b93 100755 --- a/pyomo/contrib/solver/solvers/gurobi_direct_minlp.py +++ b/pyomo/contrib/solver/solvers/gurobi_direct_minlp.py @@ -84,24 +84,12 @@ import sys +### FIXME: Remove the following as soon as non-active components no +### longer report active==True +from pyomo.network import Port +from pyomo.core.base import RangeSet, Set -""" -In Gurobi 12: - -If you have f(x) == 0, you must write it as z == f(x) and then write z == 0. -Basically, you must introduce auxiliary variables for all the general nonlinear -parts. (And no worries about additively separable or anything--they do that -under the hood). - -In this implementation, we replace the *entire* LHS of the constraint with the -auxiliary variable rather than just the nonlinear part. Otherwise we would really -need to keep track of what nonlinear subexpressions we had already replaced and make -sure to use the same auxiliary variables, and from what we know, this is probably not -worth it. - -We are not using Gurobi's '.nl' attribute at all for now--its usage seems like the -exception rather than the rule, so we will let Gurobi expand the expressions for now. -""" +### _CONSTANT = ExprType.CONSTANT _GENERAL = ExprType.GENERAL @@ -110,6 +98,7 @@ _VARIABLE = ExprType.VARIABLE _function_map = {} +_domain_map = ComponentMap() gurobipy, gurobipy_available = attempt_import('gurobipy', minimum_version='12.0.0') if gurobipy_available: @@ -140,25 +129,36 @@ } ) -### FIXME: Remove the following as soon as non-active components no -### longer report active==True -from pyomo.network import Port -from pyomo.core.base import RangeSet, Set + _domain_map.update( + ( + (Binary, (GRB.BINARY, -float('inf'), float('inf'))), + (Integers, (GRB.INTEGER, -float('inf'), float('inf'))), + (NonNegativeIntegers, (GRB.INTEGER, 0, float('inf'))), + (NonPositiveIntegers, (GRB.INTEGER, -float('inf'), 0)), + (NonNegativeReals, (GRB.CONTINUOUS, 0, float('inf'))), + (NonPositiveReals, (GRB.CONTINUOUS, -float('inf'), 0)), + (Reals, (GRB.CONTINUOUS, -float('inf'), float('inf'))), + ) + ) -### + +""" +In Gurobi 12: +If you have f(x) == 0, you must write it as z == f(x) and then write z == 0. +Basically, you must introduce auxiliary variables for all the general nonlinear +parts. (And no worries about additively separable or anything--they do that +under the hood). -_domain_map = ComponentMap( - ( - (Binary, (GRB.BINARY, -float('inf'), float('inf'))), - (Integers, (GRB.INTEGER, -float('inf'), float('inf'))), - (NonNegativeIntegers, (GRB.INTEGER, 0, float('inf'))), - (NonPositiveIntegers, (GRB.INTEGER, -float('inf'), 0)), - (NonNegativeReals, (GRB.CONTINUOUS, 0, float('inf'))), - (NonPositiveReals, (GRB.CONTINUOUS, -float('inf'), 0)), - (Reals, (GRB.CONTINUOUS, -float('inf'), float('inf'))), - ) -) +In this implementation, we replace the *entire* LHS of the constraint with the +auxiliary variable rather than just the nonlinear part. Otherwise we would really +need to keep track of what nonlinear subexpressions we had already replaced and make +sure to use the same auxiliary variables, and from what we know, this is probably not +worth it. + +We are not using Gurobi's '.nl' attribute at all for now--its usage seems like the +exception rather than the rule, so we will let Gurobi expand the expressions for now. +""" def _create_grb_var(visitor, pyomo_var, name=""): From bbb1da8bef2d447ad60d44000ab71f9b4494b7cf Mon Sep 17 00:00:00 2001 From: Emma Johnson <12833636+emma58@users.noreply.github.com> Date: Mon, 6 Oct 2025 11:47:14 -0600 Subject: [PATCH 077/103] More black --- pyomo/contrib/solver/solvers/gurobi_direct_minlp.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyomo/contrib/solver/solvers/gurobi_direct_minlp.py b/pyomo/contrib/solver/solvers/gurobi_direct_minlp.py index 727b6ca9b93..ff958ad844d 100755 --- a/pyomo/contrib/solver/solvers/gurobi_direct_minlp.py +++ b/pyomo/contrib/solver/solvers/gurobi_direct_minlp.py @@ -141,7 +141,7 @@ ) ) - + """ In Gurobi 12: From 8cc3995cf32638ccbaf45b072a69ed1e600301e0 Mon Sep 17 00:00:00 2001 From: Emma Johnson <12833636+emma58@users.noreply.github.com> Date: Mon, 6 Oct 2025 12:29:08 -0600 Subject: [PATCH 078/103] Removing some forgotten print statements --- .../contrib/solver/tests/solvers/gurobi_to_pyomo_expressions.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/pyomo/contrib/solver/tests/solvers/gurobi_to_pyomo_expressions.py b/pyomo/contrib/solver/tests/solvers/gurobi_to_pyomo_expressions.py index a0c8eb0d336..a4b87736425 100644 --- a/pyomo/contrib/solver/tests/solvers/gurobi_to_pyomo_expressions.py +++ b/pyomo/contrib/solver/tests/solvers/gurobi_to_pyomo_expressions.py @@ -68,8 +68,6 @@ def grb_nl_to_pyo_expr(opcode, data, parent, var_map): ) if i: # there are two special cases here to account for minus and square - print(ans[parent].__class__) - print(ans[parent]._args_) ans[parent]._args_ = ans[parent]._args_ + [ans[-1]] if ans[parent].__class__ in nary_ops: ans[parent]._nargs += 1 From 2e84907cbc39fe058e81020f21a872b0cdf6f896 Mon Sep 17 00:00:00 2001 From: Emma Johnson <12833636+emma58@users.noreply.github.com> Date: Mon, 6 Oct 2025 12:29:36 -0600 Subject: [PATCH 079/103] Simplifying the tests for the walker by converting back to pyomo expressions --- .../tests/solvers/test_gurobi_minlp_walker.py | 188 ++++++------------ 1 file changed, 60 insertions(+), 128 deletions(-) diff --git a/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp_walker.py b/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp_walker.py index 0162a2b4143..78641d639e2 100644 --- a/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp_walker.py +++ b/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp_walker.py @@ -11,6 +11,9 @@ from pyomo.common.dependencies import attempt_import from pyomo.core.expr.compare import assertExpressionsEqual +from pyomo.core.expr import ( + ProductExpression +) from pyomo.common.errors import InvalidValueError import pyomo.common.unittest as unittest from pyomo.contrib.solver.solvers.gurobi_direct_minlp import GurobiMINLPVisitor @@ -60,6 +63,21 @@ def get_visitor(self): @unittest.skipUnless(gurobipy_available, "gurobipy is not available") class TestGurobiMINLPWalker(CommonTest): + def _get_nl_expr_tree(self, visitor, expr): + # This is a bit hacky, but the only way that I know to get the expression tree + # publicly is from a general nonlinear constraint. So we can create it, and + # then pull out the expression we just used to test it + grb_model = visitor.grb_model + aux = grb_model.addVar() + grb_model.addConstr(aux == expr) + grb_model.update() + constrs = grb_model.getGenConstrs() + self.assertEqual(len(constrs), 1) + + aux_var, opcode, data, parent = grb_model.getGenConstrNLAdv(constrs[0]) + self.assertIs(aux_var, aux) + return opcode, data, parent + def test_var_domains(self): m = self.get_model() e = m.x1 + m.x2 + m.x3 + m.y1 + m.y2 + m.y3 + m.z1 @@ -221,42 +239,17 @@ def test_write_product_with_0(self): visitor = self.get_visitor() _, expr = visitor.walk_expression(m.c.body) - x1 = visitor.var_map[m.x1] - x2 = visitor.var_map[m.x2] - x3 = visitor.var_map[m.x3] - # this is a "nonlinear" opcode, data, parent = self._get_nl_expr_tree(visitor, expr) - self.assertEqual(len(opcode), 6) - self.assertEqual(parent[0], -1) # root - self.assertEqual(opcode[0], GRB.OPCODE_MULTIPLY) - self.assertEqual(data[0], -1) # no additional data + reverse_var_map = {grb_v : pyo_v for pyo_v, grb_v in visitor.var_map.items()} + pyo_expr = grb_nl_to_pyo_expr(opcode, data, parent, reverse_var_map) - # first arg is another multiply with three children - self.assertEqual(parent[1], 0) - self.assertEqual(opcode[1], GRB.OPCODE_MULTIPLY) - self.assertEqual(data[0], -1) - - # second arg is the constant - self.assertEqual(parent[2], 1) - self.assertEqual(opcode[2], GRB.OPCODE_CONSTANT) - self.assertEqual(data[2], 0) - - # third arg is x1 - self.assertEqual(parent[3], 1) - self.assertEqual(opcode[3], GRB.OPCODE_VARIABLE) - self.assertIs(data[3], x1) - - # fourth arg is x2 - self.assertEqual(parent[4], 1) - self.assertEqual(opcode[4], GRB.OPCODE_VARIABLE) - self.assertIs(data[4], x2) - - # fifth arg is x3, whose parent is the root - self.assertEqual(parent[5], 0) - self.assertEqual(opcode[5], GRB.OPCODE_VARIABLE) - self.assertIs(data[5], x3) + assertExpressionsEqual( + self, + pyo_expr, + ProductExpression((ProductExpression((0.0, m.x1, m.x2, m.x3)),)) + ) def test_write_division(self): m = self.get_model() @@ -311,21 +304,6 @@ def test_write_quadratic_power_expression_var_const(self): self.assertIs(expr.getVar2(0), x1) self.assertEqual(expr.getCoeff(0), 1.0) - def _get_nl_expr_tree(self, visitor, expr): - # This is a bit hacky, but the only way that I know to get the expression tree - # publicly is from a general nonlinear constraint. So we can create it, and - # then pull out the expression we just used to test it - grb_model = visitor.grb_model - aux = grb_model.addVar() - grb_model.addConstr(aux == expr) - grb_model.update() - constrs = grb_model.getGenConstrs() - self.assertEqual(len(constrs), 1) - - aux_var, opcode, data, parent = grb_model.getGenConstrNLAdv(constrs[0]) - self.assertIs(aux_var, aux) - return opcode, data, parent - def test_write_nonquadratic_power_expression_var_const(self): m = self.get_model() m.c = Constraint(expr=m.x1**3 >= 3) @@ -333,28 +311,16 @@ def test_write_nonquadratic_power_expression_var_const(self): _, expr = visitor.walk_expression(m.c.body) # This is general nonlinear - x1 = visitor.var_map[id(m.x1)] - opcode, data, parent = self._get_nl_expr_tree(visitor, expr) - # three nodes - self.assertEqual(len(opcode), 3) + reverse_var_map = {grb_v : pyo_v for pyo_v, grb_v in visitor.var_map.items()} + pyo_expr = grb_nl_to_pyo_expr(opcode, data, parent, reverse_var_map) - # the root is a power expression - self.assertEqual(parent[0], -1) # means root - self.assertEqual(opcode[0], GRB.OPCODE_POW) - # pow has no additional data - self.assertEqual(data[0], -1) - - # first child is x1 - self.assertEqual(parent[1], 0) - self.assertIs(data[1], x1) - self.assertEqual(opcode[1], GRB.OPCODE_VARIABLE) - - # second child is 3 - self.assertEqual(parent[2], 0) - self.assertEqual(opcode[2], GRB.OPCODE_CONSTANT) - self.assertEqual(data[2], 3.0) # the data is the constant's value + assertExpressionsEqual( + self, + pyo_expr, + m.x1 ** 3.0 + ) def test_write_power_expression_var_var(self): m = self.get_model() @@ -370,24 +336,14 @@ def test_write_power_expression_var_var(self): opcode, data, parent = self._get_nl_expr_tree(visitor, expr) - # three nodes - self.assertEqual(len(opcode), 3) - - # the root is a power expression - self.assertEqual(parent[0], -1) # means root - self.assertEqual(opcode[0], GRB.OPCODE_POW) - # pow has no additional data - self.assertEqual(data[0], -1) + reverse_var_map = {grb_v : pyo_v for pyo_v, grb_v in visitor.var_map.items()} + pyo_expr = grb_nl_to_pyo_expr(opcode, data, parent, reverse_var_map) - # first child is x1 - self.assertEqual(parent[1], 0) - self.assertIs(data[1], x1) - self.assertEqual(opcode[1], GRB.OPCODE_VARIABLE) - - # second child is x2 - self.assertEqual(parent[2], 0) - self.assertEqual(opcode[2], GRB.OPCODE_VARIABLE) - self.assertIs(data[2], x2) + assertExpressionsEqual( + self, + pyo_expr, + m.x1 ** m.x2 + ) def test_write_power_expression_const_var(self): m = self.get_model() @@ -399,24 +355,14 @@ def test_write_power_expression_const_var(self): opcode, data, parent = self._get_nl_expr_tree(visitor, expr) - # three nodes - self.assertEqual(len(opcode), 3) - - # the root is a power expression - self.assertEqual(parent[0], -1) # means root - self.assertEqual(opcode[0], GRB.OPCODE_POW) - # pow has no additional data - self.assertEqual(data[0], -1) - - # first child is 2 - self.assertEqual(parent[1], 0) - self.assertEqual(data[1], 2.0) - self.assertEqual(opcode[1], GRB.OPCODE_CONSTANT) + reverse_var_map = {grb_v : pyo_v for pyo_v, grb_v in visitor.var_map.items()} + pyo_expr = grb_nl_to_pyo_expr(opcode, data, parent, reverse_var_map) - # second child is x2 - self.assertEqual(parent[2], 0) - self.assertEqual(opcode[2], GRB.OPCODE_VARIABLE) - self.assertIs(data[2], x2) + assertExpressionsEqual( + self, + pyo_expr, + 2.0 ** m.x2 + ) def test_write_absolute_value_of_var(self): # Gurobi doesn't support abs of expressions, so we have to do a factorable @@ -502,24 +448,14 @@ def test_write_expression_with_mutable_param(self): opcode, data, parent = self._get_nl_expr_tree(visitor, expr) - # three nodes - self.assertEqual(len(opcode), 3) + reverse_var_map = {grb_v : pyo_v for pyo_v, grb_v in visitor.var_map.items()} + pyo_expr = grb_nl_to_pyo_expr(opcode, data, parent, reverse_var_map) - # the root is a power expression - self.assertEqual(parent[0], -1) # means root - self.assertEqual(opcode[0], GRB.OPCODE_POW) - # pow has no additional data - self.assertEqual(data[0], -1) - - # first child is 4 - self.assertEqual(parent[1], 0) - self.assertEqual(data[1], 4.0) - self.assertEqual(opcode[1], GRB.OPCODE_CONSTANT) - - # second child is x2 - self.assertEqual(parent[2], 0) - self.assertEqual(opcode[2], GRB.OPCODE_VARIABLE) - self.assertIs(data[2], x2) + assertExpressionsEqual( + self, + pyo_expr, + 4.0 ** m.x2 + ) def test_monomial_expression(self): m = self.get_model() @@ -561,18 +497,14 @@ def test_log_expression(self): opcode, data, parent = self._get_nl_expr_tree(visitor, expr) - # two nodes - self.assertEqual(len(opcode), 2) - - # the root is a power expression - self.assertEqual(parent[0], -1) # means root - self.assertEqual(opcode[0], GRB.OPCODE_LOG) - self.assertEqual(data[0], -1) + reverse_var_map = {grb_v : pyo_v for pyo_v, grb_v in visitor.var_map.items()} + pyo_expr = grb_nl_to_pyo_expr(opcode, data, parent, reverse_var_map) - # child is x1 - self.assertEqual(parent[1], 0) - self.assertIs(data[1], x1) - self.assertEqual(opcode[1], GRB.OPCODE_VARIABLE) + assertExpressionsEqual( + self, + pyo_expr, + log(m.x1) + ) def test_handle_complex_number_sqrt(self): m = self.get_model() From bf207e5f6b77e51ee1cd5c10f1e490ec9193948a Mon Sep 17 00:00:00 2001 From: Emma Johnson <12833636+emma58@users.noreply.github.com> Date: Mon, 6 Oct 2025 12:30:13 -0600 Subject: [PATCH 080/103] black --- .../tests/solvers/test_gurobi_minlp_walker.py | 50 ++++++------------- 1 file changed, 14 insertions(+), 36 deletions(-) diff --git a/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp_walker.py b/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp_walker.py index 78641d639e2..39187a50ec1 100644 --- a/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp_walker.py +++ b/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp_walker.py @@ -11,9 +11,7 @@ from pyomo.common.dependencies import attempt_import from pyomo.core.expr.compare import assertExpressionsEqual -from pyomo.core.expr import ( - ProductExpression -) +from pyomo.core.expr import ProductExpression from pyomo.common.errors import InvalidValueError import pyomo.common.unittest as unittest from pyomo.contrib.solver.solvers.gurobi_direct_minlp import GurobiMINLPVisitor @@ -77,7 +75,7 @@ def _get_nl_expr_tree(self, visitor, expr): aux_var, opcode, data, parent = grb_model.getGenConstrNLAdv(constrs[0]) self.assertIs(aux_var, aux) return opcode, data, parent - + def test_var_domains(self): m = self.get_model() e = m.x1 + m.x2 + m.x3 + m.y1 + m.y2 + m.y3 + m.z1 @@ -242,13 +240,13 @@ def test_write_product_with_0(self): # this is a "nonlinear" opcode, data, parent = self._get_nl_expr_tree(visitor, expr) - reverse_var_map = {grb_v : pyo_v for pyo_v, grb_v in visitor.var_map.items()} + reverse_var_map = {grb_v: pyo_v for pyo_v, grb_v in visitor.var_map.items()} pyo_expr = grb_nl_to_pyo_expr(opcode, data, parent, reverse_var_map) assertExpressionsEqual( self, pyo_expr, - ProductExpression((ProductExpression((0.0, m.x1, m.x2, m.x3)),)) + ProductExpression((ProductExpression((0.0, m.x1, m.x2, m.x3)),)), ) def test_write_division(self): @@ -313,14 +311,10 @@ def test_write_nonquadratic_power_expression_var_const(self): # This is general nonlinear opcode, data, parent = self._get_nl_expr_tree(visitor, expr) - reverse_var_map = {grb_v : pyo_v for pyo_v, grb_v in visitor.var_map.items()} + reverse_var_map = {grb_v: pyo_v for pyo_v, grb_v in visitor.var_map.items()} pyo_expr = grb_nl_to_pyo_expr(opcode, data, parent, reverse_var_map) - assertExpressionsEqual( - self, - pyo_expr, - m.x1 ** 3.0 - ) + assertExpressionsEqual(self, pyo_expr, m.x1**3.0) def test_write_power_expression_var_var(self): m = self.get_model() @@ -336,14 +330,10 @@ def test_write_power_expression_var_var(self): opcode, data, parent = self._get_nl_expr_tree(visitor, expr) - reverse_var_map = {grb_v : pyo_v for pyo_v, grb_v in visitor.var_map.items()} + reverse_var_map = {grb_v: pyo_v for pyo_v, grb_v in visitor.var_map.items()} pyo_expr = grb_nl_to_pyo_expr(opcode, data, parent, reverse_var_map) - assertExpressionsEqual( - self, - pyo_expr, - m.x1 ** m.x2 - ) + assertExpressionsEqual(self, pyo_expr, m.x1**m.x2) def test_write_power_expression_const_var(self): m = self.get_model() @@ -355,14 +345,10 @@ def test_write_power_expression_const_var(self): opcode, data, parent = self._get_nl_expr_tree(visitor, expr) - reverse_var_map = {grb_v : pyo_v for pyo_v, grb_v in visitor.var_map.items()} + reverse_var_map = {grb_v: pyo_v for pyo_v, grb_v in visitor.var_map.items()} pyo_expr = grb_nl_to_pyo_expr(opcode, data, parent, reverse_var_map) - assertExpressionsEqual( - self, - pyo_expr, - 2.0 ** m.x2 - ) + assertExpressionsEqual(self, pyo_expr, 2.0**m.x2) def test_write_absolute_value_of_var(self): # Gurobi doesn't support abs of expressions, so we have to do a factorable @@ -448,14 +434,10 @@ def test_write_expression_with_mutable_param(self): opcode, data, parent = self._get_nl_expr_tree(visitor, expr) - reverse_var_map = {grb_v : pyo_v for pyo_v, grb_v in visitor.var_map.items()} + reverse_var_map = {grb_v: pyo_v for pyo_v, grb_v in visitor.var_map.items()} pyo_expr = grb_nl_to_pyo_expr(opcode, data, parent, reverse_var_map) - assertExpressionsEqual( - self, - pyo_expr, - 4.0 ** m.x2 - ) + assertExpressionsEqual(self, pyo_expr, 4.0**m.x2) def test_monomial_expression(self): m = self.get_model() @@ -497,14 +479,10 @@ def test_log_expression(self): opcode, data, parent = self._get_nl_expr_tree(visitor, expr) - reverse_var_map = {grb_v : pyo_v for pyo_v, grb_v in visitor.var_map.items()} + reverse_var_map = {grb_v: pyo_v for pyo_v, grb_v in visitor.var_map.items()} pyo_expr = grb_nl_to_pyo_expr(opcode, data, parent, reverse_var_map) - assertExpressionsEqual( - self, - pyo_expr, - log(m.x1) - ) + assertExpressionsEqual(self, pyo_expr, log(m.x1)) def test_handle_complex_number_sqrt(self): m = self.get_model() From 02d37db2e045abeafe3abc8508203df322eed49c Mon Sep 17 00:00:00 2001 From: Emma Johnson <12833636+emma58@users.noreply.github.com> Date: Tue, 7 Oct 2025 10:36:53 -0600 Subject: [PATCH 081/103] Removing the last writer test onto the conversion back to Pyomo expressions --- .../tests/solvers/test_gurobi_minlp_writer.py | 38 ++++--------------- 1 file changed, 8 insertions(+), 30 deletions(-) diff --git a/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp_writer.py b/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp_writer.py index 390fe509c60..770bbbccd52 100644 --- a/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp_writer.py +++ b/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp_writer.py @@ -17,7 +17,7 @@ grb_nl_to_pyo_expr, ) from pyomo.core.expr.compare import assertExpressionsEqual -from pyomo.core.expr.numeric_expr import SumExpression +from pyomo.core.expr.numeric_expr import SumExpression, ProductExpression from pyomo.environ import ( Binary, BooleanVar, @@ -477,36 +477,14 @@ def test_unbounded_because_of_multiplying_by_0(self): res_var, opcode, data, parent = grb_model.getGenConstrNLAdv(c) # This is where we link into the linear inequality constraint self.assertIs(res_var, aux_var) + reverse_var_map = {grb_v: pyo_v for pyo_v, grb_v in var_map.items()} + pyo_expr = grb_nl_to_pyo_expr(opcode, data, parent, reverse_var_map) - self.assertEqual(len(opcode), 6) - self.assertEqual(parent[0], -1) # root - self.assertEqual(opcode[0], GRB.OPCODE_MULTIPLY) - self.assertEqual(data[0], -1) # no additional data - - # first arg is another multiply with three children - self.assertEqual(parent[1], 0) - self.assertEqual(opcode[1], GRB.OPCODE_MULTIPLY) - self.assertEqual(data[0], -1) - - # second arg is the constant - self.assertEqual(parent[2], 1) - self.assertEqual(opcode[2], GRB.OPCODE_CONSTANT) - self.assertEqual(data[2], 0) - - # third arg is x1 - self.assertEqual(parent[3], 1) - self.assertEqual(opcode[3], GRB.OPCODE_VARIABLE) - self.assertIs(data[3], x1) - - # fourth arg is x2 - self.assertEqual(parent[4], 1) - self.assertEqual(opcode[4], GRB.OPCODE_VARIABLE) - self.assertIs(data[4], x2) - - # fifth arg is x3, whose parent is the root - self.assertEqual(parent[5], 0) - self.assertEqual(opcode[5], GRB.OPCODE_VARIABLE) - self.assertIs(data[5], x3) + assertExpressionsEqual( + self, + pyo_expr, + ProductExpression((ProductExpression((0.0, m.x1, m.x2, m.x3)),)), + ) opt = SolverFactory('gurobi_direct_minlp') opt.config.raise_exception_on_nonoptimal_result = False From 3de977f43a3312508dbf4f4c46ea84f57dde3cd0 Mon Sep 17 00:00:00 2001 From: Emma Johnson <12833636+emma58@users.noreply.github.com> Date: Thu, 9 Oct 2025 10:46:30 -0600 Subject: [PATCH 082/103] Adding some tests for missing cases for pow expressions --- .../tests/solvers/test_gurobi_minlp_walker.py | 58 ++++++++++++++++++- 1 file changed, 56 insertions(+), 2 deletions(-) diff --git a/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp_walker.py b/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp_walker.py index 39187a50ec1..afa2a492415 100644 --- a/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp_walker.py +++ b/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp_walker.py @@ -11,7 +11,7 @@ from pyomo.common.dependencies import attempt_import from pyomo.core.expr.compare import assertExpressionsEqual -from pyomo.core.expr import ProductExpression +from pyomo.core.expr import ProductExpression, SumExpression from pyomo.common.errors import InvalidValueError import pyomo.common.unittest as unittest from pyomo.contrib.solver.solvers.gurobi_direct_minlp import GurobiMINLPVisitor @@ -285,13 +285,51 @@ def test_write_division_linear(self): self.assertAlmostEqual(expr.getCoeff(1), 3 / 10) self.assertIs(expr.getVar(1), x2) + def test_write_linear_power_expression_var_const(self): + m = self.get_model() + m.devious = Param(initialize=1, mutable=True) + m.c = Constraint(expr=m.x1**m.devious >= 3) + visitor = self.get_visitor() + _, expr = visitor.walk_expression(m.c.body) + + x1 = visitor.var_map[id(m.x1)] + + # It's just a single var + self.assertIs(expr, x1) + self.assertEqual(len(visitor.grb_model.getGenConstrs()), 0) + + # now try a linear expression + m.c2 = Constraint(expr=(m.x1 + 2*m.x2)**m.devious >= 5) + _, lin_expr = visitor.walk_expression(m.c2.body) + self.assertEqual(len(visitor.grb_model.getGenConstrs()), 0) + x2 = visitor.var_map[m.x2] + self.assertEqual(lin_expr.size(), 2) + self.assertEqual(lin_expr.getConstant(), 0) + self.assertIs(lin_expr.getVar(0), x1) + self.assertIs(lin_expr.getVar(1), x2) + self.assertEqual(lin_expr.getCoeff(0), 1.0) + self.assertEqual(lin_expr.getCoeff(1), 2.0) + + # now do a quadratic expression + m.c3 = Constraint(expr=(m.x1**2 + 5.4)**m.devious >= 8) + _, quad_expr = visitor.walk_expression(m.c3.body) + self.assertEqual(len(visitor.grb_model.getGenConstrs()), 0) + self.assertEqual(quad_expr.size(), 1) + expr = quad_expr.getLinExpr() + # no vars in linear part, just the constant + self.assertEqual(expr.size(), 0) + self.assertEqual(expr.getConstant(), 5.4) + self.assertIs(quad_expr.getVar1(0), x1) + self.assertIs(quad_expr.getVar2(0), x1) + self.assertEqual(quad_expr.getCoeff(0), 1.0) + def test_write_quadratic_power_expression_var_const(self): m = self.get_model() m.c = Constraint(expr=m.x1**2 >= 3) visitor = self.get_visitor() _, expr = visitor.walk_expression(m.c.body) - # This is also quadratic + # This is quadratic x1 = visitor.var_map[id(m.x1)] self.assertEqual(expr.size(), 1) @@ -302,6 +340,22 @@ def test_write_quadratic_power_expression_var_const(self): self.assertIs(expr.getVar2(0), x1) self.assertEqual(expr.getCoeff(0), 1.0) + def test_write_quadratic_constant_pow_expression(self): + m = self.get_model() + m.c = Constraint(expr=(m.x1**2 + 2*m.x2 + 3)**2 <= 7) + visitor = self.get_visitor() + _, expr = visitor.walk_expression(m.c.body) + + # This is general nonlinear + opcode, data, parent = self._get_nl_expr_tree(visitor, expr) + + reverse_var_map = {grb_v: pyo_v for pyo_v, grb_v in visitor.var_map.items()} + pyo_expr = grb_nl_to_pyo_expr(opcode, data, parent, reverse_var_map) + + assertExpressionsEqual(self, pyo_expr, + SumExpression((3.0, ProductExpression((2.0, m.x2)), + m.x1**2))**2) + def test_write_nonquadratic_power_expression_var_const(self): m = self.get_model() m.c = Constraint(expr=m.x1**3 >= 3) From f57130af3c198c10dc2cc3e1d7771c4c71abe1ae Mon Sep 17 00:00:00 2001 From: Emma Johnson <12833636+emma58@users.noreply.github.com> Date: Thu, 9 Oct 2025 10:47:11 -0600 Subject: [PATCH 083/103] black --- .../tests/solvers/test_gurobi_minlp_walker.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp_walker.py b/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp_walker.py index afa2a492415..1bbdaffb683 100644 --- a/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp_walker.py +++ b/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp_walker.py @@ -299,7 +299,7 @@ def test_write_linear_power_expression_var_const(self): self.assertEqual(len(visitor.grb_model.getGenConstrs()), 0) # now try a linear expression - m.c2 = Constraint(expr=(m.x1 + 2*m.x2)**m.devious >= 5) + m.c2 = Constraint(expr=(m.x1 + 2 * m.x2) ** m.devious >= 5) _, lin_expr = visitor.walk_expression(m.c2.body) self.assertEqual(len(visitor.grb_model.getGenConstrs()), 0) x2 = visitor.var_map[m.x2] @@ -311,7 +311,7 @@ def test_write_linear_power_expression_var_const(self): self.assertEqual(lin_expr.getCoeff(1), 2.0) # now do a quadratic expression - m.c3 = Constraint(expr=(m.x1**2 + 5.4)**m.devious >= 8) + m.c3 = Constraint(expr=(m.x1**2 + 5.4) ** m.devious >= 8) _, quad_expr = visitor.walk_expression(m.c3.body) self.assertEqual(len(visitor.grb_model.getGenConstrs()), 0) self.assertEqual(quad_expr.size(), 1) @@ -342,7 +342,7 @@ def test_write_quadratic_power_expression_var_const(self): def test_write_quadratic_constant_pow_expression(self): m = self.get_model() - m.c = Constraint(expr=(m.x1**2 + 2*m.x2 + 3)**2 <= 7) + m.c = Constraint(expr=(m.x1**2 + 2 * m.x2 + 3) ** 2 <= 7) visitor = self.get_visitor() _, expr = visitor.walk_expression(m.c.body) @@ -352,9 +352,11 @@ def test_write_quadratic_constant_pow_expression(self): reverse_var_map = {grb_v: pyo_v for pyo_v, grb_v in visitor.var_map.items()} pyo_expr = grb_nl_to_pyo_expr(opcode, data, parent, reverse_var_map) - assertExpressionsEqual(self, pyo_expr, - SumExpression((3.0, ProductExpression((2.0, m.x2)), - m.x1**2))**2) + assertExpressionsEqual( + self, + pyo_expr, + SumExpression((3.0, ProductExpression((2.0, m.x2)), m.x1**2)) ** 2, + ) def test_write_nonquadratic_power_expression_var_const(self): m = self.get_model() From 9e43496268980b2f64aee1cfe99a97860467df92 Mon Sep 17 00:00:00 2001 From: Emma Johnson <12833636+emma58@users.noreply.github.com> Date: Thu, 9 Oct 2025 10:53:41 -0600 Subject: [PATCH 084/103] Adding test for absolute value of constant --- .../tests/solvers/test_gurobi_minlp_walker.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp_walker.py b/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp_walker.py index 1bbdaffb683..671787266d2 100644 --- a/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp_walker.py +++ b/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp_walker.py @@ -406,6 +406,21 @@ def test_write_power_expression_const_var(self): assertExpressionsEqual(self, pyo_expr, 2.0**m.x2) + def test_write_absolute_value_of_constant(self): + m = self.get_model() + m.tricky = Param(initialize=-3.4, mutable=True) + m.c = Constraint(expr=abs(m.tricky) + m.x1 <= 7) + visitor = self.get_visitor() + _, expr = visitor.walk_expression(m.c.body) + + x1 = visitor.var_map[m.x1] + + self.assertEqual(len(visitor.grb_model.getGenConstrs()), 0) + self.assertEqual(expr.size(), 1) + self.assertEqual(expr.getConstant(), 3.4) + self.assertIs(expr.getVar(0), x1) + self.assertEqual(expr.getCoeff(0), 1.0) + def test_write_absolute_value_of_var(self): # Gurobi doesn't support abs of expressions, so we have to do a factorable # programming thing... From 52b331b642d53dc9131a02b2f45eb9b962f3500b Mon Sep 17 00:00:00 2001 From: Emma Johnson <12833636+emma58@users.noreply.github.com> Date: Thu, 9 Oct 2025 15:27:08 -0600 Subject: [PATCH 085/103] Testing absolute value of constant --- .../contrib/solver/tests/solvers/test_gurobi_minlp_walker.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp_walker.py b/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp_walker.py index 671787266d2..5598dd0f18b 100644 --- a/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp_walker.py +++ b/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp_walker.py @@ -409,7 +409,8 @@ def test_write_power_expression_const_var(self): def test_write_absolute_value_of_constant(self): m = self.get_model() m.tricky = Param(initialize=-3.4, mutable=True) - m.c = Constraint(expr=abs(m.tricky) + m.x1 <= 7) + m.c = Constraint(expr=abs(m.tricky + m.x2) + m.x1 <= 7) + m.x2.fix(1) visitor = self.get_visitor() _, expr = visitor.walk_expression(m.c.body) @@ -417,7 +418,7 @@ def test_write_absolute_value_of_constant(self): self.assertEqual(len(visitor.grb_model.getGenConstrs()), 0) self.assertEqual(expr.size(), 1) - self.assertEqual(expr.getConstant(), 3.4) + self.assertEqual(expr.getConstant(), 2.4) self.assertIs(expr.getVar(0), x1) self.assertEqual(expr.getCoeff(0), 1.0) From 4b75ddb29068e84c5db7997c11a23ac8c7356dbf Mon Sep 17 00:00:00 2001 From: Emma Johnson <12833636+emma58@users.noreply.github.com> Date: Thu, 9 Oct 2025 15:50:39 -0600 Subject: [PATCH 086/103] Testing a couple error cases in the writer --- .../tests/solvers/test_gurobi_minlp_writer.py | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp_writer.py b/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp_writer.py index 770bbbccd52..500a16a4d3a 100644 --- a/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp_writer.py +++ b/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp_writer.py @@ -38,6 +38,7 @@ value, Var, ) +from pyomo.gdp import Disjunction from pyomo.opt import WriterFactory from pyomo.contrib.solver.solvers.gurobi_direct_minlp import ( GurobiDirectMINLP, @@ -531,3 +532,35 @@ def test_trivially_true_constraint(self): self.assertEqual(value(m.obj), 2) self.assertEqual(results.incumbent_objective, 2) self.assertEqual(results.objective_bound, 2) + + def test_multiple_objective_error(self): + m = make_model() + m.obj2 = Objective(expr=m.x1 + m.x2) + + with self.assertRaisesRegex( + ValueError, + "More than one active objective defined for input model 'unknown': " + "Cannot write to gurobipy" + ): + results = SolverFactory('gurobi_direct_minlp').solve(m) + + def test_unrecognized_component_error(self): + m = make_model() + m.disj = Disjunction(expr=[[m.x1 + m.x2 == 3], [m.x1 + m.x2 >= 7]]) + + with self.assertRaisesRegex( + ValueError, + r"The model \('unknown'\) contains the following active components " + r"that the Gurobi MINLP writer does not know how to process:" + + "\n\t" + + r":" + + "\n\t\t" + + "disj\n\t" + + r"\:" + + "\n\t\t" + + r"disj_disjuncts\[0\]" + + "\n\t\t" + + r"disj_disjuncts\[1\]" + ): + results = SolverFactory('gurobi_direct_minlp').solve(m) + From 6488881aa48ed7d4a7165ee85d7bf8ff63cce86d Mon Sep 17 00:00:00 2001 From: Emma Johnson <12833636+emma58@users.noreply.github.com> Date: Thu, 9 Oct 2025 15:51:08 -0600 Subject: [PATCH 087/103] black --- .../tests/solvers/test_gurobi_minlp_writer.py | 31 +++++++++---------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp_writer.py b/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp_writer.py index 500a16a4d3a..95162560509 100644 --- a/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp_writer.py +++ b/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp_writer.py @@ -538,9 +538,9 @@ def test_multiple_objective_error(self): m.obj2 = Objective(expr=m.x1 + m.x2) with self.assertRaisesRegex( - ValueError, - "More than one active objective defined for input model 'unknown': " - "Cannot write to gurobipy" + ValueError, + "More than one active objective defined for input model 'unknown': " + "Cannot write to gurobipy", ): results = SolverFactory('gurobi_direct_minlp').solve(m) @@ -549,18 +549,17 @@ def test_unrecognized_component_error(self): m.disj = Disjunction(expr=[[m.x1 + m.x2 == 3], [m.x1 + m.x2 >= 7]]) with self.assertRaisesRegex( - ValueError, - r"The model \('unknown'\) contains the following active components " - r"that the Gurobi MINLP writer does not know how to process:" + - "\n\t" + - r":" + - "\n\t\t" + - "disj\n\t" + - r"\:" + - "\n\t\t" + - r"disj_disjuncts\[0\]" + - "\n\t\t" + - r"disj_disjuncts\[1\]" + ValueError, + r"The model \('unknown'\) contains the following active components " + r"that the Gurobi MINLP writer does not know how to process:" + + "\n\t" + + r":" + + "\n\t\t" + + "disj\n\t" + + r"\:" + + "\n\t\t" + + r"disj_disjuncts\[0\]" + + "\n\t\t" + + r"disj_disjuncts\[1\]", ): results = SolverFactory('gurobi_direct_minlp').solve(m) - From 94db0a9bce1e8d2662bc672f5a992cb7a0441091 Mon Sep 17 00:00:00 2001 From: Emma Johnson <12833636+emma58@users.noreply.github.com> Date: Thu, 9 Oct 2025 16:00:41 -0600 Subject: [PATCH 088/103] Debugging Jenkins failure --- .../solver/tests/solvers/test_gurobi_minlp_writer.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp_writer.py b/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp_writer.py index 95162560509..7b23fb3ab1d 100644 --- a/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp_writer.py +++ b/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp_writer.py @@ -421,13 +421,17 @@ def test_solve_model(self): m.c = Constraint(expr=m.y == m.x**2) m.obj = Objective(expr=m.x + m.y, sense=maximize) - results = SolverFactory('gurobi_direct_minlp').solve(m) + results = SolverFactory('gurobi_direct_minlp').solve(m, tee=True) self.assertEqual(value(m.obj.expr), 2) self.assertEqual(value(m.x), 1) self.assertEqual(value(m.y), 1) + self.assertEqual( + results.termination_condition, + TerminationCondition.convergenceCriteriaSatisfied, + ) self.assertEqual(results.incumbent_objective, 2) self.assertEqual(results.objective_bound, 2) From 77ef498bf5d5e06a7fd91c52cea7e936f7471301 Mon Sep 17 00:00:00 2001 From: Emma Johnson <12833636+emma58@users.noreply.github.com> Date: Tue, 14 Oct 2025 13:30:26 -0600 Subject: [PATCH 089/103] Switching to categorize_valid_components, fixing one test that breaks --- .../solver/solvers/gurobi_direct_minlp.py | 82 +++++++++++-------- .../tests/solvers/test_gurobi_minlp_writer.py | 9 +- 2 files changed, 52 insertions(+), 39 deletions(-) diff --git a/pyomo/contrib/solver/solvers/gurobi_direct_minlp.py b/pyomo/contrib/solver/solvers/gurobi_direct_minlp.py index ff958ad844d..b76ac0087cf 100755 --- a/pyomo/contrib/solver/solvers/gurobi_direct_minlp.py +++ b/pyomo/contrib/solver/solvers/gurobi_direct_minlp.py @@ -21,8 +21,6 @@ from pyomo.common.numeric_types import native_complex_types from pyomo.common.timing import HierarchicalTimer -# ESJ TODO: Could we move this to util or somewhere less bizarre? -from pyomo.contrib.cp.repn.docplex_writer import collect_valid_components from pyomo.contrib.solver.common.factory import SolverFactory from pyomo.contrib.solver.common.solution_loader import SolutionLoaderBase from pyomo.contrib.solver.common.util import NoSolutionError @@ -72,6 +70,7 @@ from pyomo.repn.quadratic import QuadraticRepnVisitor from pyomo.repn.util import ( apply_node_operation, + categorize_valid_components, ExprType, ExitNodeDispatcher, BeforeChildDispatcher, @@ -483,14 +482,12 @@ def _create_gurobi_expression( def write(self, model, **options): config = options.pop('config', self.config)(options) - components, unknown = collect_valid_components( + components, unknown = categorize_valid_components( model, active=True, sort=SortComponents.deterministic, valid={ Block, - Objective, - Constraint, Expression, Var, BooleanVar, @@ -528,7 +525,16 @@ def write(self, model, **options): grb_model, symbolic_solver_labels=config.symbolic_solver_labels ) - active_objs = components[Objective] + active_objs = [] + if components[Objective]: + for block in components[Objective]: + for obj in block.component_data_objects( + Objective, + active=True, + descend_into=False, + sort=SortComponents.deterministic + ): + active_objs.append(obj) if len(active_objs) > 1: raise ValueError( "More than one active objective defined for " @@ -559,35 +565,41 @@ def write(self, model, **options): # write constraints pyo_cons = [] grb_cons = [] - for cons in components[Constraint]: - lb, body, ub = cons.to_bounded_expression(evaluate_bounds=True) - expr_type, expr, nonlinear, aux = self._create_gurobi_expression( - body, cons, 0, grb_model, quadratic_visitor, visitor - ) - if nonlinear: - grb_model.addConstr(aux == expr) - expr = aux - elif expr_type == _CONSTANT: - # cast everything to a float in case there are numpy - # types because you can't do addConstr(np.True_) - expr = float(expr) - if lb is not None: - lb = float(lb) - if ub is not None: - ub = float(ub) - if cons.equality: - grb_cons.append(grb_model.addConstr(expr == lb)) - pyo_cons.append(cons) - else: - # TODO: should be have special handling if expr is a - # GRB.LinExpr so that we can use the ranged linear - # constraint syntax (expr == [lb, ub])? - if lb is not None: - grb_cons.append(grb_model.addConstr(expr >= lb)) - pyo_cons.append(cons) - if ub is not None: - grb_cons.append(grb_model.addConstr(expr <= ub)) - pyo_cons.append(cons) + + if components[Constraint]: + for block in components[Constraint]: + for cons in block.component_data_objects( + Constraint, active=True, + descend_into=False, + sort=SortComponents.deterministic): + lb, body, ub = cons.to_bounded_expression(evaluate_bounds=True) + expr_type, expr, nonlinear, aux = self._create_gurobi_expression( + body, cons, 0, grb_model, quadratic_visitor, visitor + ) + if nonlinear: + grb_model.addConstr(aux == expr) + expr = aux + elif expr_type == _CONSTANT: + # cast everything to a float in case there are numpy + # types because you can't do addConstr(np.True_) + expr = float(expr) + if lb is not None: + lb = float(lb) + if ub is not None: + ub = float(ub) + if cons.equality: + grb_cons.append(grb_model.addConstr(expr == lb)) + pyo_cons.append(cons) + else: + # TODO: should be have special handling if expr is a + # GRB.LinExpr so that we can use the ranged linear + # constraint syntax (expr == [lb, ub])? + if lb is not None: + grb_cons.append(grb_model.addConstr(expr >= lb)) + pyo_cons.append(cons) + if ub is not None: + grb_cons.append(grb_model.addConstr(expr <= ub)) + pyo_cons.append(cons) grb_model.update() return grb_model, visitor.var_map, pyo_obj, grb_cons, pyo_cons diff --git a/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp_writer.py b/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp_writer.py index 7b23fb3ab1d..40821ac31bb 100644 --- a/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp_writer.py +++ b/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp_writer.py @@ -557,13 +557,14 @@ def test_unrecognized_component_error(self): r"The model \('unknown'\) contains the following active components " r"that the Gurobi MINLP writer does not know how to process:" + "\n\t" - + r":" - + "\n\t\t" - + "disj\n\t" + r"\:" + "\n\t\t" + r"disj_disjuncts\[0\]" + "\n\t\t" - + r"disj_disjuncts\[1\]", + + r"disj_disjuncts\[1\]" + + "\n\t" + + r":" + + "\n\t\t" + + "disj" ): results = SolverFactory('gurobi_direct_minlp').solve(m) From adc46b7b5c48d070787e87a905082fad57a26d19 Mon Sep 17 00:00:00 2001 From: Emma Johnson <12833636+emma58@users.noreply.github.com> Date: Tue, 14 Oct 2025 13:31:26 -0600 Subject: [PATCH 090/103] black --- .../solver/solvers/gurobi_direct_minlp.py | 16 +++++++++------- .../tests/solvers/test_gurobi_minlp_writer.py | 2 +- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/pyomo/contrib/solver/solvers/gurobi_direct_minlp.py b/pyomo/contrib/solver/solvers/gurobi_direct_minlp.py index b76ac0087cf..f5ff2accfc9 100755 --- a/pyomo/contrib/solver/solvers/gurobi_direct_minlp.py +++ b/pyomo/contrib/solver/solvers/gurobi_direct_minlp.py @@ -529,10 +529,10 @@ def write(self, model, **options): if components[Objective]: for block in components[Objective]: for obj in block.component_data_objects( - Objective, - active=True, - descend_into=False, - sort=SortComponents.deterministic + Objective, + active=True, + descend_into=False, + sort=SortComponents.deterministic, ): active_objs.append(obj) if len(active_objs) > 1: @@ -569,9 +569,11 @@ def write(self, model, **options): if components[Constraint]: for block in components[Constraint]: for cons in block.component_data_objects( - Constraint, active=True, - descend_into=False, - sort=SortComponents.deterministic): + Constraint, + active=True, + descend_into=False, + sort=SortComponents.deterministic, + ): lb, body, ub = cons.to_bounded_expression(evaluate_bounds=True) expr_type, expr, nonlinear, aux = self._create_gurobi_expression( body, cons, 0, grb_model, quadratic_visitor, visitor diff --git a/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp_writer.py b/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp_writer.py index 40821ac31bb..b171dff8260 100644 --- a/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp_writer.py +++ b/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp_writer.py @@ -565,6 +565,6 @@ def test_unrecognized_component_error(self): + "\n\t" + r":" + "\n\t\t" - + "disj" + + "disj", ): results = SolverFactory('gurobi_direct_minlp').solve(m) From 984c77c21a75cdf2fe75461aae5dd371394368d6 Mon Sep 17 00:00:00 2001 From: Emma Johnson <12833636+emma58@users.noreply.github.com> Date: Wed, 15 Oct 2025 09:42:50 -0600 Subject: [PATCH 091/103] Adding Gurobi direct MINLP to the load method in plugins --- pyomo/contrib/solver/plugins.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/pyomo/contrib/solver/plugins.py b/pyomo/contrib/solver/plugins.py index 19fc9b2b2a1..6b4f3146f68 100644 --- a/pyomo/contrib/solver/plugins.py +++ b/pyomo/contrib/solver/plugins.py @@ -14,6 +14,7 @@ from .solvers.ipopt import Ipopt, LegacyIpoptSolver from .solvers.gurobi_persistent import GurobiPersistent from .solvers.gurobi_direct import GurobiDirect +from .solvers.gurobi_direct_minlp import GurobiDirectMINLP from .solvers.highs import Highs @@ -31,6 +32,11 @@ def load(): legacy_name='gurobi_direct_v2', doc='Direct (scipy-based) interface to Gurobi', )(GurobiDirect) + SolverFactory.register( + name='gurobi_direct_minlp', + legacy_name='gurobi_direct_minlp', + doc='Direct interface to Gurobi accomodating general MINLP', + )(GurobiDirectMINLP) SolverFactory.register( name='highs', legacy_name='highs', doc='Persistent interface to HiGHS' )(Highs) From 31c952339ad223368649126de4b25b1f571c1e8d Mon Sep 17 00:00:00 2001 From: Emma Johnson <12833636+emma58@users.noreply.github.com> Date: Wed, 15 Oct 2025 16:48:30 -0600 Subject: [PATCH 092/103] Centralizing check_constant implementation in repn.util --- .../solver/solvers/gurobi_direct_minlp.py | 40 +----------- pyomo/repn/ampl.py | 56 +++-------------- pyomo/repn/linear.py | 63 ++++--------------- pyomo/repn/parameterized.py | 3 +- pyomo/repn/util.py | 52 ++++++++++++++- 5 files changed, 72 insertions(+), 142 deletions(-) diff --git a/pyomo/contrib/solver/solvers/gurobi_direct_minlp.py b/pyomo/contrib/solver/solvers/gurobi_direct_minlp.py index f5ff2accfc9..468dfe1c71c 100755 --- a/pyomo/contrib/solver/solvers/gurobi_direct_minlp.py +++ b/pyomo/contrib/solver/solvers/gurobi_direct_minlp.py @@ -76,9 +76,9 @@ BeforeChildDispatcher, complex_number_error, initialize_exit_node_dispatcher, - InvalidNumber, nan, OrderedVarRecorder, + check_constant ) import sys @@ -178,9 +178,7 @@ class GurobiMINLPBeforeChildDispatcher(BeforeChildDispatcher): def _before_var(visitor, child): if child not in visitor.var_map: if child.fixed: - # ESJ TODO: I want the linear walker implementation of - # check_constant... Could it be in the base class or something? - return False, (_CONSTANT, visitor.check_constant(child.value, child)) + return False, (_CONSTANT, check_constant(child.value, child, visitor)) grb_var = _create_grb_var( visitor, child, @@ -413,40 +411,6 @@ def exitNode(self, node, data): def finalizeResult(self, result): return result - # ESJ TODO: THIS IS COPIED FROM THE LINEAR WALKER--CAN WE PUT IT IN UTIL OR - # SOMETHING? - def check_constant(self, ans, obj): - if ans.__class__ not in EXPR.native_numeric_types: - # None can be returned from uninitialized Var/Param objects - if ans is None: - return InvalidNumber( - None, f"'{obj}' evaluated to a nonnumeric value '{ans}'" - ) - if ans.__class__ is InvalidNumber: - return ans - elif ans.__class__ in native_complex_types: - return complex_number_error(ans, self, obj) - else: - # It is possible to get other non-numeric types. Most - # common are bool and 1-element numpy.array(). We will - # attempt to convert the value to a float before - # proceeding. - # - # TODO: we should check bool and warn/error (while bool is - # convertible to float in Python, they have very - # different semantic meanings in Pyomo). - try: - ans = float(ans) - except: - return InvalidNumber( - ans, f"'{obj}' evaluated to a nonnumeric value '{ans}'" - ) - if ans != ans: - return InvalidNumber( - nan, f"'{obj}' evaluated to a nonnumeric value '{ans}'" - ) - return ans - @WriterFactory.register( 'gurobi_minlp', diff --git a/pyomo/repn/ampl.py b/pyomo/repn/ampl.py index 84c0a7e6601..387c72228cb 100644 --- a/pyomo/repn/ampl.py +++ b/pyomo/repn/ampl.py @@ -48,11 +48,11 @@ BeforeChildDispatcher, ExitNodeDispatcher, ExprType, - InvalidNumber, apply_node_operation, complex_number_error, nan, sum_like_expression_types, + check_constant, ) @@ -970,7 +970,7 @@ def _before_monomial(visitor, child): arg1, arg2 = child._args_ if arg1.__class__ not in native_types: try: - arg1 = visitor.check_constant(visitor.evaluate(arg1), arg1) + arg1 = check_constant(visitor.evaluate(arg1), arg1, visitor) except (ValueError, ArithmeticError): return True, None @@ -1013,14 +1013,14 @@ def _before_linear(visitor, child): arg1, arg2 = arg._args_ if arg1.__class__ not in native_types: try: - arg1 = visitor.check_constant(visitor.evaluate(arg1), arg1) + arg1 = check_constant(visitor.evaluate(arg1), arg1, visitor) except (ValueError, ArithmeticError): return True, None # Trap multiplication by 0 and nan. if not arg1: if arg2.fixed: - arg2 = visitor.check_constant(arg2.value, arg2) + arg2 = check_constant(arg2.value, arg2, visitor) if arg2 != arg2: deprecation_warning( f"Encountered {arg1}*{_inv2str(arg2)} in expression " @@ -1062,7 +1062,7 @@ def _before_linear(visitor, child): linear[_id] = 1 else: try: - const += visitor.check_constant(visitor.evaluate(arg), arg) + const += check_constant(visitor.evaluate(arg), arg, visitor) except (ValueError, ArithmeticError): return True, None @@ -1120,50 +1120,8 @@ def __init__( self.Result = AMPLRepn self.template = self.Result.template - def check_constant(self, ans, obj): - if ans.__class__ not in native_numeric_types: - # None can be returned from uninitialized Var/Param objects - if ans is None: - return InvalidNumber( - None, f"'{obj}' evaluated to a nonnumeric value '{ans}'" - ) - if ans.__class__ is InvalidNumber: - return ans - elif ans.__class__ in native_complex_types: - return complex_number_error(ans, self, obj) - else: - # It is possible to get other non-numeric types. Most - # common are bool and 1-element numpy.array(). We will - # attempt to convert the value to a float before - # proceeding. - # - # Note that as of NumPy 1.25, blindly casting a - # 1-element ndarray to a float will generate a - # deprecation warning. We will explicitly test for - # that, but want to do the test without triggering the - # numpy import - for cls in ans.__class__.__mro__: - if cls.__name__ == 'ndarray' and cls.__module__ == 'numpy': - if len(ans) == 1: - ans = ans[0] - break - # TODO: we should check bool and warn/error (while bool is - # convertible to float in Python, they have very - # different semantic meanings in Pyomo). - try: - ans = float(ans) - except: - return InvalidNumber( - ans, f"'{obj}' evaluated to a nonnumeric value '{ans}'" - ) - if ans != ans: - return InvalidNumber( - nan, f"'{obj}' evaluated to a nonnumeric value '{ans}'" - ) - return ans - def cache_fixed_var(self, _id, child): - val = self.check_constant(child.value, child) + val = check_constant(child.value, child, self) lb, ub = child.bounds if (lb is not None and lb - val > TOL) or (ub is not None and ub - val < -TOL): raise InfeasibleConstraintException( @@ -1171,7 +1129,7 @@ def cache_fixed_var(self, _id, child): f"variable '{child.name}' (fixed value " f"{val} outside bounds [{lb}, {ub}])." ) - self.fixed_vars[_id] = self.check_constant(child.value, child) + self.fixed_vars[_id] = check_constant(child.value, child, self) def node_result_to_amplrepn(self, data): if data[0] is _GENERAL: diff --git a/pyomo/repn/linear.py b/pyomo/repn/linear.py index 60d73f0b866..3ec6d57ed69 100644 --- a/pyomo/repn/linear.py +++ b/pyomo/repn/linear.py @@ -56,6 +56,7 @@ nan, sum_like_expression_types, val2str, + check_constant ) _CONSTANT = ExprType.CONSTANT @@ -660,7 +661,7 @@ def _before_var(visitor, child): _id = id(child) if _id not in visitor.var_map: if child.fixed: - return False, (_CONSTANT, visitor.check_constant(child.value, child)) + return False, (_CONSTANT, check_constant(child.value, child, visitor)) visitor.var_recorder.add(child) ans = visitor.Result() ans.linear[_id] = 1 @@ -675,7 +676,7 @@ def _before_monomial(visitor, child): arg1, arg2 = child._args_ if arg1.__class__ not in native_types: try: - arg1 = visitor.check_constant(visitor.evaluate(arg1), arg1) + arg1 = check_constant(visitor.evaluate(arg1), arg1, visitor) except (ValueError, ArithmeticError): return True, None @@ -688,7 +689,7 @@ def _before_monomial(visitor, child): if arg2.fixed: return False, ( _CONSTANT, - arg1 * visitor.check_constant(arg2.value, arg2), + arg1 * check_constant(arg2.value, arg2, visitor), ) visitor.var_recorder.add(arg2) @@ -696,7 +697,7 @@ def _before_monomial(visitor, child): # to a numeric value at the beginning of this method. if not arg1: if arg2.fixed: - arg2 = visitor.check_constant(arg2.value, arg2) + arg2 = check_constant(arg2.value, arg2, visitor) if arg2.__class__ is InvalidNumber: deprecation_warning( f"Encountered {arg1}*{val2str(arg2)} in expression " @@ -722,7 +723,7 @@ def _before_linear(visitor, child): arg1, arg2 = arg._args_ if arg1.__class__ not in native_types: try: - arg1 = visitor.check_constant(visitor.evaluate(arg1), arg1) + arg1 = check_constant(visitor.evaluate(arg1), arg1, visitor) except (ValueError, ArithmeticError): return True, None @@ -731,7 +732,7 @@ def _before_linear(visitor, child): # method. if not arg1: if arg2.fixed: - arg2 = visitor.check_constant(arg2.value, arg2) + arg2 = check_constant(arg2.value, arg2, visitor) if arg2.__class__ is InvalidNumber: deprecation_warning( f"Encountered {arg1}*{val2str(arg2)} in expression " @@ -745,7 +746,7 @@ def _before_linear(visitor, child): _id = id(arg2) if _id not in var_map: if arg2.fixed: - const += arg1 * visitor.check_constant(arg2.value, arg2) + const += arg1 * check_constant(arg2.value, arg2, visitor) continue visitor.var_recorder.add(arg2) linear[_id] = arg1 @@ -759,7 +760,7 @@ def _before_linear(visitor, child): _id = id(arg) if _id not in var_map: if arg.fixed: - const += visitor.check_constant(arg.value, arg) + const += check_constant(arg.value, arg, visitor) continue visitor.var_recorder.add(arg) linear[_id] = 1 @@ -769,7 +770,7 @@ def _before_linear(visitor, child): linear[_id] = 1 else: try: - const += visitor.check_constant(visitor.evaluate(arg), arg) + const += check_constant(visitor.evaluate(arg), arg, visitor) except (ValueError, ArithmeticError): return True, None if linear: @@ -795,7 +796,7 @@ def _before_external(visitor, child): ans = visitor.Result() if all(is_fixed(arg) for arg in child.args): try: - ans.constant = visitor.check_constant(visitor.evaluate(child), child) + ans.constant = check_constant(visitor.evaluate(child), child, visitor) return False, (_CONSTANT, ans) except: pass @@ -843,48 +844,6 @@ def __init__( self._eval_expr_visitor = _EvaluationVisitor(True) self.evaluate = self._eval_expr_visitor.dfs_postorder_stack - def check_constant(self, ans, obj): - if ans.__class__ not in native_numeric_types: - # None can be returned from uninitialized Var/Param objects - if ans is None: - return InvalidNumber( - None, f"'{obj}' evaluated to a nonnumeric value '{ans}'" - ) - if ans.__class__ is InvalidNumber: - return ans - elif ans.__class__ in native_complex_types: - return complex_number_error(ans, self, obj) - else: - # It is possible to get other non-numeric types. Most - # common are bool and 1-element numpy.array(). We will - # attempt to convert the value to a float before - # proceeding. - # - # Note that as of NumPy 1.25, blindly casting a - # 1-element ndarray to a float will generate a - # deprecation warning. We will explicitly test for - # that, but want to do the test without triggering the - # numpy import - for cls in ans.__class__.__mro__: - if cls.__name__ == 'ndarray' and cls.__module__ == 'numpy': - if len(ans) == 1: - ans = ans[0] - break - # TODO: we should check bool and warn/error (while bool is - # convertible to float in Python, they have very - # different semantic meanings in Pyomo). - try: - ans = float(ans) - except: - return InvalidNumber( - ans, f"'{obj}' evaluated to a nonnumeric value '{ans}'" - ) - if ans != ans: - return InvalidNumber( - nan, f"'{obj}' evaluated to a nonnumeric value '{ans}'" - ) - return ans - def initializeWalker(self, expr): walk, result = self.beforeChild(None, expr, 0) if not walk: diff --git a/pyomo/repn/parameterized.py b/pyomo/repn/parameterized.py index d3ece90ca8a..9d74f570d54 100644 --- a/pyomo/repn/parameterized.py +++ b/pyomo/repn/parameterized.py @@ -23,6 +23,7 @@ ExitNodeDispatcher, ExprType, initialize_exit_node_dispatcher, + check_constant ) import pyomo.repn.linear as linear import pyomo.repn.quadratic as quadratic @@ -88,7 +89,7 @@ def _before_var(visitor, child): # We aren't treating this Var as a Var for the purposes of this walker return False, (_FIXED, child) if child.fixed: - return False, (_CONSTANT, visitor.check_constant(child.value, child)) + return False, (_CONSTANT, check_constant(child.value, child, visitor)) # This is a normal situation visitor.var_recorder.add(child) ans = visitor.Result() diff --git a/pyomo/repn/util.py b/pyomo/repn/util.py index a473cbc2a54..75e57308644 100644 --- a/pyomo/repn/util.py +++ b/pyomo/repn/util.py @@ -414,14 +414,14 @@ def _before_npv(visitor, child): try: return False, ( _CONSTANT, - visitor.check_constant(visitor.evaluate(child), child), + check_constant(visitor.evaluate(child), child, visitor), ) except (ValueError, ArithmeticError): return True, None @staticmethod def _before_param(visitor, child): - return False, (_CONSTANT, visitor.check_constant(child.value, child)) + return False, (_CONSTANT, check_constant(child.value, child, visitor)) @staticmethod def _before_index_template(visitor, child): @@ -965,3 +965,51 @@ def ftoa(val, parenthesize_negative_values=False): return '(' + a[:i] + ')' else: return a[:i] + + +def check_constant(ans, obj, visitor): + # [ESJ 10/15/25]: The only reason this takes the visitor as an + # argument is to pass it to the complex_number_error function, and + # the only reason it needs it is to get the name of the class. But + # it is public, so I'm not changing anything about that at the + # moment. + if ans.__class__ not in native_numeric_types: + # None can be returned from uninitialized Var/Param objects + if ans is None: + return InvalidNumber( + None, f"'{obj}' evaluated to a nonnumeric value '{ans}'" + ) + if ans.__class__ is InvalidNumber: + return ans + elif ans.__class__ in native_complex_types: + return complex_number_error(ans, visitor, obj) + else: + # It is possible to get other non-numeric types. Most + # common are bool and 1-element numpy.array(). We will + # attempt to convert the value to a float before + # proceeding. + # + # Note that as of NumPy 1.25, blindly casting a + # 1-element ndarray to a float will generate a + # deprecation warning. We will explicitly test for + # that, but want to do the test without triggering the + # numpy import + for cls in ans.__class__.__mro__: + if cls.__name__ == 'ndarray' and cls.__module__ == 'numpy': + if len(ans) == 1: + ans = ans[0] + break + # TODO: we should check bool and warn/error (while bool is + # convertible to float in Python, they have very + # different semantic meanings in Pyomo). + try: + ans = float(ans) + except: + return InvalidNumber( + ans, f"'{obj}' evaluated to a nonnumeric value '{ans}'" + ) + if ans != ans: + return InvalidNumber( + nan, f"'{obj}' evaluated to a nonnumeric value '{ans}'" + ) + return ans From 98b3e76116a23efe466d6c65c9f03d6dfb066dfe Mon Sep 17 00:00:00 2001 From: Emma Johnson <12833636+emma58@users.noreply.github.com> Date: Wed, 15 Oct 2025 16:49:21 -0600 Subject: [PATCH 093/103] black --- pyomo/contrib/solver/solvers/gurobi_direct_minlp.py | 2 +- pyomo/repn/linear.py | 2 +- pyomo/repn/parameterized.py | 2 +- pyomo/repn/util.py | 4 +--- 4 files changed, 4 insertions(+), 6 deletions(-) diff --git a/pyomo/contrib/solver/solvers/gurobi_direct_minlp.py b/pyomo/contrib/solver/solvers/gurobi_direct_minlp.py index 468dfe1c71c..6bbc6bd3071 100755 --- a/pyomo/contrib/solver/solvers/gurobi_direct_minlp.py +++ b/pyomo/contrib/solver/solvers/gurobi_direct_minlp.py @@ -78,7 +78,7 @@ initialize_exit_node_dispatcher, nan, OrderedVarRecorder, - check_constant + check_constant, ) import sys diff --git a/pyomo/repn/linear.py b/pyomo/repn/linear.py index 3ec6d57ed69..5a46a9bfb8a 100644 --- a/pyomo/repn/linear.py +++ b/pyomo/repn/linear.py @@ -56,7 +56,7 @@ nan, sum_like_expression_types, val2str, - check_constant + check_constant, ) _CONSTANT = ExprType.CONSTANT diff --git a/pyomo/repn/parameterized.py b/pyomo/repn/parameterized.py index 9d74f570d54..c1d8ab7170c 100644 --- a/pyomo/repn/parameterized.py +++ b/pyomo/repn/parameterized.py @@ -23,7 +23,7 @@ ExitNodeDispatcher, ExprType, initialize_exit_node_dispatcher, - check_constant + check_constant, ) import pyomo.repn.linear as linear import pyomo.repn.quadratic as quadratic diff --git a/pyomo/repn/util.py b/pyomo/repn/util.py index 75e57308644..ff9439e581b 100644 --- a/pyomo/repn/util.py +++ b/pyomo/repn/util.py @@ -1009,7 +1009,5 @@ def check_constant(ans, obj, visitor): ans, f"'{obj}' evaluated to a nonnumeric value '{ans}'" ) if ans != ans: - return InvalidNumber( - nan, f"'{obj}' evaluated to a nonnumeric value '{ans}'" - ) + return InvalidNumber(nan, f"'{obj}' evaluated to a nonnumeric value '{ans}'") return ans From a5dd19e3273f597d806fa2fcefa9ae995ba63fcf Mon Sep 17 00:00:00 2001 From: Emma Johnson <12833636+emma58@users.noreply.github.com> Date: Wed, 15 Oct 2025 16:53:08 -0600 Subject: [PATCH 094/103] Emma learns to spell accommodating --- pyomo/contrib/solver/plugins.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyomo/contrib/solver/plugins.py b/pyomo/contrib/solver/plugins.py index 6b4f3146f68..1f3479544ca 100644 --- a/pyomo/contrib/solver/plugins.py +++ b/pyomo/contrib/solver/plugins.py @@ -35,7 +35,7 @@ def load(): SolverFactory.register( name='gurobi_direct_minlp', legacy_name='gurobi_direct_minlp', - doc='Direct interface to Gurobi accomodating general MINLP', + doc='Direct interface to Gurobi accommodating general MINLP', )(GurobiDirectMINLP) SolverFactory.register( name='highs', legacy_name='highs', doc='Persistent interface to HiGHS' From 66b76dd9453a1b5772edd3e150fd7e3975124782 Mon Sep 17 00:00:00 2001 From: Emma Johnson <12833636+emma58@users.noreply.github.com> Date: Fri, 17 Oct 2025 10:09:37 -0600 Subject: [PATCH 095/103] Not copying result in apply_operation for sum --- pyomo/core/expr/numeric_expr.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pyomo/core/expr/numeric_expr.py b/pyomo/core/expr/numeric_expr.py index 43adf90ce43..55c08e8e781 100644 --- a/pyomo/core/expr/numeric_expr.py +++ b/pyomo/core/expr/numeric_expr.py @@ -1208,7 +1208,8 @@ def _trunc_extend(self, other): def _apply_operation(self, result): # Avoid 0 being added to summations by specifying the start if result: - return sum(result[1:], start=result[0]) + _iter = iter(result) + return sum(_iter, start=next(_iter)) else: return 0 From a789a1898e9a9610add01db076d5387dab1df686 Mon Sep 17 00:00:00 2001 From: Emma Johnson <12833636+emma58@users.noreply.github.com> Date: Fri, 17 Oct 2025 10:41:28 -0600 Subject: [PATCH 096/103] Accommodating general variable domains --- .../solver/solvers/gurobi_direct_minlp.py | 34 ++++++++----------- 1 file changed, 15 insertions(+), 19 deletions(-) diff --git a/pyomo/contrib/solver/solvers/gurobi_direct_minlp.py b/pyomo/contrib/solver/solvers/gurobi_direct_minlp.py index 6bbc6bd3071..f2d83b37cd0 100755 --- a/pyomo/contrib/solver/solvers/gurobi_direct_minlp.py +++ b/pyomo/contrib/solver/solvers/gurobi_direct_minlp.py @@ -97,7 +97,6 @@ _VARIABLE = ExprType.VARIABLE _function_map = {} -_domain_map = ComponentMap() gurobipy, gurobipy_available = attempt_import('gurobipy', minimum_version='12.0.0') if gurobipy_available: @@ -128,18 +127,6 @@ } ) - _domain_map.update( - ( - (Binary, (GRB.BINARY, -float('inf'), float('inf'))), - (Integers, (GRB.INTEGER, -float('inf'), float('inf'))), - (NonNegativeIntegers, (GRB.INTEGER, 0, float('inf'))), - (NonPositiveIntegers, (GRB.INTEGER, -float('inf'), 0)), - (NonNegativeReals, (GRB.CONTINUOUS, 0, float('inf'))), - (NonPositiveReals, (GRB.CONTINUOUS, -float('inf'), 0)), - (Reals, (GRB.CONTINUOUS, -float('inf'), float('inf'))), - ) - ) - """ In Gurobi 12: @@ -162,14 +149,23 @@ def _create_grb_var(visitor, pyomo_var, name=""): pyo_domain = pyomo_var.domain - if pyo_domain in _domain_map: - domain, domain_lb, domain_ub = _domain_map[pyo_domain] - else: + domain_lb, domain_ub, domain = pyo_domain.get_interval() + if domain not in (0, 1): raise ValueError( - "Unsupported domain for Var '%s': %s" % (pyomo_var.name, pyo_domain) + "Unsupported domain for Var '%s': %s" % (pyomo_var.name, pyomo_var.domain) ) - lb = max(domain_lb, pyomo_var.lb) if pyomo_var.lb is not None else domain_lb - ub = min(domain_ub, pyomo_var.ub) if pyomo_var.ub is not None else domain_ub + domain = GRB.INTEGER if domain else GRB.CONTINUOUS + # We set binaries to be binary right now because we don't know if Gurbi cares + if pyo_domain is Binary: + domain = GRB.BINARY + + # returns tigter of bounds from domain and bounds set on variable + lb, ub = pyomo_var.bounds + if lb is None: + lb = -float("inf") + if ub is None: + ub = float("inf") + return visitor.grb_model.addVar(lb=lb, ub=ub, vtype=domain, name=name) From 7ccb122f19523cc2e0883ca97ae7b9e506ad1071 Mon Sep 17 00:00:00 2001 From: Emma Johnson <12833636+emma58@users.noreply.github.com> Date: Fri, 17 Oct 2025 10:44:10 -0600 Subject: [PATCH 097/103] NFC: comment correction --- pyomo/contrib/solver/solvers/gurobi_direct_minlp.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyomo/contrib/solver/solvers/gurobi_direct_minlp.py b/pyomo/contrib/solver/solvers/gurobi_direct_minlp.py index f2d83b37cd0..17dab4ae59a 100755 --- a/pyomo/contrib/solver/solvers/gurobi_direct_minlp.py +++ b/pyomo/contrib/solver/solvers/gurobi_direct_minlp.py @@ -233,8 +233,8 @@ def _handle_node_with_eval_expr_visitor_quadratic(visitor, node, *data): def _handle_node_with_eval_expr_visitor_nonlinear(visitor, node, *data): - # ESJ: _apply_operation for DivisionExpression expects that result is indexed, so - # I'm making it a tuple rather than a map. + # ESJ: _apply_operation for DivisionExpression expects that result + # supports __getitem__, so I'm expanding the map to a tuple. return ( _GENERAL, visitor._eval_expr_visitor.visit(node, tuple(map(itemgetter(1), data))), From 917026b8bcd4b239ff73fdb6ffacd39b0802178e Mon Sep 17 00:00:00 2001 From: Emma Johnson <12833636+emma58@users.noreply.github.com> Date: Fri, 17 Oct 2025 10:51:08 -0600 Subject: [PATCH 098/103] NFC: comment typo --- pyomo/contrib/solver/solvers/gurobi_direct_minlp.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyomo/contrib/solver/solvers/gurobi_direct_minlp.py b/pyomo/contrib/solver/solvers/gurobi_direct_minlp.py index 17dab4ae59a..e33bb922162 100755 --- a/pyomo/contrib/solver/solvers/gurobi_direct_minlp.py +++ b/pyomo/contrib/solver/solvers/gurobi_direct_minlp.py @@ -553,7 +553,7 @@ def write(self, model, **options): grb_cons.append(grb_model.addConstr(expr == lb)) pyo_cons.append(cons) else: - # TODO: should be have special handling if expr is a + # TODO: should we have special handling if expr is a # GRB.LinExpr so that we can use the ranged linear # constraint syntax (expr == [lb, ub])? if lb is not None: From bf3401c95db13ee0f2d1a94c87197e810f31623f Mon Sep 17 00:00:00 2001 From: John Siirola Date: Fri, 17 Oct 2025 11:27:52 -0600 Subject: [PATCH 099/103] NFC: apply black --- pyomo/contrib/solver/solvers/gurobi_direct_minlp.py | 2 +- pyomo/contrib/solver/tests/solvers/test_solvers.py | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/pyomo/contrib/solver/solvers/gurobi_direct_minlp.py b/pyomo/contrib/solver/solvers/gurobi_direct_minlp.py index e33bb922162..187ec9255ea 100755 --- a/pyomo/contrib/solver/solvers/gurobi_direct_minlp.py +++ b/pyomo/contrib/solver/solvers/gurobi_direct_minlp.py @@ -165,7 +165,7 @@ def _create_grb_var(visitor, pyomo_var, name=""): lb = -float("inf") if ub is None: ub = float("inf") - + return visitor.grb_model.addVar(lb=lb, ub=ub, vtype=domain, name=name) diff --git a/pyomo/contrib/solver/tests/solvers/test_solvers.py b/pyomo/contrib/solver/tests/solvers/test_solvers.py index 8b8814b31f4..0b017108f85 100644 --- a/pyomo/contrib/solver/tests/solvers/test_solvers.py +++ b/pyomo/contrib/solver/tests/solvers/test_solvers.py @@ -64,7 +64,11 @@ ('highs', Highs), ('knitro_direct', KnitroDirectSolver), ] -nlp_solvers = [('gurobi_direct_minlp', GurobiDirectMINLP), ('ipopt', Ipopt), ('knitro_direct', KnitroDirectSolver)] +nlp_solvers = [ + ('gurobi_direct_minlp', GurobiDirectMINLP), + ('ipopt', Ipopt), + ('knitro_direct', KnitroDirectSolver), +] qcp_solvers = [ ('gurobi_persistent', GurobiPersistent), ('gurobi_direct_minlp', GurobiDirectMINLP), From 2a0c67e1d2eb9f29dd122b25063d198e27c6f8c4 Mon Sep 17 00:00:00 2001 From: Emma Johnson <12833636+emma58@users.noreply.github.com> Date: Fri, 17 Oct 2025 11:35:56 -0600 Subject: [PATCH 100/103] Sorting unrecognized components --- pyomo/repn/util.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyomo/repn/util.py b/pyomo/repn/util.py index ff9439e581b..eefda267c60 100644 --- a/pyomo/repn/util.py +++ b/pyomo/repn/util.py @@ -673,7 +673,7 @@ def categorize_valid_components( ctype=ctype, active=active, descend_into=False, - sort=SortComponents.unsorted, + sort=SortComponents.deterministic, ) ) return component_map, {k: v for k, v in unrecognized.items() if v} From a9d049ba44963f3b47123405e5cecddc24b68eb5 Mon Sep 17 00:00:00 2001 From: John Siirola Date: Fri, 17 Oct 2025 12:32:51 -0600 Subject: [PATCH 101/103] Ensure the unknown component exception is deterministic --- pyomo/contrib/solver/solvers/gurobi_direct_minlp.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/pyomo/contrib/solver/solvers/gurobi_direct_minlp.py b/pyomo/contrib/solver/solvers/gurobi_direct_minlp.py index 187ec9255ea..f038ec3ab2e 100755 --- a/pyomo/contrib/solver/solvers/gurobi_direct_minlp.py +++ b/pyomo/contrib/solver/solvers/gurobi_direct_minlp.py @@ -468,8 +468,11 @@ def write(self, model, **options): % ( model.name, "\n\t".join( - "%s:\n\t\t%s" % (k, "\n\t\t".join(map(attrgetter('name'), v))) - for k, v in unknown.items() + sorted( + "%s:\n\t\t%s" + % (k, "\n\t\t".join(sorted(map(attrgetter('name'), v)))) + for k, v in unknown.items() + ) ), ) ) From dfa97dd116abdb875d453cbf3e7274bea6c3bfcf Mon Sep 17 00:00:00 2001 From: John Siirola Date: Fri, 17 Oct 2025 12:33:12 -0600 Subject: [PATCH 102/103] Prevent gurobipy from being imported with environ --- .../solver/solvers/gurobi_direct_minlp.py | 29 ++++++++++++------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/pyomo/contrib/solver/solvers/gurobi_direct_minlp.py b/pyomo/contrib/solver/solvers/gurobi_direct_minlp.py index f038ec3ab2e..7a83a54965c 100755 --- a/pyomo/contrib/solver/solvers/gurobi_direct_minlp.py +++ b/pyomo/contrib/solver/solvers/gurobi_direct_minlp.py @@ -98,19 +98,19 @@ _function_map = {} -gurobipy, gurobipy_available = attempt_import('gurobipy', minimum_version='12.0.0') -if gurobipy_available: - from gurobipy import GRB, nlfunc +def _finalize_gurobipy(gurobipy, available): + if not available: + return _function_map.update( { - 'exp': (_GENERAL, nlfunc.exp), - 'log': (_GENERAL, nlfunc.log), - 'log10': (_GENERAL, nlfunc.log10), - 'sin': (_GENERAL, nlfunc.sin), - 'cos': (_GENERAL, nlfunc.cos), - 'tan': (_GENERAL, nlfunc.tan), - 'sqrt': (_GENERAL, nlfunc.sqrt), + 'exp': (_GENERAL, gurobipy.nlfunc.exp), + 'log': (_GENERAL, gurobipy.nlfunc.log), + 'log10': (_GENERAL, gurobipy.nlfunc.log10), + 'sin': (_GENERAL, gurobipy.nlfunc.sin), + 'cos': (_GENERAL, gurobipy.nlfunc.cos), + 'tan': (_GENERAL, gurobipy.nlfunc.tan), + 'sqrt': (_GENERAL, gurobipy.nlfunc.sqrt), # Not supporting any of these right now--we'd have to build them from the # above: # 'asin': None, @@ -128,6 +128,15 @@ ) +gurobipy, gurobipy_available = attempt_import( + 'gurobipy', + deferred_submodules=['GRB'], + callback=_finalize_gurobipy, + minimum_version='12.0.0', +) +GRB = gurobipy.GRB + + """ In Gurobi 12: From a69dc6c84800415af671f7a7850f2585b3f70939 Mon Sep 17 00:00:00 2001 From: Miranda Mundt Date: Fri, 17 Oct 2025 13:56:46 -0600 Subject: [PATCH 103/103] Fix copyright statements --- pyomo/contrib/solver/solvers/gurobi_direct_minlp.py | 2 +- .../tests/solvers/gurobi_to_pyomo_expressions.py | 11 +++++++++++ .../contrib/solver/tests/solvers/test_gurobi_minlp.py | 11 +++++++++++ .../solver/tests/solvers/test_gurobi_minlp_walker.py | 2 +- .../solver/tests/solvers/test_gurobi_minlp_writer.py | 2 +- 5 files changed, 25 insertions(+), 3 deletions(-) diff --git a/pyomo/contrib/solver/solvers/gurobi_direct_minlp.py b/pyomo/contrib/solver/solvers/gurobi_direct_minlp.py index 7a83a54965c..bc7b8362aea 100755 --- a/pyomo/contrib/solver/solvers/gurobi_direct_minlp.py +++ b/pyomo/contrib/solver/solvers/gurobi_direct_minlp.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/solver/tests/solvers/gurobi_to_pyomo_expressions.py b/pyomo/contrib/solver/tests/solvers/gurobi_to_pyomo_expressions.py index a4b87736425..a5763992ae2 100644 --- a/pyomo/contrib/solver/tests/solvers/gurobi_to_pyomo_expressions.py +++ b/pyomo/contrib/solver/tests/solvers/gurobi_to_pyomo_expressions.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + from pyomo.common.dependencies import attempt_import from pyomo.core.expr.numeric_expr import ( SumExpression, diff --git a/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp.py b/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp.py index 1218dfcd8de..30ddd7eca4b 100644 --- a/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp.py +++ b/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp.py @@ -1,3 +1,14 @@ +# ___________________________________________________________________________ +# +# Pyomo: Python Optimization Modeling Objects +# Copyright (c) 2008-2025 +# National Technology and Engineering Solutions of Sandia, LLC +# Under the terms of Contract DE-NA0003525 with National Technology and +# Engineering Solutions of Sandia, LLC, the U.S. Government retains certain +# rights in this software. +# This software is distributed under the 3-clause BSD License. +# ___________________________________________________________________________ + import math import pyomo.common.unittest as unittest from pyomo.contrib.solver.common.factory import SolverFactory diff --git a/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp_walker.py b/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp_walker.py index 5598dd0f18b..14eab91f09c 100644 --- a/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp_walker.py +++ b/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp_walker.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain diff --git a/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp_writer.py b/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp_writer.py index b171dff8260..f86ebd975c4 100644 --- a/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp_writer.py +++ b/pyomo/contrib/solver/tests/solvers/test_gurobi_minlp_writer.py @@ -1,7 +1,7 @@ # ___________________________________________________________________________ # # Pyomo: Python Optimization Modeling Objects -# Copyright (c) 2008-2024 +# Copyright (c) 2008-2025 # National Technology and Engineering Solutions of Sandia, LLC # Under the terms of Contract DE-NA0003525 with National Technology and # Engineering Solutions of Sandia, LLC, the U.S. Government retains certain