From d7ffc8b3adac30aeb423b184813a8e123a273431 Mon Sep 17 00:00:00 2001 From: John Siirola Date: Tue, 16 Sep 2025 19:55:50 -0600 Subject: [PATCH 01/36] Define InvalidConstraintError for unallowed constraint expressions --- pyomo/common/errors.py | 19 ++++++++++++++++--- pyomo/core/base/constraint.py | 18 +++++++++++------- 2 files changed, 27 insertions(+), 10 deletions(-) diff --git a/pyomo/common/errors.py b/pyomo/common/errors.py index 617b325149b..9db1c0c05d4 100644 --- a/pyomo/common/errors.py +++ b/pyomo/common/errors.py @@ -157,10 +157,23 @@ def __str__(self): class InfeasibleConstraintException(PyomoException): + """Exception raised by Pyomo transformations or solver interfaces + to indicate that an infeasible constraint has been identified + (e.g. in the course of range reduction). + """ - Exception class used by Pyomo transformations to indicate - that an infeasible constraint has been identified (e.g. in - the course of range reduction). + + +class InvalidConstraintError(PyomoException, ValueError): + """Exception raised when invalid constraints are identified. + + Pyomo will raise this exception when: + + - Creating a constraint with a trivial (Boolean) expression. + - Creating a constraint from an incorrectly structured tuple. + - Compiling a ranged constraint (``lb <= body <= ub``) where + either ``lb`` or ``ub`` are variable expressions. + """ diff --git a/pyomo/core/base/constraint.py b/pyomo/core/base/constraint.py index 43bf1d2acff..2e24dff84ed 100644 --- a/pyomo/core/base/constraint.py +++ b/pyomo/core/base/constraint.py @@ -17,7 +17,11 @@ from typing import Union, Type from pyomo.common.deprecation import RenamedClass, deprecated -from pyomo.common.errors import DeveloperError, TemplateExpressionError +from pyomo.common.errors import ( + DeveloperError, + InvalidConstraintError, + TemplateExpressionError, +) from pyomo.common.formatting import tabular_writer from pyomo.common.log import is_debug_set from pyomo.common.modeling import NOTSET @@ -219,7 +223,7 @@ def to_bounded_expression(self, evaluate_bounds=False): and lb.is_potentially_variable() and not lb.is_fixed() ): - raise ValueError( + raise InvalidConstraintError( f"Constraint '{self.name}' is a Ranged Inequality with a " "variable lower bound. Cannot normalize the " "constraint or send it to a solver." @@ -229,7 +233,7 @@ def to_bounded_expression(self, evaluate_bounds=False): and ub.is_potentially_variable() and not ub.is_fixed() ): - raise ValueError( + raise InvalidConstraintError( f"Constraint '{self.name}' is a Ranged Inequality with a " "variable upper bound. Cannot normalize the " "constraint or send it to a solver." @@ -414,7 +418,7 @@ def set_value(self, expr): or isinstance(arg, NumericValue) ): continue - raise ValueError( + raise InvalidConstraintError( "Constraint '%s' does not have a proper value. " "Constraint expressions expressed as tuples must " "contain native numeric types or Pyomo NumericValue " @@ -426,7 +430,7 @@ def set_value(self, expr): # Form equality expression # if expr[0] is None or expr[1] is None: - raise ValueError( + raise InvalidConstraintError( "Constraint '%s' does not have a proper value. " "Equality Constraints expressed as 2-tuples " "cannot contain None [received %s]" % (self.name, expr) @@ -445,7 +449,7 @@ def set_value(self, expr): self._expr = RangedExpression(expr, False) return else: - raise ValueError( + raise InvalidConstraintError( "Constraint '%s' does not have a proper value. " "Found a tuple of length %d. Expecting a tuple of " "length 2 or 3:\n" @@ -464,7 +468,7 @@ def set_value(self, expr): raise ValueError(_rule_returned_none_error % (self.name,)) elif expr.__class__ is bool: - raise ValueError( + raise InvalidConstraintError( "Invalid constraint expression. The constraint " "expression resolved to a trivial Boolean (%s) " "instead of a Pyomo object. Please modify your " From 6dde46f825d40619c99127820c8e0426b9f11632 Mon Sep 17 00:00:00 2001 From: John Siirola Date: Tue, 16 Sep 2025 19:57:54 -0600 Subject: [PATCH 02/36] Remove check_duplicates from linear template compiler --- pyomo/repn/linear_template.py | 65 +++++++++-------------------------- 1 file changed, 17 insertions(+), 48 deletions(-) diff --git a/pyomo/repn/linear_template.py b/pyomo/repn/linear_template.py index e84060e0a81..37b8e028d1f 100644 --- a/pyomo/repn/linear_template.py +++ b/pyomo/repn/linear_template.py @@ -101,13 +101,7 @@ def append(self, other): self.linear_sum.extend(other.linear_sum) def _build_evaluator( - self, - smap, - expr_cache, - multiplier, - repetitions, - remove_fixed_vars, - check_duplicates, + self, smap, expr_cache, multiplier, repetitions, remove_fixed_vars ): ans = [] multiplier *= self.multiplier @@ -133,29 +127,22 @@ def _build_evaluator( indent = '' if k in expr_cache: k = expr_cache[k] - if k.__class__ not in native_types and k.is_expression_type(): - ans.append('v = ' + k.to_string(smap=smap)) - k = 'v' + if k.__class__ not in native_types: + # Directly substitute the expression into the + # 'linear[vid] = coef' below + k = k.to_string(smap=smap) + # If we are removing fixed vars, add that logic here: if remove_fixed_vars: + ans.append(f"v = {k}") ans.append('if v.__class__ is tuple:') ans.append(' const += v[0] * {coef}') ans.append(' v = None') ans.append('else:') + k = 'v' indent = ' ' - elif not check_duplicates: - # Directly substitute the expression into the - # 'linear[vid] = coef below - # - # Remove the 'v = ' from the beginning of the last line: - k = ans.pop()[4:] - if check_duplicates: - ans.append(indent + f'if {k} in linear:') - ans.append(indent + f' linear[{k}] += {coef}') - ans.append(indent + 'else:') - ans.append(indent + f' linear[{k}] = {coef}') - else: - ans.append(indent + f'linear_indices.append({k})') - ans.append(indent + f'linear_data.append({coef})') + ans.append(indent + f'linear_indices.append({k})') + ans.append(indent + f'linear_data.append({coef})') + for subrepn, subindices, subsets in self.linear_sum: ans.extend( ' ' * i @@ -175,30 +162,15 @@ def _build_evaluator( except: subrep = 0 subans, subconst = subrepn._build_evaluator( - smap, - expr_cache, - multiplier, - repetitions * subrep, - remove_fixed_vars, - check_duplicates, + smap, expr_cache, multiplier, repetitions * subrep, remove_fixed_vars ) indent = ' ' * (len(subsets)) ans.extend(indent + line for line in subans) constant += subconst return ans, constant - def compile( - self, - env, - smap, - expr_cache, - args, - remove_fixed_vars=False, - check_duplicates=False, - ): - ans, constant = self._build_evaluator( - smap, expr_cache, 1, 1, remove_fixed_vars, check_duplicates - ) + def compile(self, env, smap, expr_cache, args, remove_fixed_vars=False): + ans, constant = self._build_evaluator(smap, expr_cache, 1, 1, remove_fixed_vars) if not ans: return constant indent = '\n ' @@ -217,12 +189,9 @@ def compile( ans.insert(0, fcn_body) else: ans = [ans[0], fcn_body, 'return const'] - if check_duplicates: - ans.insert(0, f"def build_expr(linear, {', '.join(args)}):") - else: - ans.insert( - 0, f"def build_expr(linear_indices, linear_data, {', '.join(args)}):" - ) + ans.insert( + 0, f"def build_expr(linear_indices, linear_data, {', '.join(args)}):" + ) ans = indent.join(ans) # build the function in the env namespace, then remove and # return the compiled function. The function's globals will From fd2856040d6501b6134979cc3c6bc656975b3871 Mon Sep 17 00:00:00 2001 From: John Siirola Date: Tue, 16 Sep 2025 20:02:01 -0600 Subject: [PATCH 03/36] When compiling templates, call to_string in everything that is not a native type --- pyomo/repn/linear_template.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/pyomo/repn/linear_template.py b/pyomo/repn/linear_template.py index 37b8e028d1f..91c436535ed 100644 --- a/pyomo/repn/linear_template.py +++ b/pyomo/repn/linear_template.py @@ -108,16 +108,14 @@ def _build_evaluator( constant = self.constant if constant.__class__ not in native_types or constant: constant *= multiplier - if not repetitions or ( - constant.__class__ not in native_types and constant.is_expression_type() - ): + if not repetitions or constant.__class__ not in native_types: ans.append('const += ' + constant.to_string(smap=smap)) constant = 0 else: constant *= repetitions for k, coef in list(self.linear.items()): coef *= multiplier - if coef.__class__ not in native_types and coef.is_expression_type(): + if coef.__class__ not in native_types: coef = coef.to_string(smap=smap) elif coef: coef = repr(coef) From 5b55d9162553b2696a9f8cefa329fc74a33b2f9d Mon Sep 17 00:00:00 2001 From: John Siirola Date: Tue, 16 Sep 2025 20:22:25 -0600 Subject: [PATCH 04/36] Catch (and test) bug where TemplateVarRecorder ignored the sorter --- pyomo/repn/tests/test_util.py | 45 +++++++++++++++++++++++++++++++++++ pyomo/repn/util.py | 8 +++++-- 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/pyomo/repn/tests/test_util.py b/pyomo/repn/tests/test_util.py index 558bba73e70..d9a9451a0b5 100644 --- a/pyomo/repn/tests/test_util.py +++ b/pyomo/repn/tests/test_util.py @@ -46,6 +46,7 @@ FileDeterminism, FileDeterminism_to_SortComponents, InvalidNumber, + TemplateVarRecorder, apply_node_operation, categorize_valid_components, complex_number_error, @@ -835,6 +836,50 @@ class new_int(int): self.assertIs(bcd[DivisionExpression], bcd._before_general_expression) self.assertEqual(len(bcd), 14) + def test_TemplateVarRecorder(self): + m = ConcreteModel() + m.x = Var([2, 3, 5, 1, 4]) + + vm = {} + vr = TemplateVarRecorder(vm, SortComponents.deterministic) + vr.add(m.x[4]) + self.assertEqual(len(vm), 5) + self.assertEqual( + {id(m.x[2]): 0, id(m.x[3]): 1, id(m.x[5]): 2, id(m.x[1]): 3, id(m.x[4]): 4}, + vr.var_order, + ) + + vm = {} + vr = TemplateVarRecorder(vm, SortComponents.indices) + vr.add(m.x[4]) + self.assertEqual(len(vm), 5) + self.assertEqual( + {id(m.x[1]): 0, id(m.x[2]): 1, id(m.x[3]): 2, id(m.x[4]): 3, id(m.x[5]): 4}, + vr.var_order, + ) + + def test_TemplateVarRecorder_user_varmap(self): + m = ConcreteModel() + m.x = Var([2, 3, 5, 1, 4]) + + vm = {id(m.x[5]): m.x[5], id(m.x[3]): m.x[3]} + vr = TemplateVarRecorder(vm, SortComponents.deterministic) + vr.add(m.x[4]) + self.assertEqual(len(vm), 5) + self.assertEqual( + {id(m.x[5]): 0, id(m.x[3]): 1, id(m.x[2]): 2, id(m.x[1]): 3, id(m.x[4]): 4}, + vr.var_order, + ) + + vm = {id(m.x[5]): m.x[5], id(m.x[3]): m.x[3]} + vr = TemplateVarRecorder(vm, SortComponents.indices) + vr.add(m.x[4]) + self.assertEqual(len(vm), 5) + self.assertEqual( + {id(m.x[5]): 0, id(m.x[3]): 1, id(m.x[1]): 2, id(m.x[2]): 3, id(m.x[4]): 4}, + vr.var_order, + ) + if __name__ == "__main__": unittest.main() diff --git a/pyomo/repn/util.py b/pyomo/repn/util.py index 6dd2938c84b..ecaa8ce91ea 100644 --- a/pyomo/repn/util.py +++ b/pyomo/repn/util.py @@ -850,7 +850,7 @@ def __init__(self, var_map, sorter): # that ordering. This means we need to both initialize the # env dict with all the Vars referenced, PLUS fill in any # additional vars that we would have indexed/recorded in - # add() + # add(). next_i = len(var_map) for i, v in enumerate(list(var_map.values())): var_comp = v.parent_component() @@ -858,7 +858,11 @@ def __init__(self, var_map, sorter): ve = self.env.get(name, None) if ve is None: ve = self.env[name] = {} - for idx, vdata in var_comp.items(): + # Fill-in all var data in this component. Note that + # we are careful to only add / assign column ids to + # var data that we will not later encounter in the + # var_map. + for idx, vdata in var_comp.items(self.sorter): vid = id(vdata) if vid not in var_map: var_map[vid] = v From 1c5528631943a0734e5e8e209ab48b5a7c42082b Mon Sep 17 00:00:00 2001 From: John Siirola Date: Tue, 16 Sep 2025 20:30:11 -0600 Subject: [PATCH 05/36] Avoid awkward call to base class by just reimplementing the method --- pyomo/repn/linear_template.py | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/pyomo/repn/linear_template.py b/pyomo/repn/linear_template.py index 91c436535ed..fcce8b850c2 100644 --- a/pyomo/repn/linear_template.py +++ b/pyomo/repn/linear_template.py @@ -63,17 +63,12 @@ def multiplier_flag(val): return 2 # something not 0 or 1 def walker_exitNode(self): - if not self.linear and self.linear_sum: - # "LINEAR" is "linear or linear_sum"; (temporarily) move - # linear_sum to linear so this node is recognized as "LINEAR". - linear = self.linear - self.linear = self.linear_sum - try: - return super().walker_exitNode() - finally: - self.linear = linear + if self.nonlinear is not None: + return _GENERAL, self + elif self.linear or self.linear_sum: + return _LINEAR, self else: - return super().walker_exitNode() + return _CONSTANT, self.multiplier * self.constant def duplicate(self): ans = super().duplicate() From db497ef76f52d0010d8587a2c3152c5e300482a8 Mon Sep 17 00:00:00 2001 From: John Siirola Date: Fri, 19 Sep 2025 04:57:13 -0600 Subject: [PATCH 06/36] Use attempt_import to break circular dependencies --- pyomo/core/expr/template_expr.py | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/pyomo/core/expr/template_expr.py b/pyomo/core/expr/template_expr.py index 90d1a39292b..ddbbaab82ca 100644 --- a/pyomo/core/expr/template_expr.py +++ b/pyomo/core/expr/template_expr.py @@ -16,6 +16,7 @@ from contextlib import nullcontext from pyomo.common.collections import MutableMapping +from pyomo.common.dependencies import attempt_import from pyomo.common.errors import TemplateExpressionError from pyomo.common.gc_manager import PauseGC from pyomo.core.expr.base import ExpressionBase, ExpressionArgs_Mixin, NPV_Mixin @@ -45,6 +46,10 @@ _ToStringVisitor, ) +# Deferred imports to break circular dependencies +pyomo_core_base_set, _ = attempt_import('pyomo.core.base.set') +pyomo_core_base_param, _ = attempt_import('pyomo.core.base.param') + logger = logging.getLogger(__name__) @@ -764,8 +769,6 @@ def _reduce_template_to_component(expr): GetAttrExpression, and TemplateSumExpression expression nodes. """ - import pyomo.core.base.set - # wildcards holds lists of # [iterator, source, value, orig_value, object0, ...] # 'iterator' iterates over 'source' to provide 'value's for each of @@ -798,14 +801,12 @@ def beforeChild(node, child, child_idx): ans = child._resolve_template(()) return False, ans if child.is_variable_type(): - from pyomo.core.base.set import RangeSet - if child.domain.isdiscrete(): domain = child.domain bounds = child.bounds if bounds != (None, None): try: - bounds = pyomo.core.base.set.RangeSet(*bounds, 0) + bounds = pyomo_core_base_set.RangeSet(*bounds, 0) domain = domain & bounds except: pass @@ -975,14 +976,12 @@ def substitute_getitem_with_param(expr, _map): new Param. For example, this method will create expressions suitable for passing to DAE integrators """ - import pyomo.core.base.param - if type(expr) is IndexTemplate: return expr _id = _GetItemIndexer(expr) if _id not in _map: - _map[_id] = pyomo.core.base.param.Param(mutable=True) + _map[_id] = pyomo_core_base_param.Param(mutable=True) _map[_id].construct() _map[_id]._name = "%s[%s]" % (_id.base.name, ','.join(str(x) for x in _id.args)) return _map[_id] @@ -1165,15 +1164,13 @@ def pause(self): def templatize_rule(block, rule, index_set): - import pyomo.core.base.set - context = _template_iter_context() internal_error = None try: # Override Set iteration to return IndexTemplates with _TemplateIterManager.init( context, - pyomo.core.base.set._FiniteSetMixin, + pyomo_core_base_set._FiniteSetMixin, GetItemExpression, GetAttrExpression, ): From 2b5669cd93c4ea91776e7e801e83a7d81e232daa Mon Sep 17 00:00:00 2001 From: John Siirola Date: Fri, 19 Sep 2025 05:03:07 -0600 Subject: [PATCH 07/36] Start tests for repn/linear_template --- pyomo/repn/tests/test_linear_template.py | 303 +++++++++++++++++++++++ 1 file changed, 303 insertions(+) create mode 100644 pyomo/repn/tests/test_linear_template.py diff --git a/pyomo/repn/tests/test_linear_template.py b/pyomo/repn/tests/test_linear_template.py new file mode 100644 index 00000000000..ee7d7e59532 --- /dev/null +++ b/pyomo/repn/tests/test_linear_template.py @@ -0,0 +1,303 @@ +# ___________________________________________________________________________ +# +# 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 import unittest + +import pyomo.core.base.constraint as constraint +import pyomo.core.base.objective as objective + +from pyomo.core.base.enums import SortComponents +from pyomo.repn.linear_template import LinearTemplateRepnVisitor +from pyomo.repn.util import TemplateVarRecorder + +from pyomo.environ import * + + +class TestLinearTemplate(unittest.TestCase): + def setUp(self): + self._memo = ( + constraint.TEMPLATIZE_CONSTRAINTS, + objective.TEMPLATIZE_OBJECTIVES, + ) + constraint.TEMPLATIZE_CONSTRAINTS = True + objective.TEMPLATIZE_OBJECTIVES = True + var_recorder = TemplateVarRecorder({}, SortComponents.deterministic) + self.visitor = LinearTemplateRepnVisitor({}, var_recorder=var_recorder) + + def tearDown(self): + constraint.TEMPLATIZE_CONSTRAINTS, objective.TEMPLATIZE_OBJECTIVES = self._memo + + def _build_evaluator(self, expr): + repn = self.visitor.walk_expression(expr) + return repn._build_evaluator( + self.visitor.symbolmap, self.visitor.expr_cache, 1, 1, False + ) + + def test_no_indirection(self): + m = ConcreteModel() + m.x = Var() + + @m.Constraint() + def c(m): + return m.x <= 0 + + self.assertTrue(hasattr(m.c, 'template_expr')) + lb, body, ub = m.c.to_bounded_expression() + self.assertEqual(lb, None) + self.assertEqual(ub, 0) + + ans, const = self._build_evaluator(body) + self.assertEqual(const, 0) + self.assertEqual(ans, ['linear_indices.append(0)', 'linear_data.append(1)']) + + def test_single_var_no_loop(self): + m = ConcreteModel() + m.x = Var([1, 2, 3]) + + @m.Constraint(m.x.index_set()) + def c(m, i): + return m.x[i] <= 1 + + self.assertTrue(hasattr(m.c[1], 'template_expr')) + lb, body, ub = m.c[1].to_bounded_expression() + self.assertEqual(lb, None) + self.assertEqual(ub, 1) + + ans, const = self._build_evaluator(body) + self.assertEqual(const, 0) + self.assertEqual( + ans, ['linear_indices.append(x1[x2])', 'linear_data.append(1)'] + ) + + @m.Constraint(m.x.index_set()) + def d(m, i): + return i * m.x[i] <= 1 + + self.assertTrue(hasattr(m.d[1], 'template_expr')) + lb, body, ub = m.d[1].to_bounded_expression() + self.assertEqual(lb, None) + self.assertEqual(ub, 1) + + ans, const = self._build_evaluator(body) + self.assertEqual(const, 0) + self.assertEqual( + ans, ['linear_indices.append(x1[x3])', 'linear_data.append(x3)'] + ) + + def test_two_var_const_no_loop(self): + m = ConcreteModel() + m.x = Var([1, 2, 3]) + m.y = Var([1, 2, 3, 4]) + + @m.Constraint(m.x.index_set()) + def c(m, i): + return m.x[i] + 10 - m.y[i + 1] >= 0 + + self.assertTrue(hasattr(m.c[1], 'template_expr')) + lb, body, ub = m.c[1].to_bounded_expression() + self.assertEqual(lb, 0) + self.assertEqual(ub, None) + + ans, const = self._build_evaluator(body) + self.assertEqual(const, 10) + self.assertEqual( + ans, + [ + 'linear_indices.append(x1[x2])', + 'linear_data.append(1)', + 'linear_indices.append(x3[x2 + 1])', + 'linear_data.append(-1)', + ], + ) + + @m.Constraint(m.x.index_set()) + def d(m, i): + return m.x[i] + i - m.y[i + 1] >= 0 + + self.assertTrue(hasattr(m.d[1], 'template_expr')) + lb, body, ub = m.d[1].to_bounded_expression() + self.assertEqual(lb, 0) + self.assertEqual(ub, None) + + ans, const = self._build_evaluator(body) + self.assertEqual(const, 0) + self.assertEqual( + ans, + [ + 'const += x4', + 'linear_indices.append(x1[x4])', + 'linear_data.append(1)', + 'linear_indices.append(x3[x4 + 1])', + 'linear_data.append(-1)', + ], + ) + + def test_sum_one_var(self): + m = ConcreteModel() + m.x = Var([1, 2, 3]) + + @m.Constraint(m.x.index_set()) + def c(m, i): + return sum(m.x[i] for i in m.x) >= 0 + + self.assertTrue(hasattr(m.c[1], 'template_expr')) + lb, body, ub = m.c[1].to_bounded_expression() + self.assertEqual(lb, 0) + self.assertEqual(ub, None) + + ans, const = self._build_evaluator(body) + self.assertEqual(const, 0) + self.assertEqual( + ans, + [ + 'for x2 in x3:', + ' linear_indices.append(x1[x2])', + ' linear_data.append(1)', + ], + ) + + @m.Constraint(m.x.index_set()) + def d(m, i): + return sum(m.x[i] + 2 for i in m.x) >= 0 + + self.assertTrue(hasattr(m.d[1], 'template_expr')) + lb, body, ub = m.d[1].to_bounded_expression() + self.assertEqual(lb, 0) + self.assertEqual(ub, None) + + ans, const = self._build_evaluator(body) + self.assertEqual(const, 6) + self.assertEqual( + ans, + [ + 'for x4 in x3:', + ' linear_indices.append(x1[x4])', + ' linear_data.append(1)', + ], + ) + + m.p = Param(initialize=2, mutable=True) + + @m.Constraint(m.x.index_set()) + def e(m, i): + return sum(m.x[i] + m.p for i in m.x) >= 0 + + self.assertTrue(hasattr(m.e[1], 'template_expr')) + lb, body, ub = m.e[1].to_bounded_expression() + self.assertEqual(lb, 0) + self.assertEqual(ub, None) + + ans, const = self._build_evaluator(body) + self.assertEqual(const, 6) + self.assertEqual( + ans, + [ + 'for x5 in x3:', + ' linear_indices.append(x1[x5])', + ' linear_data.append(1)', + ], + ) + + m.q = Param([1, 2, 3], initialize={1: 10, 2: 20, 3: 30}) + + @m.Constraint(m.x.index_set()) + def e(m, i): + return sum(m.x[i] + m.q[i] for i in m.x) >= 0 + + self.assertTrue(hasattr(m.e[1], 'template_expr')) + lb, body, ub = m.e[1].to_bounded_expression() + self.assertEqual(lb, 0) + self.assertEqual(ub, None) + + ans, const = self._build_evaluator(body) + self.assertEqual(const, 0) + self.assertEqual( + ans, + [ + 'for x6 in x3:', + ' const += x7[x6]', + ' linear_indices.append(x1[x6])', + ' linear_data.append(1)', + ], + ) + + def test_sum_two_var(self): + m = ConcreteModel() + m.x = Var([1, 2, 3]) + m.y = Var([1, 2, 3]) + + @m.Constraint(m.x.index_set()) + def c(m, i): + return sum(m.x[i] + m.y[i] for i in m.x) >= 0 + + self.assertTrue(hasattr(m.c[1], 'template_expr')) + lb, body, ub = m.c[1].to_bounded_expression() + self.assertEqual(lb, 0) + self.assertEqual(ub, None) + + ans, const = self._build_evaluator(body) + self.assertEqual(const, 0) + self.assertEqual( + ans, + [ + 'for x2 in x4:', + ' linear_indices.append(x1[x2])', + ' linear_data.append(1)', + ' linear_indices.append(x3[x2])', + ' linear_data.append(1)', + ], + ) + + @m.Constraint(m.x.index_set()) + def d(m, i): + return sum(m.x[i] + 2 + m.y[i] for i in m.x) >= 0 + + self.assertTrue(hasattr(m.d[1], 'template_expr')) + lb, body, ub = m.d[1].to_bounded_expression() + self.assertEqual(lb, 0) + self.assertEqual(ub, None) + + ans, const = self._build_evaluator(body) + self.assertEqual(const, 6) + self.assertEqual( + ans, + [ + 'for x5 in x4:', + ' linear_indices.append(x1[x5])', + ' linear_data.append(1)', + ' linear_indices.append(x3[x5])', + ' linear_data.append(1)', + ], + ) + + m.p = Param(initialize=2, mutable=True) + + @m.Constraint(m.x.index_set()) + def e(m, i): + return sum(m.x[i] + m.p + m.y[i] for i in m.x) >= 0 + + self.assertTrue(hasattr(m.e[1], 'template_expr')) + lb, body, ub = m.e[1].to_bounded_expression() + self.assertEqual(lb, 0) + self.assertEqual(ub, None) + + ans, const = self._build_evaluator(body) + self.assertEqual(const, 6) + self.assertEqual( + ans, + [ + 'for x6 in x4:', + ' linear_indices.append(x1[x6])', + ' linear_data.append(1)', + ' linear_indices.append(x3[x6])', + ' linear_data.append(1)', + ], + ) From 2c7d772ea97b71aa90df1c4763e6bfaaa0f93769 Mon Sep 17 00:00:00 2001 From: John Siirola Date: Fri, 19 Sep 2025 05:12:06 -0600 Subject: [PATCH 08/36] bugfix: converting LinearTemplateRepn to str --- pyomo/repn/linear_template.py | 33 +++++++++++++++++------- pyomo/repn/tests/test_linear.py | 16 ++++++++++++ pyomo/repn/tests/test_linear_template.py | 29 +++++++++++++++++++++ pyomo/repn/util.py | 4 +-- 4 files changed, 71 insertions(+), 11 deletions(-) diff --git a/pyomo/repn/linear_template.py b/pyomo/repn/linear_template.py index fcce8b850c2..ff0c3cd6138 100644 --- a/pyomo/repn/linear_template.py +++ b/pyomo/repn/linear_template.py @@ -17,14 +17,14 @@ import pyomo.core.expr as expr import pyomo.repn.linear as linear -import pyomo.repn.util as util from pyomo.core.expr import ExpressionType from pyomo.repn.linear import LinearRepn +from pyomo.repn.util import ExprType, initialize_exit_node_dispatcher, val2str -_CONSTANT = util.ExprType.CONSTANT -_VARIABLE = util.ExprType.VARIABLE -_LINEAR = util.ExprType.LINEAR +_CONSTANT = ExprType.CONSTANT +_VARIABLE = ExprType.VARIABLE +_LINEAR = ExprType.LINEAR code_type = deepcopy.__class__ @@ -39,14 +39,29 @@ def __init__(self): def __str__(self): linear = ( "{" - + ", ".join(f"{_str(k)}: {_str(v)}" for k, v in self.linear.items()) + + ", ".join(f"{val2str(k)}: {val2str(v)}" for k, v in self.linear.items()) + "}" ) + linear_sum = [] + for subrepn, subind, subsets in self.linear_sum: + linear_sum.append( + val2str(subrepn) + + ", [" + + ', '.join( + ("(" + ', '.join(val2str(j) for j in i) + ")") for i in subind + ) + + "], [" + + ', '.join( + ("(" + ', '.join(val2str(j) for j in i) + ")") for i in subsets + ) + + "]" + ) + linear_sum = '[' + ', '.join(linear_sum) + ']' return ( - f"{self.__class__.__name__}(mult={_str(self.multiplier)}, " - f"const={_str(self.constant)}, " + f"{self.__class__.__name__}(mult={val2str(self.multiplier)}, " + f"const={val2str(self.constant)}, " f"linear={linear}, " - f"linear_sum={self.linear_sum}, " + f"linear_sum={linear_sum}, " f"nonlinear={self.nonlinear})" ) @@ -311,7 +326,7 @@ class LinearTemplateRepnVisitor(linear.LinearRepnVisitor): Result = LinearTemplateRepn before_child_dispatcher = LinearTemplateBeforeChildDispatcher() exit_node_dispatcher = linear.ExitNodeDispatcher( - util.initialize_exit_node_dispatcher(define_exit_node_handlers()) + initialize_exit_node_dispatcher(define_exit_node_handlers()) ) def __init__(self, subexpression_cache, var_recorder, remove_fixed_vars=False): diff --git a/pyomo/repn/tests/test_linear.py b/pyomo/repn/tests/test_linear.py index 45703ae1366..7f86d40f1fd 100644 --- a/pyomo/repn/tests/test_linear.py +++ b/pyomo/repn/tests/test_linear.py @@ -67,6 +67,22 @@ def sum_sq(args, fixed, fgh): class TestLinear(unittest.TestCase): + def test_repn_to_string(self): + m = ConcreteModel() + m.x = Var(range(3)) + m.p = Param(initialize=5) + + cfg = VisitorConfig() + repn = LinearRepnVisitor(**cfg).walk_expression( + m.p * m.x[0] + m.x[1] + m.x[2] ** 2 + 5 + ) + + self.assertEqual( + str(repn), + "LinearRepn(mult=1, const=5, linear={%s: 5, %s: 1}, nonlinear=x[2]**2)" + % (id(m.x[0]), id(m.x[1])), + ) + def test_finalize(self): m = ConcreteModel() m.x = Var() diff --git a/pyomo/repn/tests/test_linear_template.py b/pyomo/repn/tests/test_linear_template.py index ee7d7e59532..b3ecf9ab889 100644 --- a/pyomo/repn/tests/test_linear_template.py +++ b/pyomo/repn/tests/test_linear_template.py @@ -41,6 +41,35 @@ def _build_evaluator(self, expr): self.visitor.symbolmap, self.visitor.expr_cache, 1, 1, False ) + def test_repn_to_string(self): + m = ConcreteModel() + m.x = Var(range(3)) + m.p = Param(range(3), initialize={0: 5}, mutable=True, default=1) + + e = m.p[0] * m.x[0] + m.x[1] + 10 + + repn = self.visitor.walk_expression(e) + self.assertEqual( + str(repn), + "LinearTemplateRepn(mult=1, const=10, linear={0: 5, 1: 1}, " + "linear_sum=[], nonlinear=None)", + ) + + @m.Objective() + def obj(m): + return sum(m.p[i] * m.x[i] for i in m.p.index_set()) + + e = m.obj.template_expr()[0] + repn = self.visitor.walk_expression(e) + self.assertEqual( + str(repn), + "LinearTemplateRepn(mult=1, const=0, linear={}, " + "linear_sum=[LinearTemplateRepn(mult=p[_1], const=0, " + "linear={%s: 1}, linear_sum=[], nonlinear=None), " + "[(_1)], [(0, 1, 2)]], nonlinear=None)" + % (list(self.visitor.expr_cache)[-1],), + ) + def test_no_indirection(self): m = ConcreteModel() m.x = Var() diff --git a/pyomo/repn/util.py b/pyomo/repn/util.py index ecaa8ce91ea..e1a7ab31635 100644 --- a/pyomo/repn/util.py +++ b/pyomo/repn/util.py @@ -131,10 +131,10 @@ def val2str(val): raised by :py:meth:`InvalidNumber.__str__`. """ - if hasattr(val, '_str'): - return val._str() if hasattr(val, 'to_string'): return val.to_string() + if hasattr(val, '_str'): + return val._str() return repr(val) From 90a0d22bed38458a2de59319df4e9e7895ff6a56 Mon Sep 17 00:00:00 2001 From: John Siirola Date: Fri, 19 Sep 2025 10:05:22 -0600 Subject: [PATCH 09/36] Improve specificity of raised exceptions --- pyomo/common/errors.py | 4 +++- pyomo/repn/linear_template.py | 4 ++++ pyomo/repn/plugins/lp_writer.py | 3 ++- pyomo/repn/plugins/standard_form.py | 3 ++- 4 files changed, 11 insertions(+), 3 deletions(-) diff --git a/pyomo/common/errors.py b/pyomo/common/errors.py index 9db1c0c05d4..b49ac2dd7f9 100644 --- a/pyomo/common/errors.py +++ b/pyomo/common/errors.py @@ -165,7 +165,7 @@ class InfeasibleConstraintException(PyomoException): class InvalidConstraintError(PyomoException, ValueError): - """Exception raised when invalid constraints are identified. + """Exception raised for constraints that cannot be represented or emitted. Pyomo will raise this exception when: @@ -173,6 +173,8 @@ class InvalidConstraintError(PyomoException, ValueError): - Creating a constraint from an incorrectly structured tuple. - Compiling a ranged constraint (``lb <= body <= ub``) where either ``lb`` or ``ub`` are variable expressions. + - Compiling a constraint that cannot be expressed / written in the + target format or interface. """ diff --git a/pyomo/repn/linear_template.py b/pyomo/repn/linear_template.py index ff0c3cd6138..bdfe98bb8f5 100644 --- a/pyomo/repn/linear_template.py +++ b/pyomo/repn/linear_template.py @@ -113,6 +113,10 @@ def append(self, other): def _build_evaluator( self, smap, expr_cache, multiplier, repetitions, remove_fixed_vars ): + if self.nonlinear is not None: + raise InvalidConstraintError( + "LinearTemplateRepn cannot build an evaluator for template " + "constraints containing general nonlinear terms") ans = [] multiplier *= self.multiplier constant = self.constant diff --git a/pyomo/repn/plugins/lp_writer.py b/pyomo/repn/plugins/lp_writer.py index 31ae37d7a3a..8685e682c33 100644 --- a/pyomo/repn/plugins/lp_writer.py +++ b/pyomo/repn/plugins/lp_writer.py @@ -20,6 +20,7 @@ document_kwargs_from_configdict, ) from pyomo.common.deprecation import deprecation_warning +from pyomo.common.errors import InvalidConstraintError from pyomo.common.gc_manager import PauseGC from pyomo.common.timing import TicTocTimer @@ -423,7 +424,7 @@ def write(self, model): continue repn = constraint_visitor.walk_expression(body) if repn.nonlinear is not None: - raise ValueError( + raise InvalidConstraintError( f"Model constraint ({con.name}) contains nonlinear terms that " "cannot be written to LP format" ) diff --git a/pyomo/repn/plugins/standard_form.py b/pyomo/repn/plugins/standard_form.py index dd61d937050..2997a0dcd7e 100644 --- a/pyomo/repn/plugins/standard_form.py +++ b/pyomo/repn/plugins/standard_form.py @@ -22,6 +22,7 @@ ) from pyomo.common.dependencies import scipy, numpy as np from pyomo.common.enums import ObjectiveSense +from pyomo.common.errors import InvalidConstraintError from pyomo.common.gc_manager import PauseGC from pyomo.common.numeric_types import native_types, value from pyomo.common.timing import TicTocTimer @@ -440,7 +441,7 @@ def write(self, model): ub = value(ub) repn = visitor.walk_expression(body) if repn.nonlinear is not None: - raise ValueError( + raise InvalidConstraintError( f"Model constraint ({con.name}) contains nonlinear terms that " "cannot be compiled to standard (linear) form." ) From 9f65886495bf8148b11f800a0fb2c5bfdf3e966a Mon Sep 17 00:00:00 2001 From: John Siirola Date: Sat, 20 Sep 2025 20:13:00 -0600 Subject: [PATCH 10/36] Fix filter for 0 multipliers/coefs --- pyomo/repn/linear_template.py | 59 ++++++++++++++++++----------------- 1 file changed, 31 insertions(+), 28 deletions(-) diff --git a/pyomo/repn/linear_template.py b/pyomo/repn/linear_template.py index bdfe98bb8f5..e391557615f 100644 --- a/pyomo/repn/linear_template.py +++ b/pyomo/repn/linear_template.py @@ -116,7 +116,8 @@ def _build_evaluator( if self.nonlinear is not None: raise InvalidConstraintError( "LinearTemplateRepn cannot build an evaluator for template " - "constraints containing general nonlinear terms") + "constraints containing general nonlinear terms" + ) ans = [] multiplier *= self.multiplier constant = self.constant @@ -127,33 +128,35 @@ def _build_evaluator( constant = 0 else: constant *= repetitions - for k, coef in list(self.linear.items()): - coef *= multiplier - if coef.__class__ not in native_types: - coef = coef.to_string(smap=smap) - elif coef: - coef = repr(coef) - else: - continue - - indent = '' - if k in expr_cache: - k = expr_cache[k] - if k.__class__ not in native_types: - # Directly substitute the expression into the - # 'linear[vid] = coef' below - k = k.to_string(smap=smap) - # If we are removing fixed vars, add that logic here: - if remove_fixed_vars: - ans.append(f"v = {k}") - ans.append('if v.__class__ is tuple:') - ans.append(' const += v[0] * {coef}') - ans.append(' v = None') - ans.append('else:') - k = 'v' - indent = ' ' - ans.append(indent + f'linear_indices.append({k})') - ans.append(indent + f'linear_data.append({coef})') + if multiplier.__class__ not in native_types or multiplier: + for k, coef in list(self.linear.items()): + coef *= multiplier + if coef.__class__ not in native_types: + coef = coef.to_string(smap=smap) + else: + # Note that coef should never be trivially 0 (the + # visitor should remove most of those), so there is + # no reason to check and skip this term... + coef = repr(coef) + + indent = '' + if k in expr_cache: + k = expr_cache[k] + if k.__class__ not in native_types: + # Directly substitute the expression into the + # 'linear[vid] = coef' below + k = k.to_string(smap=smap) + # If we are removing fixed vars, add that logic here: + if remove_fixed_vars: + ans.append(f"v = {k}") + ans.append('if v.__class__ is tuple:') + ans.append(' const += v[0] * {coef}') + ans.append(' v = None') + ans.append('else:') + k = 'v' + indent = ' ' + ans.append(indent + f'linear_indices.append({k})') + ans.append(indent + f'linear_data.append({coef})') for subrepn, subindices, subsets in self.linear_sum: ans.extend( From 52a78ac24a4200713ef7a665bae62cb90c10c217 Mon Sep 17 00:00:00 2001 From: John Siirola Date: Sun, 21 Sep 2025 02:48:11 -0600 Subject: [PATCH 11/36] Correctly handle empty subexpressions (e.g., from 0 multipliers) --- pyomo/repn/linear_template.py | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/pyomo/repn/linear_template.py b/pyomo/repn/linear_template.py index e391557615f..844c0e7f532 100644 --- a/pyomo/repn/linear_template.py +++ b/pyomo/repn/linear_template.py @@ -159,17 +159,6 @@ def _build_evaluator( ans.append(indent + f'linear_data.append({coef})') for subrepn, subindices, subsets in self.linear_sum: - ans.extend( - ' ' * i - + f"for {','.join(smap.getSymbol(i) for i in _idx)} in " - + ( - _set.to_string(smap=smap) - if _set.is_expression_type() - else smap.getSymbol(_set) - ) - + ":" - for i, (_idx, _set) in enumerate(zip(subindices, subsets)) - ) try: subrep = 1 for _set in subsets: @@ -179,8 +168,20 @@ def _build_evaluator( subans, subconst = subrepn._build_evaluator( smap, expr_cache, multiplier, repetitions * subrep, remove_fixed_vars ) - indent = ' ' * (len(subsets)) - ans.extend(indent + line for line in subans) + if subans: + ans.extend( + ' ' * i + + f"for {','.join(smap.getSymbol(i) for i in _idx)} in " + + ( + _set.to_string(smap=smap) + if _set.is_expression_type() + else smap.getSymbol(_set) + ) + + ":" + for i, (_idx, _set) in enumerate(zip(subindices, subsets)) + ) + indent = ' ' * (len(subsets)) + ans.extend(indent + line for line in subans) constant += subconst return ans, constant From bb4dd685c569330a55283531490626c065457070 Mon Sep 17 00:00:00 2001 From: John Siirola Date: Mon, 22 Sep 2025 07:48:31 -0600 Subject: [PATCH 12/36] Explicitly disallow nonlinear expressions in the LinearTemplateRepn --- pyomo/repn/linear_template.py | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/pyomo/repn/linear_template.py b/pyomo/repn/linear_template.py index 844c0e7f532..a654b7966c8 100644 --- a/pyomo/repn/linear_template.py +++ b/pyomo/repn/linear_template.py @@ -12,7 +12,7 @@ from itertools import chain from pyomo.common.collections import ComponentSet -from pyomo.common.errors import MouseTrap +from pyomo.common.errors import InvalidConstraintError, MouseTrap from pyomo.common.numeric_types import native_types, native_numeric_types import pyomo.core.expr as expr @@ -78,9 +78,8 @@ def multiplier_flag(val): return 2 # something not 0 or 1 def walker_exitNode(self): - if self.nonlinear is not None: - return _GENERAL, self - elif self.linear or self.linear_sum: + assert self.nonlinear is None + if self.linear or self.linear_sum: return _LINEAR, self else: return _CONSTANT, self.multiplier * self.constant @@ -90,6 +89,16 @@ def duplicate(self): ans.linear_sum = [(r[0].duplicate(),) + r[1:] for r in self.linear_sum] return ans + def to_expression(self, visitor): + # to_expression() is only used by the underlying + # LinearRepnVisitor to generate nonlinear expressions. We are + # explicitly disallowing nonlinear expressions here, so we are + # going to bail now: + raise InvalidConstraintError( + "LinearTemplateRepn does not support constraints containing " + "general nonlinear terms." + ) + def append(self, other): """Append a child result from StreamBasedExpressionVisitor.acceptChildResult() @@ -113,11 +122,7 @@ def append(self, other): def _build_evaluator( self, smap, expr_cache, multiplier, repetitions, remove_fixed_vars ): - if self.nonlinear is not None: - raise InvalidConstraintError( - "LinearTemplateRepn cannot build an evaluator for template " - "constraints containing general nonlinear terms" - ) + assert self.nonlinear is None ans = [] multiplier *= self.multiplier constant = self.constant From e343628bffb36f99377e7c6ee913e6b0ab108fbb Mon Sep 17 00:00:00 2001 From: John Siirola Date: Fri, 7 Nov 2025 15:46:10 -0700 Subject: [PATCH 13/36] Use SetOf when iterating over non-Set indexing sets --- pyomo/core/base/indexed_component.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/pyomo/core/base/indexed_component.py b/pyomo/core/base/indexed_component.py index f740dd588c1..0573894adbd 100644 --- a/pyomo/core/base/indexed_component.py +++ b/pyomo/core/base/indexed_component.py @@ -462,13 +462,16 @@ def keys(self, sort=SortComponents.UNSORTED, ordered=NOTSET): # of the underlying Set, there should be no warning if the # user iterates over the set when the _data dict is empty. # - if ( - SortComponents.SORTED_INDICES in sort - or SortComponents.ORDERED_INDICES in sort - ): - return iter(sorted_robust(self._data)) + # We will leverage SetOf here so we will cleanly pick up the + # iter overrides when we are templatizing + # + tmp_set = BASE.set.FiniteSetOf(self._data, name=self.name + "._data") + if SortComponents.SORTED_INDICES in sort: + return tmp_set.sorted_iter() + elif SortComponents.ORDERED_INDICES in sort: + return tmp_set.ordered_iter() else: - return self._data.__iter__() + return iter(tmp_set) if SortComponents.SORTED_INDICES in sort: ans = self._index_set.sorted_iter() From 21a07cf2067141b45a7080ab5d9be0920e85c89e Mon Sep 17 00:00:00 2001 From: John Siirola Date: Fri, 7 Nov 2025 15:52:11 -0700 Subject: [PATCH 14/36] Rework extract_values to use defaultdict for Params with default values --- pyomo/core/base/param.py | 52 +++++++++++++++++++++++++--------------- 1 file changed, 33 insertions(+), 19 deletions(-) diff --git a/pyomo/core/base/param.py b/pyomo/core/base/param.py index 9154d854efc..8c8b8ce8072 100644 --- a/pyomo/core/base/param.py +++ b/pyomo/core/base/param.py @@ -10,6 +10,7 @@ # ___________________________________________________________________________ from __future__ import annotations +import collections import sys import types import logging @@ -465,25 +466,33 @@ def extract_values(self): repeated __getitem__ calls are too expensive to extract the contents of a parameter. """ + if not self.is_indexed(): + # + # The parameter is a scalar, so we need to create a temporary + # dictionary using the value for this parameter. + # + return {None: self()} if self._mutable: # # The parameter is mutable, parameter data are ParamData types. # Thus, we need to create a temporary dictionary that contains the # values from the ParamData objects. # - return {key: param_value() for key, param_value in self.items()} - elif not self.is_indexed(): - # - # The parameter is a scalar, so we need to create a temporary - # dictionary using the value for this parameter. - # - return {None: self()} + ans = {key: param_value() for key, param_value in self.items()} else: # # The parameter is not mutable, so iteritems() can be # converted into a dictionary containing parameter values. # - return dict(self.items()) + ans = dict(self.items()) + + # We need to fill-in the "missing" values with the declared default + # + # TBD [11/2025]: should we declare __missing__ so we can still + # validate the index for any missing values? + if self._default_val is not Param.NoValue and not self._index_set.isfinite(): + ans = collections.defaultdict(lambda: self._default_val, ans) + return ans def extract_values_sparse(self): """ @@ -494,28 +503,33 @@ def extract_values_sparse(self): repeated __getitem__ calls are too expensive to extract the contents of a parameter. """ + if not self.is_indexed(): + # + # The parameter is a scalar, so we need to create a temporary + # dictionary using the value for this parameter. + # + return {None: self()} if self._mutable: # # The parameter is mutable, parameter data are ParamData types. # Thus, we need to create a temporary dictionary that contains the # values from the ParamData objects. # - ans = {} - for key, param_value in self.sparse_iteritems(): - ans[key] = param_value() - return ans - elif not self.is_indexed(): - # - # The parameter is a scalar, so we need to create a temporary - # dictionary using the value for this parameter. - # - return {None: self()} + ans = {key: param_value() for key, param_value in self.sparse_iteritems()} else: # # The parameter is not mutable, so sparse_iteritems() can be # converted into a dictionary containing parameter values. # - return dict(self.sparse_iteritems()) + ans = dict(self.sparse_iteritems()) + + # We need to fill-in the "missing" values with the declared default + # + # TBD [11/2025]: should we declare __missing__ so we can still + # validate the index for any missing values? + if self._default_val is not Param.NoValue: + ans = collections.defaultdict(lambda: self._default_val, ans) + return ans def store_values(self, new_values, check=True): """ From cdeaa7cf509d543c0bfabe32f89a27e99364ed4a Mon Sep 17 00:00:00 2001 From: John Siirola Date: Fri, 7 Nov 2025 15:58:05 -0700 Subject: [PATCH 15/36] SetOf.dimen updates: - iterate over the underlying reference when guessing the dimen This prevents recursion when templatizing. - inconsistent dimen is reported as None --- pyomo/core/base/set.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyomo/core/base/set.py b/pyomo/core/base/set.py index 29cb5ddb41e..189d49b9efd 100644 --- a/pyomo/core/base/set.py +++ b/pyomo/core/base/set.py @@ -2627,7 +2627,7 @@ def construct(self, data=None): def dimen(self): if isinstance(self._ref, SetData): return self._ref.dimen - _iter = iter(self) + _iter = iter(self._ref) try: x = next(_iter) if type(x) is tuple: @@ -2635,7 +2635,7 @@ def dimen(self): else: ans = 1 except: - return 0 + return None for x in _iter: _this = len(x) if type(x) is tuple else 1 if _this != ans: From fad7cb718c3cdb752048c32d2b4b3f56d164ebe3 Mon Sep 17 00:00:00 2001 From: John Siirola Date: Fri, 7 Nov 2025 16:19:08 -0700 Subject: [PATCH 16/36] Ensure we only attempt to templatize generators --- pyomo/core/expr/template_expr.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/pyomo/core/expr/template_expr.py b/pyomo/core/expr/template_expr.py index ddbbaab82ca..5872e54caec 100644 --- a/pyomo/core/expr/template_expr.py +++ b/pyomo/core/expr/template_expr.py @@ -52,6 +52,10 @@ logger = logging.getLogger(__name__) +# it is not clear what to import to get to the built-in "generator" +# type. We will just create a generator and query its __class__ +generator_like_types = {(_ for _ in ()).__class__, map} + class _NotSpecified(object): pass @@ -1079,6 +1083,10 @@ def next_group(self): return self._group def sum_template(self, generator): + if generator.__class__ not in generator_like_types: + raise TemplateExpressionError( + "Cannot generate templates of sums over non-generators" + ) init_cache = len(self.cache) expr = next(generator) final_cache = len(self.cache) From e2d608173341cc97a20df96e10f01a119488f390 Mon Sep 17 00:00:00 2001 From: John Siirola Date: Fri, 7 Nov 2025 16:20:06 -0700 Subject: [PATCH 17/36] Expand summations that do not involve IndexTemplates --- pyomo/core/expr/template_expr.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/pyomo/core/expr/template_expr.py b/pyomo/core/expr/template_expr.py index 5872e54caec..53f58666061 100644 --- a/pyomo/core/expr/template_expr.py +++ b/pyomo/core/expr/template_expr.py @@ -1090,7 +1090,10 @@ def sum_template(self, generator): init_cache = len(self.cache) expr = next(generator) final_cache = len(self.cache) - return TemplateSumExpression((expr,), self.npop_cache(final_cache - init_cache)) + if init_cache == final_cache: + return _TemplateIterManager.builtin_sum(generator, start=expr) + iters = self.npop_cache(final_cache - init_cache) + return TemplateSumExpression((expr,), iters) class _template_iter_manager(object): From 55b1252f2ffe1e7d4c07643fd4a1192e36b0e3f4 Mon Sep 17 00:00:00 2001 From: John Siirola Date: Fri, 7 Nov 2025 16:26:56 -0700 Subject: [PATCH 18/36] Improve error reporting --- pyomo/core/expr/template_expr.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/pyomo/core/expr/template_expr.py b/pyomo/core/expr/template_expr.py index 53f58666061..073e3b9d19e 100644 --- a/pyomo/core/expr/template_expr.py +++ b/pyomo/core/expr/template_expr.py @@ -1213,7 +1213,11 @@ def templatize_rule(block, rule, index_set): if internal_error is not None: logger.error( "The following exception was raised when " - "templatizing the rule '%s':\n\t%s" % (rule.name, internal_error[1]) + "templatizing the rule '%s':\n\t%s" + % ( + getattr(rule, '_fcn', rule.__class__).__name__, + internal_error[1], + ) ) raise TemplateExpressionError( None, From 585a7b49b7064da9609ba4c2b337cbd3ddb170e6 Mon Sep 17 00:00:00 2001 From: John Siirola Date: Fri, 7 Nov 2025 16:41:13 -0700 Subject: [PATCH 19/36] Rework linear_template to enable better unit testing --- pyomo/repn/linear_template.py | 49 +++++++++++++++++++++++++---------- 1 file changed, 35 insertions(+), 14 deletions(-) diff --git a/pyomo/repn/linear_template.py b/pyomo/repn/linear_template.py index a654b7966c8..f0950bc1faf 100644 --- a/pyomo/repn/linear_template.py +++ b/pyomo/repn/linear_template.py @@ -8,6 +8,7 @@ # rights in this software. # This software is distributed under the 3-clause BSD License. # ___________________________________________________________________________ + from copy import deepcopy from itertools import chain @@ -190,35 +191,55 @@ def _build_evaluator( constant += subconst return ans, constant - def compile(self, env, smap, expr_cache, args, remove_fixed_vars=False): + def _build_evaluator_fcn(self, args, smap, expr_cache, remove_fixed_vars): ans, constant = self._build_evaluator(smap, expr_cache, 1, 1, remove_fixed_vars) if not ans: - return constant + return lambda _ind, _dat, *_args: constant, None indent = '\n ' if not constant and ans and ans[0].startswith('const +='): # Convert initial "const +=" to "const =" - ans[0] = ''.join(ans[0].split('+', 1)) + const_init = ''.join(ans[0].split('+', 1)) + fcn_body = indent.join(ans[1:]) else: - ans.insert(0, 'const = ' + repr(constant)) - fcn_body = indent.join(ans[1:]) + const_init = 'const = ' + repr(constant) + fcn_body = indent.join(ans) if 'const' not in fcn_body: # No constants in the expression. Move the initial const # term to the return value and avoid declaring the local # variable - ans = ['return ' + ans[0].split('=', 1)[1]] - if fcn_body: - ans.insert(0, fcn_body) + ret = 'return ' + const_init.split('=', 1)[1].strip() + const_init = None else: - ans = [ans[0], fcn_body, 'return const'] - ans.insert( - 0, f"def build_expr(linear_indices, linear_data, {', '.join(args)}):" + ret = 'return const' + + fname = 'build_expr' + args = ', '.join(args) + return ( + indent.join( + filter( + None, + ( + f"def {fname}(linear_indices, linear_data, {args}):", + const_init, + fcn_body, + ret, + ), + ) + ), + fname, ) - ans = indent.join(ans) + + def compile(self, env, smap, expr_cache, args, remove_fixed_vars=False): # build the function in the env namespace, then remove and # return the compiled function. The function's globals will # still be bound to env - exec(ans, env) - return env.pop('build_expr') + fcn, fcn_name = self._build_evaluator_fcn( + args, smap, expr_cache, remove_fixed_vars + ) + if not fcn_name: + return fcn + exec(fcn, env) + return env.pop(fcn_name) class LinearTemplateBeforeChildDispatcher(linear.LinearBeforeChildDispatcher): From b6bf6b48fd51c76f7fe365d53724a7663e63c9cc Mon Sep 17 00:00:00 2001 From: John Siirola Date: Fri, 7 Nov 2025 16:51:22 -0700 Subject: [PATCH 20/36] Resolve handling of fixed variables in bounds and skipped constraints --- pyomo/repn/linear_template.py | 49 ++++++++++++++++++++++++++++++----- 1 file changed, 42 insertions(+), 7 deletions(-) diff --git a/pyomo/repn/linear_template.py b/pyomo/repn/linear_template.py index f0950bc1faf..d3de5d78b0d 100644 --- a/pyomo/repn/linear_template.py +++ b/pyomo/repn/linear_template.py @@ -19,6 +19,7 @@ import pyomo.core.expr as expr import pyomo.repn.linear as linear +from pyomo.core.base.indexed_component import IndexedComponent from pyomo.core.expr import ExpressionType from pyomo.repn.linear import LinearRepn from pyomo.repn.util import ExprType, initialize_exit_node_dispatcher, val2str @@ -391,8 +392,22 @@ def expand_expression(self, obj, template_info): smap = self.symbolmap expr, indices = template_info args = [smap.getSymbol(i) for i in indices] - if expr.is_expression_type(ExpressionType.RELATIONAL): - lb, body, ub = obj.to_bounded_expression() + if expr is IndexedComponent.Skip: + body = lambda i, c, *ind: 0 + lb = ub = None + elif expr.is_expression_type(ExpressionType.RELATIONAL): + try: + lb, body, ub = obj.to_bounded_expression() + except InvalidConstraintError: + # Ignore the variable lower/upper bound error (for + # now). We will check later that the individual + # bounds contain no non-fixed linear terms. + # + # Note: the only way to get this exception is if the + # obj is a RangedExpression, so we know that there + # will be 3 args (and this will explicitly fail if + # that is not the case) + lb, body, ub = expr.args if body is not None: body = self.walk_expression(body).compile( env, smap, self.expr_cache, args, False @@ -405,13 +420,11 @@ def expand_expression(self, obj, template_info): ub = self.walk_expression(ub).compile( env, smap, self.expr_cache, args, True ) - elif expr is not None: + else: lb = ub = None body = self.walk_expression(expr).compile( env, smap, self.expr_cache, args, False ) - else: - body = lb = ub = None self.expanded_templates[id(template_info)] = body, lb, ub linear_indices = [] @@ -425,11 +438,33 @@ def expand_expression(self, obj, template_info): if lb.__class__ is code_type: lb = lb(linear_indices, linear_data, *index) if linear_indices: - raise RuntimeError(f"Constraint {obj} has non-fixed lower bound") + # Note that we will only get here for Ranged constraints + # with potentially variable bounds. + vl = self.var_recorder.var_list + for i, coef in zip(linear_indices, linear_data): + v = vl[i] + if not v.fixed: + raise RuntimeError( + f"Constraint {obj} has non-fixed lower bound" + ) + lb += v.value * coef + linear_indices = [] + linear_data = [] if ub.__class__ is code_type: ub = ub(linear_indices, linear_data, *index) if linear_indices: - raise RuntimeError(f"Constraint {obj} has non-fixed upper bound") + # Note that we will only get here for Ranged constraints + # with potentially variable bounds. + vl = self.var_recorder.var_list + for i, coef in zip(linear_indices, linear_data): + v = vl[i] + if not v.fixed: + raise RuntimeError( + f"Constraint {obj} has non-fixed upper bound" + ) + ub += v.value * coef + linear_indices = [] + linear_data = [] return ( body(linear_indices, linear_data, *index), linear_indices, From 72b4eea2ab84eaa07994d2204c4e60cd03fc552f Mon Sep 17 00:00:00 2001 From: John Siirola Date: Fri, 7 Nov 2025 17:05:31 -0700 Subject: [PATCH 21/36] Don't unnecessarily expand sparse Params --- pyomo/repn/linear_template.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyomo/repn/linear_template.py b/pyomo/repn/linear_template.py index d3de5d78b0d..66d4d9989c4 100644 --- a/pyomo/repn/linear_template.py +++ b/pyomo/repn/linear_template.py @@ -298,7 +298,7 @@ def _before_indexed_param(visitor, child): if child not in visitor.indexed_params: visitor.indexed_params.add(child) name = visitor.symbolmap.getSymbol(child) - visitor.env[name] = child.extract_values() + visitor.env[name] = child.extract_values_sparse() return False, (_CONSTANT, child) @staticmethod From d3a3a241a1cd48a38506f856ac6143ed48b83843 Mon Sep 17 00:00:00 2001 From: John Siirola Date: Fri, 7 Nov 2025 17:05:49 -0700 Subject: [PATCH 22/36] Improve error --- pyomo/repn/linear_template.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyomo/repn/linear_template.py b/pyomo/repn/linear_template.py index 66d4d9989c4..fcc58bd5311 100644 --- a/pyomo/repn/linear_template.py +++ b/pyomo/repn/linear_template.py @@ -343,7 +343,7 @@ def _handle_templatesum(visitor, node, comp, *args): ans.linear_sum.append((comp[1], node.template_iters(), [a[1] for a in args])) return _LINEAR, ans else: - raise DeveloperError() + raise DeveloperError(comp) def define_exit_node_handlers(_exit_node_handlers=None): From 6353b49df8d5ef71fbd4262ae2aa491b156764f2 Mon Sep 17 00:00:00 2001 From: John Siirola Date: Fri, 7 Nov 2025 17:13:46 -0700 Subject: [PATCH 23/36] Verify absence of bounds for objectives; update comment --- pyomo/repn/plugins/standard_form.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/pyomo/repn/plugins/standard_form.py b/pyomo/repn/plugins/standard_form.py index 2997a0dcd7e..e87a982b542 100644 --- a/pyomo/repn/plugins/standard_form.py +++ b/pyomo/repn/plugins/standard_form.py @@ -379,9 +379,10 @@ def write(self, model): obj_index_ptr = [0] for obj in objectives: if hasattr(obj, 'template_expr'): - offset, linear_index, linear_data, _, _ = ( + offset, linear_index, linear_data, lb, ub = ( template_visitor.expand_expression(obj, obj.template_expr()) ) + assert lb is None and ub is None N = len(linear_index) obj_index.append(linear_index) obj_data.append(linear_data) @@ -433,7 +434,8 @@ def write(self, model): ) N = len(linear_data) else: - # Note: lb and ub could be a number, expression, or None + # Note: lb and ub could be a number, expression, or None. + # Non-fixed expressions will raise an InvalidConstraintError. lb, body, ub = con.to_bounded_expression() if lb.__class__ not in native_types: lb = value(lb) From 3b2da5d8cb0294bbd1a509d72e725b666632e13e Mon Sep 17 00:00:00 2001 From: John Siirola Date: Fri, 7 Nov 2025 17:19:24 -0700 Subject: [PATCH 24/36] Add interface to get var_list for TemplateVarRecorder --- pyomo/repn/util.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/pyomo/repn/util.py b/pyomo/repn/util.py index e1a7ab31635..32ddad5a151 100644 --- a/pyomo/repn/util.py +++ b/pyomo/repn/util.py @@ -816,6 +816,7 @@ def __init__(self, var_map, var_order, sorter): self.var_map = var_map self.var_order = var_order self.sorter = sorter + assert len(var_map) == len(var_order) def add(self, var): # We always add all indices to the var_map at once so that @@ -842,6 +843,7 @@ class TemplateVarRecorder(object): def __init__(self, var_map, sorter): self.var_map = var_map self._var_order = None + self._var_list = None self.sorter = sorter self.env = {None: 0} self.symbolmap = EXPR.SymbolMap(NumericLabeler('x')) @@ -876,6 +878,12 @@ def var_order(self): self._var_order = {vid: i for i, vid in enumerate(self.var_map)} return self._var_order + @property + def var_list(self): + if self._var_list is None or len(self._var_list) != len(self.var_map): + self._var_list = list(self.var_map.values()) + return self._var_list + def add(self, var): # Note: the following is mostly a copy of # LinearBeforeChildDispatcher.record_var, but with extra From edbc4e102207f5c88e73f470fcd378511f56de6d Mon Sep 17 00:00:00 2001 From: John Siirola Date: Fri, 7 Nov 2025 17:20:50 -0700 Subject: [PATCH 25/36] Expand linear_template testing --- pyomo/repn/tests/test_linear_template.py | 890 ++++++++++++++++++++++- 1 file changed, 850 insertions(+), 40 deletions(-) diff --git a/pyomo/repn/tests/test_linear_template.py b/pyomo/repn/tests/test_linear_template.py index b3ecf9ab889..15539af1f7a 100644 --- a/pyomo/repn/tests/test_linear_template.py +++ b/pyomo/repn/tests/test_linear_template.py @@ -14,6 +14,7 @@ import pyomo.core.base.constraint as constraint import pyomo.core.base.objective as objective +from pyomo.common.errors import InvalidConstraintError from pyomo.core.base.enums import SortComponents from pyomo.repn.linear_template import LinearTemplateRepnVisitor from pyomo.repn.util import TemplateVarRecorder @@ -41,6 +42,15 @@ def _build_evaluator(self, expr): self.visitor.symbolmap, self.visitor.expr_cache, 1, 1, False ) + def _build_evaluator_fcn(self, expr, args): + repn = self.visitor.walk_expression(expr) + return repn._build_evaluator_fcn( + args, self.visitor.symbolmap, self.visitor.expr_cache, False + ) + + def _eval(self, obj): + return self.visitor.expand_expression(obj, obj.template_expr()) + def test_repn_to_string(self): m = ConcreteModel() m.x = Var(range(3)) @@ -85,7 +95,7 @@ def c(m): ans, const = self._build_evaluator(body) self.assertEqual(const, 0) - self.assertEqual(ans, ['linear_indices.append(0)', 'linear_data.append(1)']) + self.assertEqual(['linear_indices.append(0)', 'linear_data.append(1)'], ans) def test_single_var_no_loop(self): m = ConcreteModel() @@ -103,7 +113,7 @@ def c(m, i): ans, const = self._build_evaluator(body) self.assertEqual(const, 0) self.assertEqual( - ans, ['linear_indices.append(x1[x2])', 'linear_data.append(1)'] + ['linear_indices.append(x1[x2])', 'linear_data.append(1)'], ans ) @m.Constraint(m.x.index_set()) @@ -118,7 +128,7 @@ def d(m, i): ans, const = self._build_evaluator(body) self.assertEqual(const, 0) self.assertEqual( - ans, ['linear_indices.append(x1[x3])', 'linear_data.append(x3)'] + ['linear_indices.append(x1[x3])', 'linear_data.append(x3)'], ans ) def test_two_var_const_no_loop(self): @@ -138,13 +148,13 @@ def c(m, i): ans, const = self._build_evaluator(body) self.assertEqual(const, 10) self.assertEqual( - ans, [ 'linear_indices.append(x1[x2])', 'linear_data.append(1)', 'linear_indices.append(x3[x2 + 1])', 'linear_data.append(-1)', ], + ans, ) @m.Constraint(m.x.index_set()) @@ -159,7 +169,6 @@ def d(m, i): ans, const = self._build_evaluator(body) self.assertEqual(const, 0) self.assertEqual( - ans, [ 'const += x4', 'linear_indices.append(x1[x4])', @@ -167,95 +176,96 @@ def d(m, i): 'linear_indices.append(x3[x4 + 1])', 'linear_data.append(-1)', ], + ans, ) def test_sum_one_var(self): m = ConcreteModel() m.x = Var([1, 2, 3]) - @m.Constraint(m.x.index_set()) - def c(m, i): + @m.Constraint() + def c(m): return sum(m.x[i] for i in m.x) >= 0 - self.assertTrue(hasattr(m.c[1], 'template_expr')) - lb, body, ub = m.c[1].to_bounded_expression() + self.assertTrue(hasattr(m.c, 'template_expr')) + lb, body, ub = m.c.to_bounded_expression() self.assertEqual(lb, 0) self.assertEqual(ub, None) ans, const = self._build_evaluator(body) self.assertEqual(const, 0) self.assertEqual( - ans, [ 'for x2 in x3:', ' linear_indices.append(x1[x2])', ' linear_data.append(1)', ], + ans, ) - @m.Constraint(m.x.index_set()) - def d(m, i): + @m.Constraint() + def d(m): return sum(m.x[i] + 2 for i in m.x) >= 0 - self.assertTrue(hasattr(m.d[1], 'template_expr')) - lb, body, ub = m.d[1].to_bounded_expression() + self.assertTrue(hasattr(m.d, 'template_expr')) + lb, body, ub = m.d.to_bounded_expression() self.assertEqual(lb, 0) self.assertEqual(ub, None) ans, const = self._build_evaluator(body) self.assertEqual(const, 6) self.assertEqual( - ans, [ 'for x4 in x3:', ' linear_indices.append(x1[x4])', ' linear_data.append(1)', ], + ans, ) m.p = Param(initialize=2, mutable=True) - @m.Constraint(m.x.index_set()) - def e(m, i): + @m.Constraint() + def e(m): return sum(m.x[i] + m.p for i in m.x) >= 0 - self.assertTrue(hasattr(m.e[1], 'template_expr')) - lb, body, ub = m.e[1].to_bounded_expression() + self.assertTrue(hasattr(m.e, 'template_expr')) + lb, body, ub = m.e.to_bounded_expression() self.assertEqual(lb, 0) self.assertEqual(ub, None) ans, const = self._build_evaluator(body) self.assertEqual(const, 6) self.assertEqual( - ans, [ 'for x5 in x3:', ' linear_indices.append(x1[x5])', ' linear_data.append(1)', ], + ans, ) m.q = Param([1, 2, 3], initialize={1: 10, 2: 20, 3: 30}) - @m.Constraint(m.x.index_set()) - def e(m, i): + @m.Constraint() + def e(m): return sum(m.x[i] + m.q[i] for i in m.x) >= 0 - self.assertTrue(hasattr(m.e[1], 'template_expr')) - lb, body, ub = m.e[1].to_bounded_expression() + self.assertTrue(hasattr(m.e, 'template_expr')) + lb, body, ub = m.e.to_bounded_expression() self.assertEqual(lb, 0) self.assertEqual(ub, None) ans, const = self._build_evaluator(body) self.assertEqual(const, 0) self.assertEqual( - ans, [ 'for x6 in x3:', ' const += x7[x6]', ' linear_indices.append(x1[x6])', ' linear_data.append(1)', ], + ans, ) def test_sum_two_var(self): @@ -263,19 +273,18 @@ def test_sum_two_var(self): m.x = Var([1, 2, 3]) m.y = Var([1, 2, 3]) - @m.Constraint(m.x.index_set()) - def c(m, i): + @m.Constraint() + def c(m): return sum(m.x[i] + m.y[i] for i in m.x) >= 0 - self.assertTrue(hasattr(m.c[1], 'template_expr')) - lb, body, ub = m.c[1].to_bounded_expression() + self.assertTrue(hasattr(m.c, 'template_expr')) + lb, body, ub = m.c.to_bounded_expression() self.assertEqual(lb, 0) self.assertEqual(ub, None) ans, const = self._build_evaluator(body) self.assertEqual(const, 0) self.assertEqual( - ans, [ 'for x2 in x4:', ' linear_indices.append(x1[x2])', @@ -283,21 +292,21 @@ def c(m, i): ' linear_indices.append(x3[x2])', ' linear_data.append(1)', ], + ans, ) - @m.Constraint(m.x.index_set()) - def d(m, i): + @m.Constraint() + def d(m): return sum(m.x[i] + 2 + m.y[i] for i in m.x) >= 0 - self.assertTrue(hasattr(m.d[1], 'template_expr')) - lb, body, ub = m.d[1].to_bounded_expression() + self.assertTrue(hasattr(m.d, 'template_expr')) + lb, body, ub = m.d.to_bounded_expression() self.assertEqual(lb, 0) self.assertEqual(ub, None) ans, const = self._build_evaluator(body) self.assertEqual(const, 6) self.assertEqual( - ans, [ 'for x5 in x4:', ' linear_indices.append(x1[x5])', @@ -305,23 +314,23 @@ def d(m, i): ' linear_indices.append(x3[x5])', ' linear_data.append(1)', ], + ans, ) m.p = Param(initialize=2, mutable=True) - @m.Constraint(m.x.index_set()) - def e(m, i): + @m.Constraint() + def e(m): return sum(m.x[i] + m.p + m.y[i] for i in m.x) >= 0 - self.assertTrue(hasattr(m.e[1], 'template_expr')) - lb, body, ub = m.e[1].to_bounded_expression() + self.assertTrue(hasattr(m.e, 'template_expr')) + lb, body, ub = m.e.to_bounded_expression() self.assertEqual(lb, 0) self.assertEqual(ub, None) ans, const = self._build_evaluator(body) self.assertEqual(const, 6) self.assertEqual( - ans, [ 'for x6 in x4:', ' linear_indices.append(x1[x6])', @@ -329,4 +338,805 @@ def e(m, i): ' linear_indices.append(x3[x6])', ' linear_data.append(1)', ], + ans, + ) + + def test_sum_with_multiplier(self): + m = ConcreteModel() + m.w = Var([1, 2, 3]) + m.x = Var([1, 2, 3]) + m.y = Var([1, 2, 3]) + m.z = Var([1, 2, 3]) + + @m.Constraint() + def c(m): + return ( + sum(4 * (m.w[i] + 2 * m.x[i]) + 3 * (m.y[i] + 2 * m.z[i]) for i in m.x) + >= 0 + ) + + self.assertTrue(hasattr(m.c, 'template_expr')) + lb, body, ub = m.c.to_bounded_expression() + self.assertEqual(lb, 0) + self.assertEqual(ub, None) + + ans, const = self._build_evaluator(body) + self.assertEqual(const, 0) + self.assertEqual( + [ + 'for x2 in x6:', + ' linear_indices.append(x1[x2])', + ' linear_data.append(4)', + ' linear_indices.append(x3[x2])', + ' linear_data.append(8)', + ' linear_indices.append(x4[x2])', + ' linear_data.append(3)', + ' linear_indices.append(x5[x2])', + ' linear_data.append(6)', + ], + ans, + ) + + def test_nested_sum_with_multiplier(self): + m = ConcreteModel() + m.I = Set(initialize=[1, 2, 3]) + m.J = Set(initialize=[1, 2]) + m.x = Var([1, 2, 3], [1, 2]) + m.y = Var([1, 2, 3], [1, 2]) + + @m.Constraint() + def c(m): + return ( + sum( + 4 * sum(m.x[i, j] for j in m.J) + 3 * sum(m.y[i, j] for j in m.J) + for i in m.I + ) + >= 0 + ) + + self.assertTrue(hasattr(m.c, 'template_expr')) + lb, body, ub = m.c.to_bounded_expression() + self.assertEqual(lb, 0) + self.assertEqual(ub, None) + + ans, const = self._build_evaluator(body) + self.assertEqual(const, 0) + self.assertEqual( + [ + 'for x2 in x7:', + ' for x3 in x4:', + ' linear_indices.append(x1[x2,x3])', + ' linear_data.append(4)', + ' for x6 in x4:', + ' linear_indices.append(x5[x2,x6])', + ' linear_data.append(3)', + ], + ans, + ) + + @m.Constraint() + def d(m): + return ( + sum( + 4 * sum(j * m.x[i, j] for j in m.J) + + 3 * sum(i * m.y[i, j] for j in m.J) + for i in m.I + ) + >= 0 + ) + + self.assertTrue(hasattr(m.d, 'template_expr')) + lb, body, ub = m.d.to_bounded_expression() + self.assertEqual(lb, 0) + self.assertEqual(ub, None) + + ans, const = self._build_evaluator(body) + self.assertEqual(const, 0) + self.assertEqual( + [ + 'for x9 in x7:', + ' for x8 in x4:', + ' linear_indices.append(x1[x9,x8])', + ' linear_data.append(x8*4)', + ' for x10 in x4:', + ' linear_indices.append(x5[x9,x10])', + ' linear_data.append(x9*3)', + ], + ans, + ) + + def test_filter_0_coef(self): + m = ConcreteModel() + m.I = Set(initialize=[1, 2, 3]) + m.J = Set(initialize=[1, 2]) + m.x = Var([1, 2, 3], [1, 2]) + m.y = Var([1, 2, 3], [1, 2]) + m.p = Param(mutable=True, initialize=0) + + @m.Constraint() + def c(m): + return sum(m.x[i] + m.y[i] - m.x[i] - m.p * m.y[i] for i in m.x) >= 0 + + self.assertTrue(hasattr(m.c, 'template_expr')) + lb, body, ub = m.c.to_bounded_expression() + self.assertEqual(lb, 0) + self.assertEqual(ub, None) + + ans, const = self._build_evaluator(body) + self.assertEqual(const, 0) + self.assertEqual( + [ + 'for x2,x3 in x5:', + ' linear_indices.append(x1[x2,x3])', + ' linear_data.append(1)', + ' linear_indices.append(x4[x2,x3])', + ' linear_data.append(1)', + ' linear_indices.append(x1[x2,x3])', + ' linear_data.append(-1)', + ], + ans, + ) + + @m.Constraint() + def d(m): + return ( + sum( + 4 * sum(j * m.x[i, j] for j in m.J) + + 0 * sum(m.y[i, j] for j in m.J) + for i in m.I + ) + >= 0 + ) + + self.assertTrue(hasattr(m.d, 'template_expr')) + lb, body, ub = m.d.to_bounded_expression() + self.assertEqual(lb, 0) + self.assertEqual(ub, None) + + ans, const = self._build_evaluator(body) + self.assertEqual(const, 0) + self.assertEqual( + [ + 'for x7 in x10:', + ' for x6 in x8:', + ' linear_indices.append(x1[x7,x6])', + ' linear_data.append(x6*4)', + ], + ans, + ) + + @m.Constraint() + def e(m): + return ( + sum( + 4 * sum(j * m.x[i, j] for j in m.J) + + 0 * sum(i * m.y[i, j] for j in m.J) + for i in m.I + ) + >= 0 + ) + + self.assertTrue(hasattr(m.e, 'template_expr')) + lb, body, ub = m.e.to_bounded_expression() + self.assertEqual(lb, 0) + self.assertEqual(ub, None) + + ans, const = self._build_evaluator(body) + self.assertEqual(const, 0) + self.assertEqual( + [ + 'for x12 in x10:', + ' for x11 in x8:', + ' linear_indices.append(x1[x12,x11])', + ' linear_data.append(x11*4)', + ' for x13 in x8:', + ' linear_indices.append(x4[x12,x13])', + ' linear_data.append(x12*0)', + ], + ans, ) + + def test_iter_nonfinite_component(self): + m = ConcreteModel() + m.x = Var(NonNegativeIntegers) + m.p = Param(mutable=True, initialize=0) + m.x[1] = 1 + m.x[2] = 2 + + @m.Constraint() + def c(m): + return sum(m.p * m.x[i] for i in m.x) >= 0 + + self.assertTrue(hasattr(m.c, 'template_expr')) + lb, body, ub = m.c.to_bounded_expression() + self.assertEqual(lb, 0) + self.assertEqual(ub, None) + + ans, const = self._build_evaluator(body) + self.assertEqual(const, 0) + self.assertEqual([], ans) + + m.p = 1 + ans, const = self._build_evaluator(body) + self.assertEqual(const, 0) + self.assertEqual( + [ + 'for x2 in x3:', + ' linear_indices.append(x1[x2])', + ' linear_data.append(1)', + ], + ans, + ) + + def test_set_of_sets(self): + m = ConcreteModel() + m.I = Set(initialize=[1, 2]) + m.J = Set(m.I, initialize=lambda m, i: range(i)) + m.x = Var([1, 2], [0, 1]) + + @m.Constraint(m.I) + def c(m, i): + return sum(m.x[i, j] for j in m.J[i]) == 2 + + self.assertTrue(hasattr(m.c[1], 'template_expr')) + lb, body, ub = m.c[1].to_bounded_expression() + self.assertEqual(lb, 2) + self.assertEqual(ub, 2) + + ans, const = self._build_evaluator(body) + self.assertEqual(const, 0) + self.assertEqual( + [ + 'for x3 in x4[x2]:', + ' linear_indices.append(x1[x2,x3])', + ' linear_data.append(1)', + ], + ans, + ) + + def test_general_nonlinear(self): + m = ConcreteModel() + m.x = Var([1, 2, 3]) + m.y = Var([1, 2, 3]) + + @m.Constraint() + def c(m): + return sum(m.x[i] ** 2 for i in m.x) >= 0 + + self.assertTrue(hasattr(m.c, 'template_expr')) + lb, body, ub = m.c.to_bounded_expression() + self.assertEqual(lb, 0) + self.assertEqual(ub, None) + + with self.assertRaisesRegex( + InvalidConstraintError, + "LinearTemplateRepn does not support constraints containing " + "general nonlinear terms.", + ): + ans, const = self._build_evaluator(body) + + def test_monomial_expr(self): + m = ConcreteModel() + m.x = Var() + + @m.Constraint([1, 2, 3]) + def c(m, i): + return (0, 5 * m.x, i) + + self.assertTrue(hasattr(m.c[1], 'template_expr')) + lb, body, ub = m.c[2].to_bounded_expression() + self.assertEqual(lb, 0) + self.assertEqual(str(ub), '_1') + + ans, const = self._build_evaluator(body) + self.assertEqual(const, 0) + self.assertEqual(['linear_indices.append(0)', 'linear_data.append(5)'], ans) + + def test_fcn_no_sum_expr(self): + m = ConcreteModel() + m.x = Var([1, 2, 3]) + m.y = Var([1, 2, 3, 4]) + + @m.Constraint(m.x.index_set()) + def c(m, i): + return m.x[i] + 10 - m.y[i + 1] >= 0 + + self.assertTrue(hasattr(m.c[1], 'template_expr')) + lb, body, ub = m.c[1].to_bounded_expression() + self.assertEqual(lb, 0) + self.assertEqual(ub, None) + + fcn, fname = self._build_evaluator_fcn(body, ()) + self.assertEqual(fname, 'build_expr') + self.assertEqual( + '''def build_expr(linear_indices, linear_data, ): + linear_indices.append(x1[x2]) + linear_data.append(1) + linear_indices.append(x3[x2 + 1]) + linear_data.append(-1) + return 10''', + fcn, + ) + + def test_fcn_sum_expr_no_const(self): + m = ConcreteModel() + m.x = Var([1, 2, 3, 4]) + + @m.Constraint() + def c(m): + return sum(m.x[i] for i in m.x) >= 0 + + self.assertTrue(hasattr(m.c, 'template_expr')) + lb, body, ub = m.c.to_bounded_expression() + self.assertEqual(lb, 0) + self.assertEqual(ub, None) + + fcn, fname = self._build_evaluator_fcn(body, ()) + self.assertEqual(fname, 'build_expr') + self.assertEqual( + '''def build_expr(linear_indices, linear_data, ): + for x2 in x3: + linear_indices.append(x1[x2]) + linear_data.append(1) + return 0''', + fcn, + ) + + def test_fcn_sum_expr_const(self): + m = ConcreteModel() + m.x = Var([1, 2, 3, 4]) + + @m.Constraint() + def c(m): + return sum(m.x[i] + i for i in m.x) >= 0 + + self.assertTrue(hasattr(m.c, 'template_expr')) + lb, body, ub = m.c.to_bounded_expression() + self.assertEqual(lb, 0) + self.assertEqual(ub, None) + + fcn, fname = self._build_evaluator_fcn(body, ()) + self.assertEqual(fname, 'build_expr') + self.assertEqual( + '''def build_expr(linear_indices, linear_data, ): + const = 0 + for x2 in x3: + const += x2 + linear_indices.append(x1[x2]) + linear_data.append(1) + return const''', + fcn, + ) + + def test_fcn_const(self): + m = ConcreteModel() + m.x = Var() + m.x.fix(4) + + @m.Constraint() + def c(m): + return m.x + 5 >= 0 + + self.assertTrue(hasattr(m.c, 'template_expr')) + lb, body, ub = m.c.to_bounded_expression() + self.assertEqual(lb, 0) + self.assertEqual(ub, None) + + fcn, fname = self._build_evaluator_fcn(body, ()) + self.assertEqual(fname, None) + self.assertEqual(9, fcn(None, None)) + + def test_fcn_with_outer_const(self): + m = ConcreteModel() + m.I = Set(initialize=[1, 2]) + m.x = Var(m.I) + m.y = Var([1, 2, 3]) + m.p = Param(m.I, initialize=lambda m, i: i) + + @m.Constraint(m.I) + def c(m, i): + return 5 + m.x[i] + m.p[i] + sum(m.y.values()) >= 0 + + self.assertTrue(hasattr(m.c[1], 'template_expr')) + lb, body, ub = m.c[1].to_bounded_expression() + self.assertEqual(lb, 0) + self.assertEqual(ub, None) + + fcn, fname = self._build_evaluator_fcn(body, ()) + self.assertEqual(fname, 'build_expr') + self.assertEqual( + """def build_expr(linear_indices, linear_data, ): + linear_indices.append(x1[x2]) + linear_data.append(1) + for x5 in x6: + linear_indices.append(x4[x5]) + linear_data.append(1) + return 5 + x3[x2]""", + fcn, + ) + + def test_fcn_with_outer_and_inner_const(self): + m = ConcreteModel() + m.I = Set(initialize=[1, 2]) + m.x = Var(m.I) + m.y = Var([1, 2, 3]) + m.p = Param(m.I, initialize=lambda m, i: i) + + @m.Constraint(m.I) + def c(m, i): + return 5 + m.x[i] + m.p[i] + sum(m.y[j] + j for j in m.y) >= 0 + + self.assertTrue(hasattr(m.c[1], 'template_expr')) + lb, body, ub = m.c[1].to_bounded_expression() + self.assertEqual(lb, 0) + self.assertEqual(ub, None) + + fcn, fname = self._build_evaluator_fcn(body, ()) + self.assertEqual(fname, 'build_expr') + self.assertEqual( + """def build_expr(linear_indices, linear_data, ): + const = 5 + x3[x2] + linear_indices.append(x1[x2]) + linear_data.append(1) + for x5 in x6: + const += x5 + linear_indices.append(x4[x5]) + linear_data.append(1) + return const""", + fcn, + ) + + def test_fcn_with_nonfinite(self): + m = ConcreteModel() + m.x = Var(Any) + + @m.Constraint() + def c(m): + return 5 + sum(m.x.values()) >= 0 + + m.x[1] + m.x[3] + m.x[5] + + self.assertTrue(hasattr(m.c, 'template_expr')) + lb, body, ub = m.c.to_bounded_expression() + self.assertEqual(lb, 0) + self.assertEqual(ub, None) + + fcn, fname = self._build_evaluator_fcn(body, ()) + self.assertEqual(fname, 'build_expr') + self.assertEqual( + """def build_expr(linear_indices, linear_data, ): + for x2 in x3: + linear_indices.append(x1[x2]) + linear_data.append(1) + return 5""", + fcn, + ) + + def test_fcn_explicit_sum(self): + m = ConcreteModel() + m.x = Var([1, 2, 3]) + + @m.Constraint() + def c(m): + return sum(i * m.x[i] for i in [1, 2, 3]) >= 0 + + self.assertTrue(hasattr(m.c, 'template_expr')) + lb, body, ub = m.c.to_bounded_expression() + self.assertEqual(lb, 0) + self.assertEqual(ub, None) + + fcn, fname = self._build_evaluator_fcn(body, ()) + self.assertEqual(fname, 'build_expr') + self.assertEqual( + """def build_expr(linear_indices, linear_data, ): + linear_indices.append(0) + linear_data.append(1) + linear_indices.append(1) + linear_data.append(2) + linear_indices.append(2) + linear_data.append(3) + return 0""", + fcn, + ) + + def test_eval_no_sum_expr(self): + m = ConcreteModel() + m.x = Var([1, 2, 3]) + m.y = Var([1, 2, 3, 4]) + + @m.Constraint(m.x.index_set()) + def c(m, i): + return m.x[i] + 10 - m.y[i + 1] >= 0 + + const, var_list, coef_list, lb, ub = self._eval(m.c[1]) + self.assertEqual(const, 10) + self.assertEqual(var_list, [0, 4]) + self.assertEqual(coef_list, [1, -1]) + self.assertEqual(lb, 0) + self.assertEqual(ub, None) + const, var_list, coef_list, lb, ub = self._eval(m.c[2]) + self.assertEqual(const, 10) + self.assertEqual(var_list, [1, 5]) + self.assertEqual(coef_list, [1, -1]) + self.assertEqual(lb, 0) + self.assertEqual(ub, None) + + def test_eval_sum_expr_no_const(self): + m = ConcreteModel() + m.x = Var([1, 2, 3, 4]) + + @m.Constraint() + def c(m): + return sum(m.x[i] for i in m.x) >= 0 + + const, var_list, coef_list, lb, ub = self._eval(m.c) + self.assertEqual(const, 0) + self.assertEqual(var_list, [0, 1, 2, 3]) + self.assertEqual(coef_list, [1, 1, 1, 1]) + self.assertEqual(lb, 0) + self.assertEqual(ub, None) + + def test_eval_sum_expr_const(self): + m = ConcreteModel() + m.x = Var([1, 2, 3, 4]) + + @m.Constraint() + def c(m): + return sum(m.x[i] + i for i in m.x) >= 0 + + const, var_list, coef_list, lb, ub = self._eval(m.c) + self.assertEqual(const, 10) + self.assertEqual(var_list, [0, 1, 2, 3]) + self.assertEqual(coef_list, [1, 1, 1, 1]) + self.assertEqual(lb, 0) + self.assertEqual(ub, None) + + def test_eval_const(self): + m = ConcreteModel() + m.x = Var() + m.x.fix(4) + + @m.Constraint() + def c(m): + return m.x + 5 >= 0 + + const, var_list, coef_list, lb, ub = self._eval(m.c) + self.assertEqual(const, 9) + self.assertEqual(var_list, []) + self.assertEqual(coef_list, []) + self.assertEqual(lb, 0) + self.assertEqual(ub, None) + + def test_eval_with_outer_const(self): + m = ConcreteModel() + m.I = Set(initialize=[1, 2]) + m.x = Var(m.I) + m.y = Var([1, 2, 3]) + m.p = Param(m.I, initialize=lambda m, i: i) + + @m.Constraint(m.I) + def c(m, i): + return 5 + m.x[i] + m.p[i] + sum(m.y.values()) >= 0 + + const, var_list, coef_list, lb, ub = self._eval(m.c[1]) + self.assertEqual(const, 6) + self.assertEqual(var_list, [0, 2, 3, 4]) + self.assertEqual(coef_list, [1, 1, 1, 1]) + self.assertEqual(lb, 0) + self.assertEqual(ub, None) + const, var_list, coef_list, lb, ub = self._eval(m.c[2]) + self.assertEqual(const, 7) + self.assertEqual(var_list, [1, 2, 3, 4]) + self.assertEqual(coef_list, [1, 1, 1, 1]) + self.assertEqual(lb, 0) + self.assertEqual(ub, None) + + def test_eval_with_outer_and_inner_const(self): + m = ConcreteModel() + m.I = Set(initialize=[1, 2]) + m.x = Var(m.I) + m.y = Var([1, 2, 3]) + m.p = Param(m.I, initialize=lambda m, i: i) + + @m.Constraint(m.I) + def c(m, i): + return 5 + m.x[i] + m.p[i] + sum(m.y[j] + j for j in m.y) >= 0 + + const, var_list, coef_list, lb, ub = self._eval(m.c[1]) + self.assertEqual(const, 12) + self.assertEqual(var_list, [0, 2, 3, 4]) + self.assertEqual(coef_list, [1, 1, 1, 1]) + self.assertEqual(lb, 0) + self.assertEqual(ub, None) + const, var_list, coef_list, lb, ub = self._eval(m.c[2]) + self.assertEqual(const, 13) + self.assertEqual(var_list, [1, 2, 3, 4]) + self.assertEqual(coef_list, [1, 1, 1, 1]) + self.assertEqual(lb, 0) + self.assertEqual(ub, None) + + def test_eval_with_nonfinite(self): + m = ConcreteModel() + m.x = Var(Any) + + @m.Constraint() + def c(m): + return 5 + sum(m.x.values()) >= 0 + + m.x[1] + m.x[3] + m.x[5] + + const, var_list, coef_list, lb, ub = self._eval(m.c) + self.assertEqual(const, 5) + self.assertEqual(var_list, [0, 1, 2]) + self.assertEqual(coef_list, [1, 1, 1]) + self.assertEqual(lb, 0) + self.assertEqual(ub, None) + + def test_eval_explicit_sum(self): + m = ConcreteModel() + m.x = Var([1, 2, 3]) + + @m.Constraint() + def c(m): + return sum(i * m.x[i] for i in [1, 2, 3]) >= 10 + + const, var_list, coef_list, lb, ub = self._eval(m.c) + self.assertEqual(const, 0) + self.assertEqual(var_list, [0, 1, 2]) + self.assertEqual(coef_list, [1, 2, 3]) + self.assertEqual(lb, 10) + self.assertEqual(ub, None) + + def test_eval_ranged_const(self): + m = ConcreteModel() + m.I = Set(initialize=[1, 2]) + m.x = Var([1, 2]) + + @m.Constraint(m.I) + def c(m, i): + return inequality(2, m.x[i], 4) + + const, var_list, coef_list, lb, ub = self._eval(m.c[1]) + self.assertEqual(const, 0) + self.assertEqual(var_list, [0]) + self.assertEqual(coef_list, [1]) + self.assertEqual(lb, 2) + self.assertEqual(ub, 4) + const, var_list, coef_list, lb, ub = self._eval(m.c[2]) + self.assertEqual(const, 0) + self.assertEqual(var_list, [1]) + self.assertEqual(coef_list, [1]) + self.assertEqual(lb, 2) + self.assertEqual(ub, 4) + + def test_eval_ranged_expr(self): + m = ConcreteModel() + m.I = Set(initialize=[1, 2]) + m.x = Var(m.I) + m.p = Param(m.I, initialize=lambda m, i: i * 2) + + @m.Constraint(m.I) + def c(m, i): + return inequality(m.p[i], m.x[i], 2 * m.p[i]) + + const, var_list, coef_list, lb, ub = self._eval(m.c[1]) + self.assertEqual(const, 0) + self.assertEqual(var_list, [0]) + self.assertEqual(coef_list, [1]) + self.assertEqual(lb, 2) + self.assertEqual(ub, 4) + const, var_list, coef_list, lb, ub = self._eval(m.c[2]) + self.assertEqual(const, 0) + self.assertEqual(var_list, [1]) + self.assertEqual(coef_list, [1]) + self.assertEqual(lb, 4) + self.assertEqual(ub, 8) + + def test_eval_ranged_fixed_expr(self): + m = ConcreteModel() + m.I = Set(initialize=[1, 2]) + m.x = Var(m.I) + m.p = Var([1, 2, 3], initialize=lambda m, i: i * 2) + m.p[1].fix() + m.p[2].fix() + + @m.Constraint(m.I) + def c(m, i): + return inequality(m.p[i], m.x[i], 2 * m.p[i]) + + const, var_list, coef_list, lb, ub = self._eval(m.c[1]) + self.assertEqual(const, 0) + self.assertEqual(var_list, [0]) + self.assertEqual(coef_list, [1]) + self.assertEqual(lb, 2) + self.assertEqual(ub, 4) + const, var_list, coef_list, lb, ub = self._eval(m.c[2]) + self.assertEqual(const, 0) + self.assertEqual(var_list, [1]) + self.assertEqual(coef_list, [1]) + self.assertEqual(lb, 4) + self.assertEqual(ub, 8) + + def test_eval_ranged_unfixed_expr(self): + m = ConcreteModel() + m.I = Set(initialize=[1, 2]) + m.x = Var(m.I) + m.p = Var([1, 2, 3], initialize=lambda m, i: i * 2) + m.p.fix() + + @m.Constraint(m.I) + def c(m, i): + return inequality(m.p[i], m.x[i], 2 * m.p[i + 1]) + + m.p[1].unfix() + m.p[3].unfix() + with self.assertRaisesRegex( + RuntimeError, r"Constraint c\[1\] has non-fixed lower bound" + ): + self._eval(m.c[1]) + with self.assertRaisesRegex( + RuntimeError, r"Constraint c\[2\] has non-fixed upper bound" + ): + self._eval(m.c[2]) + + def test_eval_default_param_expr(self): + m = ConcreteModel() + m.I = Set(initialize=[1, 2, 3]) + m.x = Var(m.I) + m.p = Param(m.I, initialize={2: 5}, default=10) + + @m.Constraint(m.I) + def c(m, i): + return m.x[i] >= m.p[i] + + const, var_list, coef_list, lb, ub = self._eval(m.c[1]) + self.assertEqual(const, 0) + self.assertEqual(var_list, [0]) + self.assertEqual(coef_list, [1]) + self.assertEqual(lb, 10) + self.assertEqual(ub, None) + const, var_list, coef_list, lb, ub = self._eval(m.c[2]) + self.assertEqual(const, 0) + self.assertEqual(var_list, [1]) + self.assertEqual(coef_list, [1]) + self.assertEqual(lb, 5) + self.assertEqual(ub, None) + const, var_list, coef_list, lb, ub = self._eval(m.c[3]) + self.assertEqual(const, 0) + self.assertEqual(var_list, [2]) + self.assertEqual(coef_list, [1]) + self.assertEqual(lb, 10) + self.assertEqual(ub, None) + + def test_eval_objective(self): + m = ConcreteModel() + m.I = Set(initialize=[1, 2]) + m.x = Var(m.I) + m.obj = Objective(expr=sum(i + (i * 3) * m.x[i] for i in m.I)) + + const, var_list, coef_list, lb, ub = self._eval(m.obj) + self.assertEqual(const, 3) + self.assertEqual(var_list, [0, 1]) + self.assertEqual(coef_list, [3, 6]) + self.assertEqual(lb, None) + self.assertEqual(ub, None) + + def test_skip(self): + m = ConcreteModel() + m.I = Set(initialize=[1, 2]) + m.x = Var(m.I) + + @m.Constraint() + def c(m): + return Constraint.Skip + + const, var_list, coef_list, lb, ub = self._eval(m.c) + self.assertEqual(const, 0) + self.assertEqual(var_list, []) + self.assertEqual(coef_list, []) + self.assertEqual(lb, None) + self.assertEqual(ub, None) From c7c5d6f63313f23bb17473832f2bc5c2c2b5d19f Mon Sep 17 00:00:00 2001 From: John Siirola Date: Sun, 9 Nov 2025 18:48:41 -0700 Subject: [PATCH 26/36] SetOf: revert change to the dimen of empty iterables --- pyomo/core/base/set.py | 4 ++-- pyomo/core/expr/template_expr.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pyomo/core/base/set.py b/pyomo/core/base/set.py index 2e1f0733b41..37af7e37bf5 100644 --- a/pyomo/core/base/set.py +++ b/pyomo/core/base/set.py @@ -2634,8 +2634,8 @@ def dimen(self): ans = len(x) else: ans = 1 - except: - return None + except StopIteration: + return 0 for x in _iter: _this = len(x) if type(x) is tuple else 1 if _this != ans: diff --git a/pyomo/core/expr/template_expr.py b/pyomo/core/expr/template_expr.py index 29db30f4eea..63bebce3856 100644 --- a/pyomo/core/expr/template_expr.py +++ b/pyomo/core/expr/template_expr.py @@ -1035,7 +1035,7 @@ def __next__(self): else: d = _set.dimen grp = context.next_group() - if d is None or type(d) is not int: + if not d or type(d) is not int: idx = (IndexTemplate(_set, None, context.next_id(), grp),) else: idx = tuple( From ff1f45c8fb277566829a1b784b9bed63f22b0945 Mon Sep 17 00:00:00 2001 From: John Siirola Date: Sun, 9 Nov 2025 18:49:34 -0700 Subject: [PATCH 27/36] Update tests to reflect use of SetOf; remove deprecation warnings --- pyomo/common/tests/test_timing.py | 2 ++ pyomo/repn/tests/test_linear_template.py | 6 +++--- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/pyomo/common/tests/test_timing.py b/pyomo/common/tests/test_timing.py index c75fc3fafd9..575e6dfc962 100644 --- a/pyomo/common/tests/test_timing.py +++ b/pyomo/common/tests/test_timing.py @@ -98,6 +98,7 @@ def test_report_timing(self): (0(\.\d+)?) seconds to construct Var x; 2 indices total (0(\.\d+)?) seconds to construct Var y; 0 indices total (0(\.\d+)?) seconds to construct Suffix Suffix + (0(\.\d+)?) seconds to construct SetOf 'y._data' (0(\.\d+)?) seconds to apply Transformation RelaxIntegerVars \(in-place\) """.strip() @@ -147,6 +148,7 @@ def test_report_timing_context_manager(self): (0(\.\d+)?) seconds to construct Var x; 2 indices total (0(\.\d+)?) seconds to construct Var y; 0 indices total (0(\.\d+)?) seconds to construct Suffix Suffix + (0(\.\d+)?) seconds to construct SetOf 'y._data' (0(\.\d+)?) seconds to apply Transformation RelaxIntegerVars \(in-place\) """.strip() diff --git a/pyomo/repn/tests/test_linear_template.py b/pyomo/repn/tests/test_linear_template.py index 15539af1f7a..3c1808947f8 100644 --- a/pyomo/repn/tests/test_linear_template.py +++ b/pyomo/repn/tests/test_linear_template.py @@ -538,7 +538,7 @@ def e(m): def test_iter_nonfinite_component(self): m = ConcreteModel() - m.x = Var(NonNegativeIntegers) + m.x = Var(NonNegativeIntegers, dense=False) m.p = Param(mutable=True, initialize=0) m.x[1] = 1 m.x[2] = 2 @@ -788,7 +788,7 @@ def c(m, i): def test_fcn_with_nonfinite(self): m = ConcreteModel() - m.x = Var(Any) + m.x = Var(Any, dense=False) @m.Constraint() def c(m): @@ -959,7 +959,7 @@ def c(m, i): def test_eval_with_nonfinite(self): m = ConcreteModel() - m.x = Var(Any) + m.x = Var(Any, dense=False) @m.Constraint() def c(m): From 7e6fb2b48461bcd27387934a41f630e7fef7f09e Mon Sep 17 00:00:00 2001 From: John Siirola Date: Sun, 9 Nov 2025 22:25:47 -0700 Subject: [PATCH 28/36] Revert "SetOf: revert change to the dimen of empty iterables" This reverts commit c7c5d6f63313f23bb17473832f2bc5c2c2b5d19f. --- pyomo/core/base/set.py | 4 ++-- pyomo/core/expr/template_expr.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pyomo/core/base/set.py b/pyomo/core/base/set.py index 37af7e37bf5..2e1f0733b41 100644 --- a/pyomo/core/base/set.py +++ b/pyomo/core/base/set.py @@ -2634,8 +2634,8 @@ def dimen(self): ans = len(x) else: ans = 1 - except StopIteration: - return 0 + except: + return None for x in _iter: _this = len(x) if type(x) is tuple else 1 if _this != ans: diff --git a/pyomo/core/expr/template_expr.py b/pyomo/core/expr/template_expr.py index 63bebce3856..29db30f4eea 100644 --- a/pyomo/core/expr/template_expr.py +++ b/pyomo/core/expr/template_expr.py @@ -1035,7 +1035,7 @@ def __next__(self): else: d = _set.dimen grp = context.next_group() - if not d or type(d) is not int: + if d is None or type(d) is not int: idx = (IndexTemplate(_set, None, context.next_id(), grp),) else: idx = tuple( From c2b2b92db336ce35c5c58ebf7ed94d5e9f527361 Mon Sep 17 00:00:00 2001 From: John Siirola Date: Sun, 9 Nov 2025 22:27:13 -0700 Subject: [PATCH 29/36] Track change that SetOf([]).dimen is now None --- pyomo/core/base/set.py | 2 +- pyomo/core/tests/unit/test_set.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyomo/core/base/set.py b/pyomo/core/base/set.py index 2e1f0733b41..f0667437f23 100644 --- a/pyomo/core/base/set.py +++ b/pyomo/core/base/set.py @@ -2634,7 +2634,7 @@ def dimen(self): ans = len(x) else: ans = 1 - except: + except StopIteration: return None for x in _iter: _this = len(x) if type(x) is tuple else 1 diff --git a/pyomo/core/tests/unit/test_set.py b/pyomo/core/tests/unit/test_set.py index 5e445c27c73..0330504464e 100644 --- a/pyomo/core/tests/unit/test_set.py +++ b/pyomo/core/tests/unit/test_set.py @@ -1714,7 +1714,7 @@ def test_bounds(self): ) def test_dimen(self): - self.assertEqual(SetOf([]).dimen, 0) + self.assertEqual(SetOf([]).dimen, None) self.assertEqual(SetOf([1, 2, 3]).dimen, 1) self.assertEqual(SetOf([(1, 2), (2, 3), (4, 5)]).dimen, 2) self.assertEqual(SetOf([1, (2, 3)]).dimen, None) From 1a55b4c92d5bd7870238b212ec6a0eee6425fb35 Mon Sep 17 00:00:00 2001 From: John Siirola Date: Mon, 10 Nov 2025 09:31:08 -0700 Subject: [PATCH 30/36] SetOf({}).dimen should return UnknownSetDimen --- pyomo/core/base/set.py | 4 +++- pyomo/core/expr/template_expr.py | 5 ++++- pyomo/core/tests/unit/test_set.py | 2 +- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/pyomo/core/base/set.py b/pyomo/core/base/set.py index f0667437f23..5b7a73226f5 100644 --- a/pyomo/core/base/set.py +++ b/pyomo/core/base/set.py @@ -2635,7 +2635,9 @@ def dimen(self): else: ans = 1 except StopIteration: - return None + # The referenced object is empty, so we can't infer / verify + # the dimensionality. + return UnknownSetDimen for x in _iter: _this = len(x) if type(x) is tuple else 1 if _this != ans: diff --git a/pyomo/core/expr/template_expr.py b/pyomo/core/expr/template_expr.py index 29db30f4eea..2e0a8e24660 100644 --- a/pyomo/core/expr/template_expr.py +++ b/pyomo/core/expr/template_expr.py @@ -1035,7 +1035,10 @@ def __next__(self): else: d = _set.dimen grp = context.next_group() - if d is None or type(d) is not int: + if type(d) is not int: + # This covers None (jagged set) and UnknownSetDimen. In + # both cases, we will not attempt to unpack the Set and just + # assume a single index template. idx = (IndexTemplate(_set, None, context.next_id(), grp),) else: idx = tuple( diff --git a/pyomo/core/tests/unit/test_set.py b/pyomo/core/tests/unit/test_set.py index 0330504464e..84881596e17 100644 --- a/pyomo/core/tests/unit/test_set.py +++ b/pyomo/core/tests/unit/test_set.py @@ -1714,7 +1714,7 @@ def test_bounds(self): ) def test_dimen(self): - self.assertEqual(SetOf([]).dimen, None) + self.assertEqual(SetOf([]).dimen, UnknownSetDimen) self.assertEqual(SetOf([1, 2, 3]).dimen, 1) self.assertEqual(SetOf([(1, 2), (2, 3), (4, 5)]).dimen, 2) self.assertEqual(SetOf([1, (2, 3)]).dimen, None) From 7641291b95be0b4e30c0b6cc85bf4d0961e0e659 Mon Sep 17 00:00:00 2001 From: John Siirola Date: Mon, 10 Nov 2025 19:48:33 -0700 Subject: [PATCH 31/36] Avoid slicing over UnknownSetDimen referenecs --- pyomo/contrib/incidence_analysis/tests/test_scc_solver.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyomo/contrib/incidence_analysis/tests/test_scc_solver.py b/pyomo/contrib/incidence_analysis/tests/test_scc_solver.py index 08cf5ace9c1..3aeb4379b27 100644 --- a/pyomo/contrib/incidence_analysis/tests/test_scc_solver.py +++ b/pyomo/contrib/incidence_analysis/tests/test_scc_solver.py @@ -152,7 +152,7 @@ def test_dynamic_backward_disc_with_initial_conditions(self): # At t1, "input var" height[t0] is fixed, so # it is not included here. self.assertEqual(len(inputs), len(other_var_set)) - for var in block.input_vars[:]: + for var in block.input_vars.values(): self.assertIn(var, other_var_set) self.assertTrue(var.fixed) @@ -216,7 +216,7 @@ def test_dynamic_backward_disc_without_initial_conditions(self): # At t1, "input var" height[t0] is fixed, so # it is not included here. self.assertEqual(len(inputs), len(other_var_set)) - for var in block.input_vars[:]: + for var in block.input_vars.values(): self.assertIn(var, other_var_set) self.assertTrue(var.fixed) From ed79074c6f63e7a512d93ac71db4496ed429cd42 Mon Sep 17 00:00:00 2001 From: John Siirola Date: Mon, 10 Nov 2025 21:43:05 -0700 Subject: [PATCH 32/36] Explicit sum() should not prevent templatizing constraint --- pyomo/core/expr/template_expr.py | 4 +--- pyomo/repn/tests/test_linear_template.py | 28 ++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/pyomo/core/expr/template_expr.py b/pyomo/core/expr/template_expr.py index 2e0a8e24660..0952f89f76c 100644 --- a/pyomo/core/expr/template_expr.py +++ b/pyomo/core/expr/template_expr.py @@ -1087,9 +1087,7 @@ def next_group(self): def sum_template(self, generator): if generator.__class__ not in generator_like_types: - raise TemplateExpressionError( - "Cannot generate templates of sums over non-generators" - ) + return _TemplateIterManager.builtin_sum(generator) init_cache = len(self.cache) expr = next(generator) final_cache = len(self.cache) diff --git a/pyomo/repn/tests/test_linear_template.py b/pyomo/repn/tests/test_linear_template.py index 3c1808947f8..ce63735f80b 100644 --- a/pyomo/repn/tests/test_linear_template.py +++ b/pyomo/repn/tests/test_linear_template.py @@ -179,6 +179,34 @@ def d(m, i): ans, ) + def test_explicit_sum(self): + m = ConcreteModel() + m.I = Set(initialize=[1, 2, 3]) + m.x = Var(range(5)) + + @m.Constraint(m.I) + def c(m, i): + return sum([m.x[i - 1], m.x[i], m.x[i + 1]]) >= 0 + + self.assertTrue(hasattr(m.c[1], 'template_expr')) + lb, body, ub = m.c[1].to_bounded_expression() + self.assertEqual(lb, 0) + self.assertEqual(ub, None) + + ans, const = self._build_evaluator(body) + self.assertEqual(const, 0) + self.assertEqual( + [ + 'linear_indices.append(x1[x2 - 1])', + 'linear_data.append(1)', + 'linear_indices.append(x1[x2])', + 'linear_data.append(1)', + 'linear_indices.append(x1[x2 + 1])', + 'linear_data.append(1)', + ], + ans, + ) + def test_sum_one_var(self): m = ConcreteModel() m.x = Var([1, 2, 3]) From f2290a1a68777f2086fc45fec411a7b869c120d2 Mon Sep 17 00:00:00 2001 From: John Siirola Date: Mon, 10 Nov 2025 23:56:06 -0700 Subject: [PATCH 33/36] Additional validation before creating TemplateSumExpression objects --- pyomo/core/expr/template_expr.py | 69 +++++++++++++++++++++--- pyomo/repn/tests/test_linear_template.py | 57 ++++++++++++++++++++ 2 files changed, 120 insertions(+), 6 deletions(-) diff --git a/pyomo/core/expr/template_expr.py b/pyomo/core/expr/template_expr.py index 0952f89f76c..bc395648ea7 100644 --- a/pyomo/core/expr/template_expr.py +++ b/pyomo/core/expr/template_expr.py @@ -52,9 +52,56 @@ logger = logging.getLogger(__name__) + +def _validate_generator(generator): + # We are worried about users writing things like + # + # sum(m.x[i, j] for i in [1, 2, 3] for j in m.J) + # or + # sum(m.x[j] for i in [1, 2, 3] for j in m.J[i]) + # + # If they do, we will not "see" the "i" as an IndexTemplate, so the + # expression would be reduced to + # + # sum(m.x[1, j] for j in m.J) + # and + # sum(m.x[j] for j in m.J[1]) + # + # To guard against this, we will look into the generator code, and + # if there are any local variables declared that are not + # IndexTemplate objects (or tuples of them), then we will throw up + # our hands and expand the sum: + for lvar_name in generator.gi_frame.f_code.co_varnames: + if lvar_name == '.0': + # Skip the outer generator object + continue + lvar = generator.gi_frame.f_locals.get(lvar_name, None) + if lvar.__class__ is IndexTemplate: + continue + if lvar.__class__ is tuple and all(i.__class__ is IndexTemplate for i in lvar): + continue + return False + return True + + +def _validate_map(generator): + # We would love to validate that the map is actually iterating over + # Pyom Set objects (and yielding IndexTemplate objects), but there + # doesn't appear to be a way to interrogate the results of the list + # / generator that the map is iterating over. So, we will just have + # to trust the user . + # + # FIXME: rework IndexedComponent to return custom generators that + # wrap map so we can only accept them and not all maps? + return True + + # it is not clear what to import to get to the built-in "generator" # type. We will just create a generator and query its __class__ -generator_like_types = {(_ for _ in ()).__class__, map} +generator_validators = { + (_ for _ in ()).__class__: _validate_generator, + map: _validate_map, +} class _NotSpecified: @@ -1086,14 +1133,24 @@ def next_group(self): return self._group def sum_template(self, generator): - if generator.__class__ not in generator_like_types: + try: + validator = generator_validators[generator.__class__] + except KeyError: + # We will only templatize sums over maps and generators. + # Expand everything else: return _TemplateIterManager.builtin_sum(generator) - init_cache = len(self.cache) + niters = -len(self.cache) expr = next(generator) - final_cache = len(self.cache) - if init_cache == final_cache: + niters += len(self.cache) + if niters: + iters = self.npop_cache(niters) + else: + # This didn't generate any new IndexTemplate objects; expand it: + return _TemplateIterManager.builtin_sum(generator, start=expr) + if not validator(generator): + # See the validator implementions above for situations where + # we will not attempt to generate SumTemplate objects return _TemplateIterManager.builtin_sum(generator, start=expr) - iters = self.npop_cache(final_cache - init_cache) return TemplateSumExpression((expr,), iters) diff --git a/pyomo/repn/tests/test_linear_template.py b/pyomo/repn/tests/test_linear_template.py index ce63735f80b..dabd047761a 100644 --- a/pyomo/repn/tests/test_linear_template.py +++ b/pyomo/repn/tests/test_linear_template.py @@ -207,6 +207,63 @@ def c(m, i): ans, ) + def test_mixed_sum(self): + m = ConcreteModel() + m.I = Set(initialize=[0, 1]) + m.x = Var(range(3)) + + @m.Constraint() + def c(m): + return sum(m.x[i + j] for j in [0, 1] for i in m.I) >= 0 + + self.assertFalse(hasattr(m.c, 'template_expr')) + lb, body, ub = m.c.to_bounded_expression() + self.assertEqual(lb, 0) + self.assertEqual(ub, None) + + ans, const = self._build_evaluator(body) + self.assertEqual(const, 0) + self.assertEqual( + [ + 'linear_indices.append(0)', + 'linear_data.append(1)', + 'linear_indices.append(1)', + 'linear_data.append(2)', + 'linear_indices.append(2)', + 'linear_data.append(1)', + ], + ans, + ) + + m = ConcreteModel() + m.I = Set(initialize=[(0, 0), (1, 1)]) + m.x = Var(range(3), range(3), range(3)) + + @m.Constraint() + def c(m): + return sum(m.x[i, j] for j in [1, 2] for i in m.I) >= 0 + + self.assertFalse(hasattr(m.c, 'template_expr')) + lb, body, ub = m.c.to_bounded_expression() + self.assertEqual(lb, 0) + self.assertEqual(ub, None) + + ans, const = self._build_evaluator(body) + self.assertEqual(const, 0) + self.assertEqual( + [ + 'linear_indices.append(4)', + 'linear_data.append(1)', + 'linear_indices.append(16)', + 'linear_data.append(1)', + 'linear_indices.append(5)', + 'linear_data.append(1)', + 'linear_indices.append(17)', + 'linear_data.append(1)', + ], + ans, + ) + def test_sum_one_var(self): m = ConcreteModel() m.x = Var([1, 2, 3]) From 19f0fe35ab08559c43dd19c7cf18a6c34648d163 Mon Sep 17 00:00:00 2001 From: John Siirola Date: Tue, 11 Nov 2025 00:05:00 -0700 Subject: [PATCH 34/36] Remove (unused) 'remove_fixed_vars' option --- pyomo/repn/linear_template.py | 36 ++++++++---------------- pyomo/repn/tests/test_linear_template.py | 4 +-- 2 files changed, 13 insertions(+), 27 deletions(-) diff --git a/pyomo/repn/linear_template.py b/pyomo/repn/linear_template.py index fcc58bd5311..a5b01749824 100644 --- a/pyomo/repn/linear_template.py +++ b/pyomo/repn/linear_template.py @@ -121,9 +121,7 @@ def append(self, other): term[0].multiplier *= mult self.linear_sum.extend(other.linear_sum) - def _build_evaluator( - self, smap, expr_cache, multiplier, repetitions, remove_fixed_vars - ): + def _build_evaluator(self, smap, expr_cache, multiplier, repetitions): assert self.nonlinear is None ans = [] multiplier *= self.multiplier @@ -153,15 +151,6 @@ def _build_evaluator( # Directly substitute the expression into the # 'linear[vid] = coef' below k = k.to_string(smap=smap) - # If we are removing fixed vars, add that logic here: - if remove_fixed_vars: - ans.append(f"v = {k}") - ans.append('if v.__class__ is tuple:') - ans.append(' const += v[0] * {coef}') - ans.append(' v = None') - ans.append('else:') - k = 'v' - indent = ' ' ans.append(indent + f'linear_indices.append({k})') ans.append(indent + f'linear_data.append({coef})') @@ -173,7 +162,7 @@ def _build_evaluator( except: subrep = 0 subans, subconst = subrepn._build_evaluator( - smap, expr_cache, multiplier, repetitions * subrep, remove_fixed_vars + smap, expr_cache, multiplier, repetitions * subrep ) if subans: ans.extend( @@ -192,8 +181,8 @@ def _build_evaluator( constant += subconst return ans, constant - def _build_evaluator_fcn(self, args, smap, expr_cache, remove_fixed_vars): - ans, constant = self._build_evaluator(smap, expr_cache, 1, 1, remove_fixed_vars) + def _build_evaluator_fcn(self, args, smap, expr_cache): + ans, constant = self._build_evaluator(smap, expr_cache, 1, 1) if not ans: return lambda _ind, _dat, *_args: constant, None indent = '\n ' @@ -230,13 +219,11 @@ def _build_evaluator_fcn(self, args, smap, expr_cache, remove_fixed_vars): fname, ) - def compile(self, env, smap, expr_cache, args, remove_fixed_vars=False): + def compile(self, env, smap, expr_cache, args): # build the function in the env namespace, then remove and # return the compiled function. The function's globals will # still be bound to env - fcn, fcn_name = self._build_evaluator_fcn( - args, smap, expr_cache, remove_fixed_vars - ) + fcn, fcn_name = self._build_evaluator_fcn(args, smap, expr_cache) if not fcn_name: return fcn exec(fcn, env) @@ -364,7 +351,7 @@ class LinearTemplateRepnVisitor(linear.LinearRepnVisitor): initialize_exit_node_dispatcher(define_exit_node_handlers()) ) - def __init__(self, subexpression_cache, var_recorder, remove_fixed_vars=False): + def __init__(self, subexpression_cache, var_recorder): super().__init__(subexpression_cache, var_recorder=var_recorder) self.indexed_vars = set() self.indexed_params = set() @@ -372,7 +359,6 @@ def __init__(self, subexpression_cache, var_recorder, remove_fixed_vars=False): self.env = var_recorder.env self.symbolmap = var_recorder.symbolmap self.expanded_templates = {} - self.remove_fixed_vars = remove_fixed_vars def enterNode(self, node): # SumExpression are potentially large nary operators. Directly @@ -410,20 +396,20 @@ def expand_expression(self, obj, template_info): lb, body, ub = expr.args if body is not None: body = self.walk_expression(body).compile( - env, smap, self.expr_cache, args, False + env, smap, self.expr_cache, args ) if lb is not None: lb = self.walk_expression(lb).compile( - env, smap, self.expr_cache, args, True + env, smap, self.expr_cache, args ) if ub is not None: ub = self.walk_expression(ub).compile( - env, smap, self.expr_cache, args, True + env, smap, self.expr_cache, args ) else: lb = ub = None body = self.walk_expression(expr).compile( - env, smap, self.expr_cache, args, False + env, smap, self.expr_cache, args ) self.expanded_templates[id(template_info)] = body, lb, ub diff --git a/pyomo/repn/tests/test_linear_template.py b/pyomo/repn/tests/test_linear_template.py index dabd047761a..eb1b454f9ce 100644 --- a/pyomo/repn/tests/test_linear_template.py +++ b/pyomo/repn/tests/test_linear_template.py @@ -39,13 +39,13 @@ def tearDown(self): def _build_evaluator(self, expr): repn = self.visitor.walk_expression(expr) return repn._build_evaluator( - self.visitor.symbolmap, self.visitor.expr_cache, 1, 1, False + self.visitor.symbolmap, self.visitor.expr_cache, 1, 1 ) def _build_evaluator_fcn(self, expr, args): repn = self.visitor.walk_expression(expr) return repn._build_evaluator_fcn( - args, self.visitor.symbolmap, self.visitor.expr_cache, False + args, self.visitor.symbolmap, self.visitor.expr_cache ) def _eval(self, obj): From 1dcf6ff780bc48baf3e0ca90094c63d88ae8b3f3 Mon Sep 17 00:00:00 2001 From: John Siirola Date: Tue, 11 Nov 2025 00:16:14 -0700 Subject: [PATCH 35/36] Remove duplicated code --- pyomo/repn/linear_template.py | 38 +++++++++++++++++------------------ 1 file changed, 18 insertions(+), 20 deletions(-) diff --git a/pyomo/repn/linear_template.py b/pyomo/repn/linear_template.py index a5b01749824..87b938cdc1b 100644 --- a/pyomo/repn/linear_template.py +++ b/pyomo/repn/linear_template.py @@ -426,31 +426,17 @@ def expand_expression(self, obj, template_info): if linear_indices: # Note that we will only get here for Ranged constraints # with potentially variable bounds. - vl = self.var_recorder.var_list - for i, coef in zip(linear_indices, linear_data): - v = vl[i] - if not v.fixed: - raise RuntimeError( - f"Constraint {obj} has non-fixed lower bound" - ) - lb += v.value * coef - linear_indices = [] - linear_data = [] + lb += self._evaluate_fixed_vars( + linear_indices, linear_data, obj, 'lower' + ) if ub.__class__ is code_type: ub = ub(linear_indices, linear_data, *index) if linear_indices: # Note that we will only get here for Ranged constraints # with potentially variable bounds. - vl = self.var_recorder.var_list - for i, coef in zip(linear_indices, linear_data): - v = vl[i] - if not v.fixed: - raise RuntimeError( - f"Constraint {obj} has non-fixed upper bound" - ) - ub += v.value * coef - linear_indices = [] - linear_data = [] + ub += self._evaluate_fixed_vars( + linear_indices, linear_data, obj, 'upper' + ) return ( body(linear_indices, linear_data, *index), linear_indices, @@ -458,3 +444,15 @@ def expand_expression(self, obj, template_info): lb, ub, ) + + def _evaluate_fixed_vars(self, linear_indices, linear_data, obj, bound): + ans = 0 + vl = self.var_recorder.var_list + for i, coef in zip(linear_indices, linear_data): + v = vl[i] + if not v.fixed: + raise RuntimeError(f"Constraint {obj} has non-fixed {bound} bound") + ans += v.value * coef + linear_indices.clear() + linear_data.clear() + return ans From 3d8e302da251359d2e98ad537b0eb65bf92cd6b7 Mon Sep 17 00:00:00 2001 From: John Siirola Date: Tue, 11 Nov 2025 00:23:51 -0700 Subject: [PATCH 36/36] NFC: fix typo --- pyomo/core/expr/template_expr.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyomo/core/expr/template_expr.py b/pyomo/core/expr/template_expr.py index bc395648ea7..0f3e3817322 100644 --- a/pyomo/core/expr/template_expr.py +++ b/pyomo/core/expr/template_expr.py @@ -1148,7 +1148,7 @@ def sum_template(self, generator): # This didn't generate any new IndexTemplate objects; expand it: return _TemplateIterManager.builtin_sum(generator, start=expr) if not validator(generator): - # See the validator implementions above for situations where + # See the validator implementations above for situations where # we will not attempt to generate SumTemplate objects return _TemplateIterManager.builtin_sum(generator, start=expr) return TemplateSumExpression((expr,), iters)