From 168ce4eabfd796bac427fdaf31dce38483b76b8b Mon Sep 17 00:00:00 2001 From: cjck944084735-dot <263858999+cjck944084735-dot@users.noreply.github.com> Date: Sat, 29 Aug 2026 20:29:56 +0800 Subject: [PATCH] Fix LinearRepnVisitor to substitute fixed vars already in var_map record_monomial only applied the fixed-variable (constant) handling when the variable had not yet been registered in the visitor var_map. When a variable was registered in bulk by the TemplateVarRecorder while it was still free, and was fixed afterwards, subsequent walk_expression calls treated it as an unrestricted variable: the LinearStandardFormCompiler would then reject expressions involving it as nonlinear even though they are linear after substitution. Check var.fixed before the var_map membership test so fixed variables always contribute their value as a constant. Fixes #3851 --- pyomo/repn/linear.py | 29 +++++++++++--------- pyomo/repn/tests/test_linear.py | 47 +++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 13 deletions(-) diff --git a/pyomo/repn/linear.py b/pyomo/repn/linear.py index dd5b44cd58a..3cfe9aa2c06 100644 --- a/pyomo/repn/linear.py +++ b/pyomo/repn/linear.py @@ -657,20 +657,23 @@ def __init__(self): @staticmethod def record_monomial(visitor, result, coef, var): _id = id(var) + if var.fixed: + # Fixed variables contribute constants even if they were + # already registered in the var_map earlier while still + # free (gh-3851) + var = check_constant(var.value, var, visitor) + if not coef and var.__class__ is InvalidNumber: + deprecation_warning( + f"Encountered {coef}*{val2str(var)} in expression " + "tree. Mapping the NaN result to 0 for compatibility " + "with the lp_v1 writer. In the future, this NaN " + "will be preserved/emitted to comply with IEEE-754.", + version='6.6.0', + ) + else: + result.constant += coef * var + return if _id not in visitor.var_map: - if var.fixed: - var = check_constant(var.value, var, visitor) - if not coef and var.__class__ is InvalidNumber: - deprecation_warning( - f"Encountered {coef}*{val2str(var)} in expression " - "tree. Mapping the NaN result to 0 for compatibility " - "with the lp_v1 writer. In the future, this NaN " - "will be preserved/emitted to comply with IEEE-754.", - version='6.6.0', - ) - else: - result.constant += coef * var - return visitor.var_recorder.add(var) if _id in result.linear: result.linear[_id] += coef diff --git a/pyomo/repn/tests/test_linear.py b/pyomo/repn/tests/test_linear.py index 269d1fc73c1..d2ee5e523a1 100644 --- a/pyomo/repn/tests/test_linear.py +++ b/pyomo/repn/tests/test_linear.py @@ -1799,3 +1799,50 @@ def test_var_order(self): repn.linear, {id(m.x[0]): 1, id(m.x[1]): 2, id(m.x[2]): 3, id(m.x[3]): 4} ) self.assertEqual(repn.nonlinear, None) + + +class TestFixedVarAfterRegistration(unittest.TestCase): + # gh-3851: variables that were registered in the var_map while + # still free must be handled as constants once fixed + def test_fixed_var_after_registration(self): + from pyomo.environ import Set + from pyomo.repn.util import SortComponents, TemplateVarRecorder + + m = ConcreteModel() + m.a = Set(initialize=[1, 2, 3]) + m.x = Var(m.a) + e1 = m.x[1] * (m.x[2] + m.x[3]) + e2 = m.x[1] - 1 / m.x[2] + m.x[2].fix(2) + + visitor = LinearRepnVisitor( + subexpression_cache={}, + var_recorder=TemplateVarRecorder({}, SortComponents.ORDERED_INDICES), + ) + visitor.walk_expression(e1) + repn = visitor.walk_expression(e2) + + self.assertIsNone(repn.nonlinear) + self.assertEqual(repn.constant, -0.5) + self.assertEqual(repn.linear, {id(m.x[1]): 1}) + + def test_fixed_var_substituted_before_registration(self): + from pyomo.environ import Set + from pyomo.repn.util import SortComponents, TemplateVarRecorder + + m = ConcreteModel() + m.a = Set(initialize=[1, 2, 3]) + m.x = Var(m.a) + e1 = m.x[1] * (m.x[2] + m.x[3]) + m.x[2].fix(2) + + visitor = LinearRepnVisitor( + subexpression_cache={}, + var_recorder=TemplateVarRecorder({}, SortComponents.ORDERED_INDICES), + ) + repn = visitor.walk_expression(e1) + + # x[2] is fixed, so it never becomes a linear term and its + # value does not appear in the nonlinear remainder + self.assertEqual(repn.linear, {}) + self.assertNotIn("x[2]", str(repn.nonlinear))