Repository: Pyomo/pyomo

Issue:
Pyomo Versions > 6.4.4 result in crashes for several ASL-based MINLP solvers (believed to be related to NL Writer)
## Summary

When trying to solve the MINLP problem in the code below, Pyomo 6.4.4 succeeds with the default NL Writer, but fails when switched to use version 2 NL Writer. Newer versions that default to version 2 of NL Writer always fail.

### Steps to reproduce the issue

The following code replicates the problem.

- `ipopt` solves the relaxation of the MINLP problem - just for reference
- `scip` does not crash, but it gives a different answer with the V2 NL Writer
- `bonmin` and `couenne` both fail with V2 NL Writer

Pyomo 6.7.1 gives the same behavior as Pyomo 6.4.4 with V2 NL Writer

```python
from pyomo.environ import *
import pyomo.repn.plugins.nl_writer as nl_writer

def create_model():
    model = ConcreteModel()

    model.p1 = Var(within=PositiveReals, bounds=(0.85, 1.15))
    model.p2 = Var(within=PositiveReals, bounds=(0.68, 0.92))
    model.c1 = Var(within=NonNegativeReals, bounds=(-0.0, 0.7))
    model.c2 = Var(within=NonNegativeReals, bounds=(-0.0, 0.7))
    model.t1 = Var(within=Boolean, bounds=(0, 1))
    model.t2 = Var(within=Boolean, bounds=(0, 1))
    model.const = Constraint(expr=((0.7 - (model.c1*model.t1 + model.c2*model.t2)) <= (model.p1*model.t1 + model.p2*model.t2)))
    model.OBJ = Objective(expr=(model.p1*model.t1 + model.p2*model.t2))

    return model

def solve(solver, nl_version):
    if pyomo.version.version_info[0:2] == (6,4):
        nl_writer._activate_nl_writer_version(nl_version)
    else:
        if nl_version == 1:
            return
    model = create_model()
    opt = SolverFactory(solver)
    try:
        opt.solve(model, tee=False)
    except Exception as e:
        print(f"{opt.name}:{opt.options.solver}:nl_version_{nl_version} {e}")    
        return
    print(f"{opt.name}:{opt.options.solver}:nl_version_{nl_version} {value(model.OBJ)}")

for nl_version in [1, 2]:
    for solver in ["ipopt", "scip", "bonmin", "couenne"]:
        solve(solver, nl_version)
```

### Error Message

#### Pyomo 6.4.4
```console
ipopt:None:nl_version_1 0.34492753275643157
scip:None:nl_version_1 0.68
asl:bonmin:nl_version_1 0.68
asl:couenne:nl_version_1 0.68
ipopt:None:nl_version_2 0.34492753275643157
scip:None:nl_version_2 0.699999999
ERROR: Solver (asl) returned non-zero return code (-6)
ERROR: Solver log: Bonmin 1.8.9 using Cbc 2.10.8 and Ipopt 3.12.13 malloc():
    invalid size (unsorted) bonmin:
asl:bonmin:nl_version_2 Solver (asl) did not exit normally
ERROR: Solver (asl) returned non-zero return code (-6)
ERROR: Solver log: Couenne 0.5.8 -- an Open-Source solver for Mixed Integer
    Nonlinear Optimization Mailing list: couenne@list.coin-or.org
    Instructions: http://www.coin-or.org/Couenne corrupted size vs. prev_size
    couenne:
asl:couenne:nl_version_2 Solver (asl) did not exit normally
```
#### Pyomo 6.7.1
```console
ipopt:None:nl_version_2 0.34492753275643157
scip:None:nl_version_2 0.699999999
ERROR: Solver (asl) returned non-zero return code (-6)
ERROR: Solver log: Bonmin 1.8.9 using Cbc 2.10.8 and Ipopt 3.12.13 malloc():
invalid size (unsorted) bonmin:
asl:bonmin:nl_version_2 Solver (asl) did not exit normally
ERROR: Solver (asl) returned non-zero return code (-6)
ERROR: Solver log: Couenne 0.5.8 -- an Open-Source solver for Mixed Integer
Nonlinear Optimization Mailing list: couenne@list.coin-or.org Instructions:
http://www.coin-or.org/Couenne corrupted size vs. prev_size couenne:
asl:couenne:nl_version_2 Solver (asl) did not exit normally
```
### Information on your system

