Repository: Pyomo/pyomo

Issue:
Incorrect degrees of freedom with linear presolve
## Summary

Testing out `NLWriter().write(model, file, linear_presolve=True)`, I have noticed that sometimes a variable that is eliminated is still written to the nl file. 

### Steps to reproduce the issue

```python
import pyomo.environ as pyo 
import pyomo.repn.plugins.nl_writer as nl_writer
import subprocess

# We use one constraint to eliminate one variable,
# but apparently no variables get eliminated.

m = pyo.ConcreteModel()

m.x = pyo.Var([1, 2, 3], initialize=1, bounds=(0, 10))

m.subexpr = pyo.Expression(pyo.Integers)
m.subexpr[1] = m.x[1] + m.x[2]

m.eq = pyo.Constraint(pyo.Integers)
m.eq[1] = m.x[1] == 7
m.eq[2] = m.x[3] == 0.1 * m.subexpr[1] * m.x[2]

m.obj = pyo.Objective(expr=m.x[1]**2 + m.x[2]**2 + m.x[3]**3)

nl_fname = "test.nl"
with open(nl_fname, "w") as f:
    nlinfo = nl_writer.NLWriter().write(m, f, linear_presolve=True)

print(f"N. var = {len(nlinfo.variables)}")
print(f"N. con = {len(nlinfo.constraints)}")
print(f"N. eliminated = {len(nlinfo.eliminated_vars)}")

print(f"Eliminated: {nlinfo.eliminated_vars[0][0].name}")
print("Variables in nl file:")
for var in nlinfo.variables:
    print(f"  {var.name}")

# Not strictly necessary. This is just here to support the argument that
# the eliminated variable actually makes its way to the nl file.
subprocess.run(["ipopt", nl_fname])
```
This gives:
```console
N. var = 3
N. con = 1
N. eliminated = 1
Eliminated: x[1]
Variables in nl file:
  x[2]
  x[1]
  x[3]
```
Followed by the Ipopt log, which includes:
```console
Total number of variables............................:        3
                     variables with only lower bounds:        0
                variables with lower and upper bounds:        3
                     variables with only upper bounds:        0
Total number of equality constraints.................:        1
Total number of inequality constraints...............:        0
        inequality constraints with only lower bounds:        0
   inequality constraints with lower and upper bounds:        0
        inequality constraints with only upper bounds:        0
```
I would expect the model to have two variables and one constraint after presolve, where instead it (supposedly) has three variables and one constraint.

### Information on your system

Pyomo version: 6.7.0.dev0 (current main)
Python version: 3.9.6
Solver (if applicable): Ipopt 3.14.11 (should not be relevant)

### Hypothesis

This appears to be happening because the call to `NLWriter._categorize_vars` for named expressions is adds `expr_info.linear` to the `linear_by_comp` map, approximately here:
https://github.com/Pyomo/pyomo/blob/91729ba44cdbaa888eedc7072251015d1d9e8da2/pyomo/repn/plugins/nl_writer.py#L1637-L1650
Then, when running `_categorize_vars` for the constraint in which the subexpression participates, we add `linear_by_comp[subexpr_id]` to `nonlinear_vars` for the constraint. The core issue seems to be that we don't check whether any of the linear variables have been eliminated (i.e., are present in `linear_by_comp`) when recording the linear portion of components. However, I am not sure why this does not cause a problem for constraints (perhaps because they don't appear in other components' expressions - but maybe the wrong variable could still show up since the elimination is not applied to `expo_info.linear`?). 

I did not understand the substitution code well enough to propose a fix. In particular, it was not clear to me where the substitution is happening. I think I see it for the nonlinear case, but not for the linear case.


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
@@ -1685,6 +1685,15 @@ def _linear_presolve(self, comp_by_linear_var, lcon_by_linear_nnz, var_bounds):
         if not self.config.linear_presolve:
             return eliminated_cons, eliminated_vars
 