Pyomo version: 6.4.4 / 6.7.1
Python version: 3.11.6
Operating system: Red Hat Enterprise Linux release 8.8 (Ootpa)
How Pyomo was installed (PyPI, conda, source): PyPI
Solver (if applicable): see above, all solvers were downloaded from the AMPL Portal (https://portal.ampl.com/user/ampl/download)



Discussion:

Resolving patch:
diff --git a/pyomo/repn/plugins/nl_writer.py b/pyomo/repn/plugins/nl_writer.py
--- a/pyomo/repn/plugins/nl_writer.py
+++ b/pyomo/repn/plugins/nl_writer.py
@@ -113,6 +113,7 @@
 TOL = 1e-8
 inf = float('inf')
 minus_inf = -inf
+allowable_binary_var_bounds = {(0, 0), (0, 1), (1, 1)}
 
 _CONSTANT = ExprType.CONSTANT
 _MONOMIAL = ExprType.MONOMIAL
@@ -882,7 +883,12 @@ def write(self, model):
             elif v.is_binary():
                 binary_vars.add(_id)
             elif v.is_integer():
-                integer_vars.add(_id)
+                # Note: integer variables whose bounds are in {0, 1}
+                # should be classified as binary
+                if var_bounds[_id] in allowable_binary_var_bounds:
+                    binary_vars.add(_id)
+                else:
+                    integer_vars.add(_id)
             else:
                 raise ValueError(
                     f"Variable '{v.name}' has a domain that is not Real, "
@@ -1277,8 +1283,8 @@ def write(self, model):
                 len(linear_binary_vars),
                 len(linear_integer_vars),
                 len(both_vars_nonlinear.intersection(discrete_vars)),
-                len(con_vars_nonlinear.intersection(discrete_vars)),
-                len(obj_vars_nonlinear.intersection(discrete_vars)),
+                len(con_only_nonlinear_vars.intersection(discrete_vars)),
+                len(obj_only_nonlinear_vars.intersection(discrete_vars)),
             )
         )
         #


Test patch:
diff --git a/pyomo/repn/tests/ampl/test_nlv2.py b/pyomo/repn/tests/ampl/test_nlv2.py
--- a/pyomo/repn/tests/ampl/test_nlv2.py
+++ b/pyomo/repn/tests/ampl/test_nlv2.py
@@ -42,6 +42,8 @@
     Suffix,
     Constraint,
     Expression,
+    Binary,
+    Integers,
 )
 import pyomo.environ as pyo
 
@@ -1266,7 +1268,7 @@ def test_nonfloat_constants(self):
  0 0   #network constraints: nonlinear, linear
  0 0 0 #nonlinear vars in constraints, objectives, both
  0 0 0 1       #linear network variables; functions; arith, flags
- 0 4 0 0 0     #discrete variables: binary, integer, nonlinear (b,c,o)
+ 4 0 0 0 0     #discrete variables: binary, integer, nonlinear (b,c,o)
  4 4   #nonzeros in Jacobian, obj. gradient
  6 4   #max name lengths: constraints, variables
  0 0 0 0 0     #common exprs: b,c,o,c1,o1
@@ -2165,6 +2167,143 @@ def test_named_expressions(self):
 0 0
 1 0
 2 0