+        # We need to record all named expressions with linear components
+        # so that any eliminated variables are removed from them.
+        for expr, info, _ in self.subexpression_cache.values():
+            if not info.linear:
+                continue
+            expr_id = id(expr)
+            for _id in info.linear:
+                comp_by_linear_var[_id].append((expr_id, info))
+
         fixed_vars = [
             _id for _id, (lb, ub) in var_bounds.items() if lb == ub and lb is not None
         ]


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
@@ -1480,7 +1480,6 @@ def test_presolve_almost_lower_triangular_nonlinear(self):
         # Note: bounds on x[1] are:
         #   min(22/3, 82/17, 23/4, -39/-6) == 4.823529411764706
         #   max(2/3, 62/17, 3/4, -19/-6) == 3.6470588235294117
-        print(OUT.getvalue())
         self.assertEqual(
             *nl_diff(
                 """g3 1 1 0	# problem unknown
@@ -1557,6 +1556,83 @@ def test_presolve_lower_triangular_out_of_bounds(self):
                 nlinfo = nl_writer.NLWriter().write(m, OUT, linear_presolve=True)
         self.assertEqual(LOG.getvalue(), "")
 
+    def test_presolve_named_expressions(self):
+        # Test from #3055
+        m = pyo.ConcreteModel()
+        m.x = pyo.Var([1, 2, 3], initialize=1, bounds=(0, 10))
+        m.subexpr = pyo.Expression(pyo.Integers)
+        m.subexpr[1] = m.x[1] + m.x[2]
+        m.eq = pyo.Constraint(pyo.Integers)
+        m.eq[1] = m.x[1] == 7
+        m.eq[2] = m.x[3] == 0.1 * m.subexpr[1] * m.x[2]
+        m.obj = pyo.Objective(expr=m.x[1] ** 2 + m.x[2] ** 2 + m.x[3] ** 3)
+
+        OUT = io.StringIO()
+        with LoggingIntercept() as LOG:
+            nlinfo = nl_writer.NLWriter().write(
+                m, OUT, symbolic_solver_labels=True, linear_presolve=True
+            )
+        self.assertEqual(LOG.getvalue(), "")
+
+        self.assertEqual(
+            nlinfo.eliminated_vars, [(m.x[1], nl_writer.AMPLRepn(7, {}, None))]
+        )
+
+        self.assertEqual(
+            *nl_diff(
+                """g3 1 1 0	# problem unknown
+ 2 1 1 0 1 	# 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
+ 1 2 1 	# nonlinear vars in constraints, objectives, both
+ 0 0 0 1	# linear network variables; functions; arith, flags
+ 0 0 0 0 0 	# discrete variables: binary, integer, nonlinear (b,c,o)
+ 2 2 	# nonzeros in Jacobian, obj. gradient
+ 5 4	# max name lengths: constraints, variables
+ 0 0 0 1 0	# common exprs: b,c,o,c1,o1
+V2 1 1	#subexpr[1]
+0 1
+n7.0
+C0	#eq[2]
+o16	#-
+o2	#*
+o2	#*
+n0.1
+v2	#subexpr[1]
+v0	#x[2]
+O0 0	#obj
+o54	# sumlist
+3	# (n)
+o5	#^
+n7.0
+n2
+o5	#^
+v0	#x[2]
+n2
+o5	#^
+v1	#x[3]
+n3
+x2	# initial guess
+0 1	#x[2]
+1 1	#x[3]
+r	#1 ranges (rhs's)
+4 0	#eq[2]
+b	#2 bounds (on variables)
+0 0 10	#x[2]
+0 0 10	#x[3]
+k1	#intermediate Jacobian column lengths
+1
+J0 2	#eq[2]
+0 0
+1 1
+G0 2	#obj
+0 0
+1 0
+""",
+                OUT.getvalue(),
+            )
+        )
+
     def test_scaling(self):
         m = pyo.ConcreteModel()
         m.x = pyo.Var(initialize=0)
@@ -1664,7 +1740,7 @@ def test_scaling(self):
         self.assertEqual(LOG.getvalue(), "")
 
         nl2 = OUT.getvalue()
-        print(nl2)
+
         self.assertEqual(
             *nl_diff(
                 """g3 1 1 0	# problem unknown
@@ -1758,7 +1834,7 @@ def test_named_expressions(self):
 
         OUT = io.StringIO()
         nl_writer.NLWriter().write(m, OUT, symbolic_solver_labels=True)
-        print(OUT.getvalue())
+
         self.assertEqual(
             *nl_diff(
                 """g3 1 1 0	# problem unknown