+""",
+                OUT.getvalue(),
+            )
+        )
+
+    def test_discrete_var_tabulation(self):
+        # This tests an error reported in #3235
+        #
+        # Among other issues, this verifies that nonlinear discrete
+        # variables are tabulated correctly (header line 7), and that
+        # integer variables with bounds in {0, 1} are mapped to binary
+        # variables.
+        m = ConcreteModel()
+        m.p1 = Var(bounds=(0.85, 1.15))
+        m.p2 = Var(bounds=(0.68, 0.92))
+        m.c1 = Var(bounds=(-0.0, 0.7))
+        m.c2 = Var(bounds=(-0.0, 0.7))
+        m.t1 = Var(within=Binary, bounds=(0, 1))
+        m.t2 = Var(within=Binary, bounds=(0, 1))
+        m.t3 = Var(within=Binary, bounds=(0, 1))
+        m.t4 = Var(within=Binary, bounds=(0, 1))
+        m.t5 = Var(within=Integers, bounds=(0, None))
+        m.t6 = Var(within=Integers, bounds=(0, None))
+        m.x1 = Var(within=Binary)
+        m.x2 = Var(within=Integers, bounds=(0, 1))
+        m.x3 = Var(within=Integers, bounds=(0, None))
+        m.const = Constraint(
+            expr=(
+                (0.7 - (m.c1 * m.t1 + m.c2 * m.t2))
+                <= (m.p1 * m.t1 + m.p2 * m.t2 + m.p1 * m.t4 + m.t6 * m.t5)
+            )
+        )
+        m.OBJ = Objective(
+            expr=(m.p1 * m.t1 + m.p2 * m.t2 + m.p2 * m.t3 + m.x1 + m.x2 + m.x3)
+        )
+
+        OUT = io.StringIO()
+        nl_writer.NLWriter().write(m, OUT, symbolic_solver_labels=True)
+
+        self.assertEqual(
+            *nl_diff(
+                """g3 1 1 0	# problem unknown
+ 13 1 1 0 0    #vars, constraints, objectives, ranges, eqns
+ 1 1 0 0 0 0   #nonlinear constrs, objs; ccons: lin, nonlin, nd, nzlb
+ 0 0   #network constraints: nonlinear, linear
+ 9 10 4        #nonlinear vars in constraints, objectives, both
+ 0 0 0 1       #linear network variables; functions; arith, flags
+ 2 1 2 3 1     #discrete variables: binary, integer, nonlinear (b,c,o)
+ 9 8   #nonzeros in Jacobian, obj. gradient
+ 5 2   #max name lengths: constraints, variables
+ 0 0 0 0 0     #common exprs: b,c,o,c1,o1
+C0     #const
+o0     #+
+o16    #-
+o0     #+
+o2     #*
+v4     #c1
+v2     #t1
+o2     #*
+v5     #c2
+v3     #t2
+o16    #-
+o54    #sumlist
+4      #(n)
+o2     #*
+v0     #p1
+v2     #t1
+o2     #*
+v1     #p2
+v3     #t2
+o2     #*
+v0     #p1
+v6     #t4
+o2     #*
+v7     #t6
+v8     #t5
+O0 0   #OBJ
+o54    #sumlist
+3      #(n)
+o2     #*
+v0     #p1
+v2     #t1
+o2     #*
+v1     #p2
+v3     #t2
+o2     #*
+v1     #p2
+v9     #t3
+x0     #initial guess
+r      #1 ranges (rhs's)
+1 -0.7 #const
+b      #13 bounds (on variables)
+0 0.85 1.15    #p1
+0 0.68 0.92    #p2
+0 0 1  #t1
+0 0 1  #t2
+0 -0.0 0.7     #c1
+0 -0.0 0.7     #c2
+0 0 1  #t4
+2 0    #t6
+2 0    #t5
+0 0 1  #t3
+0 0 1  #x1
+0 0 1  #x2
+2 0    #x3
+k12    #intermediate Jacobian column lengths
+1
+2
+3
+4
+5
+6
+7
+8
+9
+9
+9
+9
+J0 9   #const
+0 0
+1 0
+2 0
+3 0
+4 0
+5 0
+6 0
+7 0
+8 0
+G0 8   #OBJ
+0 0
+1 0
+2 0
+3 0
+9 0
+10 1
+11 1
+12 1
 """,
                 OUT.getvalue(),
             )