From 3a6e2181d229e9a33138dab537264451fa47f43b Mon Sep 17 00:00:00 2001 From: radhakrishnatg Date: Fri, 18 Apr 2025 13:05:23 -0400 Subject: [PATCH 01/10] Added default rule for custom blocks --- pyomo/core/base/block.py | 41 ++++++++++++++++- pyomo/core/tests/unit/test_block.py | 71 +++++++++++++++++++++++++++++ 2 files changed, 111 insertions(+), 1 deletion(-) diff --git a/pyomo/core/base/block.py b/pyomo/core/base/block.py index dd0bc790b68..f4d9a56b53c 100644 --- a/pyomo/core/base/block.py +++ b/pyomo/core/base/block.py @@ -2399,10 +2399,36 @@ def __init__(self, *args, **kwargs): break +def _default_rule(model_options): + """ + Default rule for custom blocks + + Parameters + ---------- + model_options : dict + Dictionary of options needed to construct the block model + """ + + def _rule(blk, *args): + try: + # Attempt to build the model + blk.build(*args, **model_options) + + except AttributeError: + # build method is not implemented in the BlockData class + # Returning an empty Pyomo Block + pass + + return _rule + + class CustomBlock(Block): """The base class used by instances of custom block components""" def __init__(self, *args, **kwargs): + model_options = kwargs.pop("options", {}) + kwargs.setdefault("rule", _default_rule(model_options)) + if self._default_ctype is not None: kwargs.setdefault('ctype', self._default_ctype) Block.__init__(self, *args, **kwargs) @@ -2431,7 +2457,20 @@ def declare_custom_block(name, new_ctype=None): >>> @declare_custom_block(name="FooBlock") ... class FooBlockData(BlockData): ... # custom block data class - ... pass + ... # CustomBlock returns an empty block if `build` method is not implemented + ... def build(self, *args, option_1, option_2): + ... # args contains the index (for indexed blocks) + ... # option_1, option_2, ... are additional arguments + ... self.x = Var() + ... self.cost = Param(initialize=option_1) + + Usage: + >>> m = ConcreteModel() + >>> m.blk = FooBlock([1, 2], options={"option_1": 1, "option_2": 2}) + + Specify `rule` argument to ignore the default rule argument. + >>> m = ConcreteModel() + >>> m.blk = FooBlock([1, 2], rule=my_custom_block_rule) """ def block_data_decorator(block_data): diff --git a/pyomo/core/tests/unit/test_block.py b/pyomo/core/tests/unit/test_block.py index 54cf1607ff6..6c3525a0123 100644 --- a/pyomo/core/tests/unit/test_block.py +++ b/pyomo/core/tests/unit/test_block.py @@ -3049,6 +3049,77 @@ def pprint(self, ostream=None, verbose=False, prefix=""): b.pprint(ostream=stream) self.assertEqual(correct_s, stream.getvalue()) + def test_custom_block_default_rule(self): + """Tests the decorator with `build` method, but without options""" + @declare_custom_block("FooBlock") + class FooBlockData(BlockData): + def build(self, *args): + self.x = Var(list(args)) + self.y = Var() + + m = ConcreteModel() + m.blk_without_index = FooBlock() + m.blk_1 = FooBlock([1, 2, 3]) + m.blk_2 = FooBlock([4, 5], [6, 7]) + + self.assertIn("x", m.blk_without_index.component_map()) + self.assertIn("y", m.blk_without_index.component_map()) + self.assertIn("x", m.blk_1[3].component_map()) + self.assertIn("x", m.blk_2[4, 6].component_map()) + + self.assertEqual(len(m.blk_1), 3) + self.assertEqual(len(m.blk_2), 4) + + self.assertEqual(len(m.blk_1[2].x), 1) + self.assertEqual(len(m.blk_2[4, 6].x), 2) + + def test_custom_block_default_rule_options(self): + """Tests the decorator with `build` method and model options""" + @declare_custom_block("FooBlock") + class FooBlockData(BlockData): + def build(self, *args, capex, opex): + self.x = Var(list(args)) + self.y = Var() + + self.capex = capex + self.opex = opex + + options = {"capex": 42, "opex": 24} + m = ConcreteModel() + m.blk_without_index = FooBlock(options=options) + m.blk_1 = FooBlock([1, 2, 3], options=options) + m.blk_2 = FooBlock([4, 5], [6, 7], options=options) + + self.assertEqual(m.blk_without_index.capex, 42) + self.assertEqual(m.blk_without_index.opex, 24) + + self.assertEqual(m.blk_1[3].capex, 42) + self.assertEqual(m.blk_2[4, 7].opex, 24) + + with self.assertRaises(TypeError): + # missing 2 required keyword arguments + m.blk_3 = FooBlock() + + def test_custom_block_user_rule(self): + """Tests if the default rule can be overwritten""" + @declare_custom_block("FooBlock") + class FooBlockData(BlockData): + def build(self, *args): + self.x = Var(list(args)) + self.y = Var() + + def _new_rule(blk): + blk.a = Var() + blk.b = Var() + + m = ConcreteModel() + m.blk = FooBlock(rule=_new_rule) + + self.assertNotIn("x", m.blk.component_map()) + self.assertNotIn("y", m.blk.component_map()) + self.assertIn("a", m.blk.component_map()) + self.assertIn("b", m.blk.component_map()) + def test_block_rules(self): m = ConcreteModel() m.I = Set() From 753f1ae9e475fa3ec585fcb9f7c01672eaf40470 Mon Sep 17 00:00:00 2001 From: John Siirola Date: Mon, 14 Jul 2025 23:08:09 -0600 Subject: [PATCH 02/10] Simplify Block construction using functools.partial --- pyomo/core/base/block.py | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/pyomo/core/base/block.py b/pyomo/core/base/block.py index f4d9a56b53c..483c24f2c6b 100644 --- a/pyomo/core/base/block.py +++ b/pyomo/core/base/block.py @@ -11,6 +11,7 @@ from __future__ import annotations import copy +import functools import logging import sys import weakref @@ -2108,17 +2109,7 @@ def __init__(self, *args, **kwargs): "the function arguments", version='5.7.2', ) - if self.is_indexed(): - - def rule_wrapper(model, *_idx): - return _rule(model, *_idx, **_options) - - else: - - def rule_wrapper(model): - return _rule(model, **_options) - - self._rule = Initializer(rule_wrapper) + self._rule = Initializer(functools.partial(_rule, **_options)) else: self._rule = Initializer(_rule) if _concrete: From bb2e26def96632ad9ea5eab066df8101d93848d7 Mon Sep 17 00:00:00 2001 From: John Siirola Date: Mon, 14 Jul 2025 23:13:34 -0600 Subject: [PATCH 03/10] Rework how we define the default rule through declare_custom_block --- pyomo/core/base/block.py | 47 +++++++++++++++++----------------------- 1 file changed, 20 insertions(+), 27 deletions(-) diff --git a/pyomo/core/base/block.py b/pyomo/core/base/block.py index 483c24f2c6b..28b8e6cacba 100644 --- a/pyomo/core/base/block.py +++ b/pyomo/core/base/block.py @@ -2390,38 +2390,13 @@ def __init__(self, *args, **kwargs): break -def _default_rule(model_options): - """ - Default rule for custom blocks - - Parameters - ---------- - model_options : dict - Dictionary of options needed to construct the block model - """ - - def _rule(blk, *args): - try: - # Attempt to build the model - blk.build(*args, **model_options) - - except AttributeError: - # build method is not implemented in the BlockData class - # Returning an empty Pyomo Block - pass - - return _rule - - class CustomBlock(Block): """The base class used by instances of custom block components""" def __init__(self, *args, **kwargs): - model_options = kwargs.pop("options", {}) - kwargs.setdefault("rule", _default_rule(model_options)) - if self._default_ctype is not None: kwargs.setdefault('ctype', self._default_ctype) + kwargs.setdefault("rule", getattr(self, '_default_rule', None)) Block.__init__(self, *args, **kwargs) def __new__(cls, *args, **kwargs): @@ -2442,7 +2417,18 @@ def __new__(cls, *args, **kwargs): return super().__new__(cls._indexed_custom_block, *args, **kwargs) -def declare_custom_block(name, new_ctype=None): +class _custom_block_rule_redirect(object): + """Functor to redirect the default rule to a BlockData method""" + + def __init__(self, cls, name): + self.cls = cls + self.name = name + + def __call__(self, block, *args, **kwargs): + return getattr(self.cls, self.name)(block, *args, **kwargs) + + +def declare_custom_block(name, new_ctype=None, rule=None): """Decorator to declare components for a custom block data class >>> @declare_custom_block(name="FooBlock") @@ -2485,9 +2471,16 @@ def block_data_decorator(block_data): "_ComponentDataClass": block_data, # By default this new block does not declare a new ctype "_default_ctype": None, + # Define the default rule (may be None) + "_default_rule": rule, }, ) + # If the default rule is a string, then replace it with a + # function that will look up the attribute on the data class. + if type(rule) is str: + comp._default_rule = _custom_block_rule_redirect(block_data, rule) + if new_ctype is not None: if new_ctype is True: comp._default_ctype = comp From d8fcc9121e93df9d48432ce6b6795d67c84c8411 Mon Sep 17 00:00:00 2001 From: John Siirola Date: Mon, 14 Jul 2025 23:30:50 -0600 Subject: [PATCH 04/10] Support passing extra constructor kwargs to the component rule --- pyomo/core/base/block.py | 2 +- pyomo/core/base/component.py | 14 ++++++--- pyomo/core/base/initializer.py | 57 ++++++++++++++++++++++++---------- pyomo/core/base/param.py | 27 +++++++++++++--- 4 files changed, 75 insertions(+), 25 deletions(-) diff --git a/pyomo/core/base/block.py b/pyomo/core/base/block.py index 28b8e6cacba..b8206d7c74f 100644 --- a/pyomo/core/base/block.py +++ b/pyomo/core/base/block.py @@ -2100,7 +2100,6 @@ def __init__(self, *args, **kwargs): # initializer self._dense = kwargs.pop('dense', True) kwargs.setdefault('ctype', Block) - ActiveIndexedComponent.__init__(self, *args, **kwargs) if _options is not None: deprecation_warning( "The Block 'options=' keyword is deprecated. " @@ -2112,6 +2111,7 @@ def __init__(self, *args, **kwargs): self._rule = Initializer(functools.partial(_rule, **_options)) else: self._rule = Initializer(_rule) + ActiveIndexedComponent.__init__(self, *args, **kwargs) if _concrete: # Call self.construct() as opposed to just setting the _constructed # flag so that the base class construction procedure fires (this diff --git a/pyomo/core/base/component.py b/pyomo/core/base/component.py index 446f5f64a6f..6f3ad01a9b3 100644 --- a/pyomo/core/base/component.py +++ b/pyomo/core/base/component.py @@ -32,6 +32,7 @@ from pyomo.core.pyomoobject import PyomoObject from pyomo.core.base.component_namer import name_repr, index_repr from pyomo.core.base.global_set import UnindexedComponent_index +from pyomo.core.base.initializer import PartialInitializer logger = logging.getLogger('pyomo.core') @@ -451,10 +452,15 @@ def __init__(self, **kwds): self.doc = kwds.pop('doc', None) self._name = kwds.pop('name', None) if kwds: - raise ValueError( - "Unexpected keyword options found while constructing '%s':\n\t%s" - % (type(self).__name__, ','.join(sorted(kwds.keys()))) - ) + # If there are leftover keywords, and the component has a + # rule, pass those keywords on to the rule + if getattr(self, '_rule', None) is not None: + self._rule = PartialInitializer(self._rule, **kwds) + else: + raise ValueError( + "Unexpected keyword options found while constructing '%s':\n\t%s" + % (type(self).__name__, ','.join(sorted(kwds.keys()))) + ) # # Verify that ctype has been specified. # diff --git a/pyomo/core/base/initializer.py b/pyomo/core/base/initializer.py index 6dd9890c933..79818c14d3f 100644 --- a/pyomo/core/base/initializer.py +++ b/pyomo/core/base/initializer.py @@ -340,15 +340,15 @@ class IndexedCallInitializer(InitializerBase): def __init__(self, _fcn): self._fcn = _fcn - def __call__(self, parent, idx): + def __call__(self, parent, idx, **kwargs): # Note: this is called by a component using data from a Set (so # any tuple-like type should have already been checked and # converted to a tuple; or flattening is turned off and it is # the user's responsibility to sort things out. if idx.__class__ is tuple: - return self._fcn(parent, *idx) + return self._fcn(parent, *idx, **kwargs) else: - return self._fcn(parent, idx) + return self._fcn(parent, idx, **kwargs) class ParameterizedIndexedCallInitializer(IndexedCallInitializer): @@ -356,11 +356,11 @@ class ParameterizedIndexedCallInitializer(IndexedCallInitializer): __slots__ = () - def __call__(self, parent, idx, *args): + def __call__(self, parent, idx, *args, **kwargs): if idx.__class__ is tuple: - return self._fcn(parent, *args, *idx) + return self._fcn(parent, *args, *idx, **kwargs) else: - return self._fcn(parent, *args, idx) + return self._fcn(parent, *args, idx, **kwargs) class CountedCallGenerator(object): @@ -481,8 +481,8 @@ def __init__(self, _fcn, constant=True): self._fcn = _fcn self._constant = constant - def __call__(self, parent, idx): - return self._fcn(parent) + def __call__(self, parent, idx, **kwargs): + return self._fcn(parent, **kwargs) def constant(self): """Return True if this initializer is constant across all indices""" @@ -494,8 +494,8 @@ class ParameterizedScalarCallInitializer(ScalarCallInitializer): __slots__ = () - def __call__(self, parent, idx, *args): - return self._fcn(parent, *args) + def __call__(self, parent, idx, *args, **kwargs): + return self._fcn(parent, *args, **kwargs) class DefaultInitializer(InitializerBase): @@ -523,9 +523,9 @@ def __init__(self, initializer, default, exceptions): self._default = default self._exceptions = exceptions - def __call__(self, parent, index): + def __call__(self, parent, index, **kwargs): try: - return self._initializer(parent, index) + return self._initializer(parent, index, **kwargs) except self._exceptions: return self._default @@ -565,8 +565,33 @@ def indices(self): """ return self._base_initializer.indices() - def __call__(self, parent, idx, *args): - return self._base_initializer(parent, idx)(parent, *args) + def __call__(self, parent, idx, *args, **kwargs): + return self._base_initializer(parent, idx)(parent, *args, **kwargs) + + +class PartialInitializer(InitializerBase): + """Partial wrapper of an InitializerBase that supplies additional arguments""" + + __slots__ = ('_fcn',) + + def __init__(self, _fcn, *args, **kwargs): + self._fcn = functools.partial(_fcn, *args, **kwargs) + + def constant(self): + return self._fcn.func.constant() + + def contains_indices(self): + return self._fcn.func.contains_indices() + + def indices(self): + return self._fcn.func.indices() + + def __call__(self, parent, idx, *args, **kwargs): + # Note that the Initializer.__call__ API is different from the + # rule API. As a result, we cannot just inherit from + # IndexedCallInitializer and must instead implement our own + # __call__ here. + return self._fcn(parent, idx, *args, **kwargs) _bound_sequence_types = collections.defaultdict(None.__class__) @@ -618,8 +643,8 @@ def __init__(self, arg, obj=NOTSET): arg, treat_sequences_as_mappings=treat_sequences_as_mappings ) - def __call__(self, parent, index): - val = self._initializer(parent, index) + def __call__(self, parent, index, **kwargs): + val = self._initializer(parent, index, **kwargs) if _bound_sequence_types[val.__class__]: return val if _bound_sequence_types[val.__class__] is None: diff --git a/pyomo/core/base/param.py b/pyomo/core/base/param.py index 7944fadc430..36017f78be6 100644 --- a/pyomo/core/base/param.py +++ b/pyomo/core/base/param.py @@ -13,15 +13,15 @@ import sys import types import logging -from weakref import ref as weakref_ref -from pyomo.common.pyomo_typing import overload from typing import Union, Type +from weakref import ref as weakref_ref from pyomo.common.autoslots import AutoSlots from pyomo.common.deprecation import deprecation_warning, RenamedClass from pyomo.common.log import is_debug_set from pyomo.common.modeling import NOTSET from pyomo.common.numeric_types import native_types, value as expr_value +from pyomo.common.pyomo_typing import overload from pyomo.common.timing import ConstructionTimer from pyomo.core.expr.expr_common import _type_check_exception_arg from pyomo.core.expr.numvalue import NumericValue @@ -32,7 +32,7 @@ UnindexedComponent_set, IndexedComponent_NDArrayMixin, ) -from pyomo.core.base.initializer import Initializer +from pyomo.core.base.initializer import Initializer, PartialInitializer from pyomo.core.base.misc import apply_indexed_rule, apply_parameterized_indexed_rule from pyomo.core.base.set import Reals, _AnySet, SetInitializer from pyomo.core.base.units_container import units @@ -41,6 +41,10 @@ logger = logging.getLogger('pyomo.core') +def _placeholder_rule(*args, **kwargs): + pass + + def _raise_modifying_immutable_error(obj, index): if obj.is_indexed(): name = "%s[%s]" % (obj.name, index) @@ -358,6 +362,14 @@ def __init__(self, *args, **kwd): # expression simplification does not remove units from # the expression. self._mutable = True + if _init is not NOTSET: + # We need a placeholder rule on the Param because the base + # class will wrap it to pass in any unrecognized keyword + # arguments. We can't just pass the actual rule because + # we want to use is_indexed() to change how we process the rule. + self._rule = _placeholder_rule + else: + self._rule = None kwd.setdefault('ctype', Param) IndexedComponent.__init__(self, *args, **kwd) @@ -370,11 +382,18 @@ def __init__(self, *args, **kwd): else: self.domain = SetInitializer(_domain_rule)(self.parent_block(), None, self) # After IndexedComponent.__init__ so we can call is_indexed(). - self._rule = Initializer( + _rule = Initializer( _init, treat_sequences_as_mappings=self.is_indexed(), arg_not_specified=NOTSET, ) + if self._rule.__class__ is PartialInitializer: + # Replace the _placeholder_rule with the user-specified rule + self._rule = PartialInitializer( + _rule, *self._rule._fcn.args, **self._rule._fcn.keywords + ) + else: + self._rule = _rule def __len__(self): """ From f309130997f4c7d593bc3f7032747e45e9585046 Mon Sep 17 00:00:00 2001 From: John Siirola Date: Mon, 14 Jul 2025 23:31:47 -0600 Subject: [PATCH 05/10] Expand/update documentation --- pyomo/core/base/block.py | 49 +++++++++++++++++++++++----------- pyomo/core/base/initializer.py | 2 +- 2 files changed, 35 insertions(+), 16 deletions(-) diff --git a/pyomo/core/base/block.py b/pyomo/core/base/block.py index b8206d7c74f..fbd7f99199e 100644 --- a/pyomo/core/base/block.py +++ b/pyomo/core/base/block.py @@ -2431,23 +2431,42 @@ def __call__(self, block, *args, **kwargs): def declare_custom_block(name, new_ctype=None, rule=None): """Decorator to declare components for a custom block data class + This decorator simplifies the definition of custom derived Block + classes. With this decorator, developers must only implement the + derived "Data" class. The decorator automatically creates the + derived containers using the provided name, and adds them to the + current module: + >>> @declare_custom_block(name="FooBlock") ... class FooBlockData(BlockData): - ... # custom block data class - ... # CustomBlock returns an empty block if `build` method is not implemented - ... def build(self, *args, option_1, option_2): - ... # args contains the index (for indexed blocks) - ... # option_1, option_2, ... are additional arguments - ... self.x = Var() - ... self.cost = Param(initialize=option_1) - - Usage: - >>> m = ConcreteModel() - >>> m.blk = FooBlock([1, 2], options={"option_1": 1, "option_2": 2}) - - Specify `rule` argument to ignore the default rule argument. - >>> m = ConcreteModel() - >>> m.blk = FooBlock([1, 2], rule=my_custom_block_rule) + ... pass + + >>> s = FooBlock() + >>> type(s) + + + >>> s = FooBlock([1,2]) + >>> type(s) + + + It is frequenty desirable for the custom class to have a default + ``rule`` for constructing and populating new instances. The default + rule can be provided either as an explicit function or a string. If + a string, the rule is obtained by attribute lookup on the derived + Data class: + + >>> @declare_custom_block(name="BarBlock", rule="build") + ... class BarBlockData(BlockData): + ... def build(self, *args): + ... self.x = Var(initialize=5) + + >>> m = pyo.ConcreteModel() + >>> m.b = BarBlock([1,2]) + >>> print(m.b[1].x.value) + 5 + >>> print(m.b[2].x.value) + 5 + """ def block_data_decorator(block_data): diff --git a/pyomo/core/base/initializer.py b/pyomo/core/base/initializer.py index 79818c14d3f..d7b73d7c9bc 100644 --- a/pyomo/core/base/initializer.py +++ b/pyomo/core/base/initializer.py @@ -542,7 +542,7 @@ def indices(self): class ParameterizedInitializer(InitializerBase): - """Base class for all Initializer objects""" + """Wapper to provide additional positional arguments to Initializer objects""" __slots__ = ('_base_initializer',) From e49ef8b3cff6ac4e951b7d246236af6193c96317 Mon Sep 17 00:00:00 2001 From: John Siirola Date: Mon, 14 Jul 2025 23:32:28 -0600 Subject: [PATCH 06/10] Update tests for declare_custom_block and passing kwargs to rules --- pyomo/core/tests/unit/test_block.py | 50 +++++++++++++++++++---------- pyomo/core/tests/unit/test_param.py | 11 +++++++ 2 files changed, 44 insertions(+), 17 deletions(-) diff --git a/pyomo/core/tests/unit/test_block.py b/pyomo/core/tests/unit/test_block.py index 92b4a22ad7c..04c36fa0a33 100644 --- a/pyomo/core/tests/unit/test_block.py +++ b/pyomo/core/tests/unit/test_block.py @@ -15,6 +15,7 @@ from io import StringIO import logging import os +import pickle import sys import types import json @@ -79,6 +80,16 @@ def foo(self): DerivedBlock._Block_reserved_words = set(dir(DerivedBlock())) +@declare_custom_block("FooBlock", rule="build") +class FooBlockData(BlockData): + def build(self, *args, capex, opex): + self.x = Var(list(args)) + self.y = Var() + + self.capex = capex + self.opex = opex + + class TestGenerators(unittest.TestCase): def generate_model(self): # @@ -3059,16 +3070,17 @@ def pprint(self, ostream=None, verbose=False, prefix=""): def test_custom_block_default_rule(self): """Tests the decorator with `build` method, but without options""" - @declare_custom_block("FooBlock") - class FooBlockData(BlockData): + + @declare_custom_block("LocalFooBlock", rule="build") + class LocalFooBlockData(BlockData): def build(self, *args): self.x = Var(list(args)) self.y = Var() m = ConcreteModel() - m.blk_without_index = FooBlock() - m.blk_1 = FooBlock([1, 2, 3]) - m.blk_2 = FooBlock([4, 5], [6, 7]) + m.blk_without_index = LocalFooBlock() + m.blk_1 = LocalFooBlock([1, 2, 3]) + m.blk_2 = LocalFooBlock([4, 5], [6, 7]) self.assertIn("x", m.blk_without_index.component_map()) self.assertIn("y", m.blk_without_index.component_map()) @@ -3083,20 +3095,12 @@ def build(self, *args): def test_custom_block_default_rule_options(self): """Tests the decorator with `build` method and model options""" - @declare_custom_block("FooBlock") - class FooBlockData(BlockData): - def build(self, *args, capex, opex): - self.x = Var(list(args)) - self.y = Var() - - self.capex = capex - self.opex = opex options = {"capex": 42, "opex": 24} m = ConcreteModel() - m.blk_without_index = FooBlock(options=options) - m.blk_1 = FooBlock([1, 2, 3], options=options) - m.blk_2 = FooBlock([4, 5], [6, 7], options=options) + m.blk_without_index = FooBlock(capex=42, opex=24) + m.blk_1 = FooBlock([1, 2, 3], **options) + m.blk_2 = FooBlock([4, 5], [6, 7], **options) self.assertEqual(m.blk_without_index.capex, 42) self.assertEqual(m.blk_without_index.opex, 24) @@ -3104,12 +3108,24 @@ def build(self, *args, capex, opex): self.assertEqual(m.blk_1[3].capex, 42) self.assertEqual(m.blk_2[4, 7].opex, 24) - with self.assertRaises(TypeError): + new_m = pickle.loads(pickle.dumps(m)) + self.assertIs(new_m.blk_without_index.__class__, m.blk_without_index.__class__) + self.assertIs(new_m.blk_1.__class__, m.blk_1.__class__) + self.assertIs(new_m.blk_2.__class__, m.blk_2.__class__) + + self.assertIsNot(new_m.blk_without_index, m.blk_without_index) + self.assertIsNot(new_m.blk_1, m.blk_1) + self.assertIsNot(new_m.blk_2, m.blk_2) + + with self.assertRaisesRegex( + TypeError, "missing 2 required keyword-only arguments" + ): # missing 2 required keyword arguments m.blk_3 = FooBlock() def test_custom_block_user_rule(self): """Tests if the default rule can be overwritten""" + @declare_custom_block("FooBlock") class FooBlockData(BlockData): def build(self, *args): diff --git a/pyomo/core/tests/unit/test_param.py b/pyomo/core/tests/unit/test_param.py index 66fbb742b70..341e45dc182 100644 --- a/pyomo/core/tests/unit/test_param.py +++ b/pyomo/core/tests/unit/test_param.py @@ -1221,6 +1221,17 @@ def test_constructor(self): model.a = Param(initialize={None: 3.3}) instance = model.create_instance() + def test_rule_args(self): + m = ConcreteModel() + + @m.Param([1, 2, 3], multiplier=5) + def p(m, i, multiplier): + return i * multiplier + + self.assertEqual(m.p[1], 5) + self.assertEqual(m.p[2], 10) + self.assertEqual(m.p[3], 15) + def test_empty_index(self): # Verify that we can initialize a parameter with an empty set. model = ConcreteModel() From 4d3a4ee879898b2e0afbcff77d0ee2ee7295c92a Mon Sep 17 00:00:00 2001 From: John Siirola Date: Tue, 15 Jul 2025 11:39:57 -0600 Subject: [PATCH 07/10] NFC: fix typo --- pyomo/core/base/block.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyomo/core/base/block.py b/pyomo/core/base/block.py index 55618681934..f89a28d6469 100644 --- a/pyomo/core/base/block.py +++ b/pyomo/core/base/block.py @@ -2470,7 +2470,7 @@ def declare_custom_block(name, new_ctype=None, rule=None): >>> type(s) - It is frequenty desirable for the custom class to have a default + It is frequently desirable for the custom class to have a default ``rule`` for constructing and populating new instances. The default rule can be provided either as an explicit function or a string. If a string, the rule is obtained by attribute lookup on the derived From bd20bb60c88df40024660ce1915cbb9a9955ace8 Mon Sep 17 00:00:00 2001 From: John Siirola Date: Sat, 19 Jul 2025 08:06:59 -0600 Subject: [PATCH 08/10] NFC: fix typo Co-authored-by: Bethany Nicholson --- pyomo/core/base/initializer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyomo/core/base/initializer.py b/pyomo/core/base/initializer.py index d7b73d7c9bc..3f5a83cf853 100644 --- a/pyomo/core/base/initializer.py +++ b/pyomo/core/base/initializer.py @@ -542,7 +542,7 @@ def indices(self): class ParameterizedInitializer(InitializerBase): - """Wapper to provide additional positional arguments to Initializer objects""" + """Wrapper to provide additional positional arguments to Initializer objects""" __slots__ = ('_base_initializer',) From 0ce641f17f5da8ab8678689229bdcb12faa12e60 Mon Sep 17 00:00:00 2001 From: John Siirola Date: Mon, 21 Jul 2025 11:05:12 -0600 Subject: [PATCH 09/10] Make rule a private attribute, add deprecation path --- pyomo/core/base/constraint.py | 38 +++++++++++++------ pyomo/core/base/logical_constraint.py | 29 ++++++++++---- pyomo/core/base/objective.py | 36 ++++++++++++------ pyomo/core/tests/unit/test_con.py | 21 ++++++++++ .../tests/unit/test_logical_constraint.py | 20 ++++++++++ pyomo/core/tests/unit/test_obj.py | 22 ++++++++++- 6 files changed, 134 insertions(+), 32 deletions(-) diff --git a/pyomo/core/base/constraint.py b/pyomo/core/base/constraint.py index d9338ab1f70..676153566c8 100644 --- a/pyomo/core/base/constraint.py +++ b/pyomo/core/base/constraint.py @@ -16,7 +16,7 @@ from pyomo.common.pyomo_typing import overload from typing import Union, Type -from pyomo.common.deprecation import RenamedClass +from pyomo.common.deprecation import RenamedClass, deprecated from pyomo.common.errors import DeveloperError, TemplateExpressionError from pyomo.common.formatting import tabular_writer from pyomo.common.log import is_debug_set @@ -545,7 +545,7 @@ def __init__(self, template_info, component, index): def expr(self): # Note that it is faster to just generate the expression from # scratch than it is to clone it and replace the IndexTemplate objects - self.set_value(self.parent_component().rule(self.parent_block(), self.index())) + self.set_value(self.parent_component()._rule(self.parent_block(), self.index())) return self.expr def template_expr(self): @@ -640,9 +640,9 @@ def __init__(self, *args, **kwargs): _init = self._pop_from_kwargs('Constraint', kwargs, ('rule', 'expr'), None) # Special case: we accept 2- and 3-tuples as constraints if type(_init) is tuple: - self.rule = Initializer(_init, treat_sequences_as_mappings=False) + self._rule = Initializer(_init, treat_sequences_as_mappings=False) else: - self.rule = Initializer(_init) + self._rule = Initializer(_init) kwargs.setdefault('ctype', Constraint) ActiveIndexedComponent.__init__(self, *args, **kwargs) @@ -663,7 +663,7 @@ def construct(self, data=None): for _set in self._anonymous_sets: _set.construct() - rule = self.rule + rule = self._rule try: # We do not (currently) accept data for constructing Constraints index = None @@ -719,9 +719,9 @@ def construct(self, data=None): timer.report() def _getitem_when_not_present(self, idx): - if self.rule is None: + if self._rule is None: raise KeyError(idx) - con = self._setitem_when_not_present(idx, self.rule(self.parent_block(), idx)) + con = self._setitem_when_not_present(idx, self._rule(self.parent_block(), idx)) if con is None: raise KeyError(idx) return con @@ -746,6 +746,20 @@ def _pprint(self): ], ) + @property + def rule(self): + return self._rule + + @rule.setter + @deprecated( + f"The 'Constraint.rule' attribute will be made " + "read-only in a future Pyomo release.", + version='6.9.3.dev0', + remove_in='6.11', + ) + def rule(self, rule): + self._rule = rule + def display(self, prefix="", ostream=None): """ Print component state information @@ -971,14 +985,14 @@ def __init__(self, **kwargs): super().__init__(Set(dimen=1), **kwargs) - self.rule = Initializer( + self._rule = Initializer( _rule, treat_sequences_as_mappings=False, allow_generators=True ) # HACK to make the "counted call" syntax work. We wait until # after the base class is set up so that is_indexed() is # reliable. - if self.rule is not None and type(self.rule) is IndexedCallInitializer: - self.rule = CountedCallInitializer(self, self.rule, self._starting_index) + if self._rule is not None and type(self._rule) is IndexedCallInitializer: + self._rule = CountedCallInitializer(self, self._rule, self._starting_index) def construct(self, data=None): """ @@ -995,8 +1009,8 @@ def construct(self, data=None): for _set in self._anonymous_sets: _set.construct() - if self.rule is not None: - _rule = self.rule(self.parent_block(), ()) + if self._rule is not None: + _rule = self._rule(self.parent_block(), ()) for cc in iter(_rule): if cc is ConstraintList.End: break diff --git a/pyomo/core/base/logical_constraint.py b/pyomo/core/base/logical_constraint.py index 62300d61740..b6fdf5f1315 100644 --- a/pyomo/core/base/logical_constraint.py +++ b/pyomo/core/base/logical_constraint.py @@ -13,7 +13,7 @@ import logging from weakref import ref as weakref_ref -from pyomo.common.deprecation import RenamedClass +from pyomo.common.deprecation import RenamedClass, deprecated from pyomo.common.formatting import tabular_writer from pyomo.common.log import is_debug_set from pyomo.common.modeling import NOTSET @@ -227,7 +227,7 @@ def __new__(cls, *args, **kwds): def __init__(self, *args, **kwargs): _init = self._pop_from_kwargs('Constraint', kwargs, ('rule', 'expr'), None) - self.rule = Initializer(_init) + self._rule = Initializer(_init) kwargs.setdefault('ctype', LogicalConstraint) ActiveIndexedComponent.__init__(self, *args, **kwargs) @@ -248,7 +248,7 @@ def construct(self, data=None): for _set in self._anonymous_sets: _set.construct() - rule = self.rule + rule = self._rule try: # We do not (currently) accept data for constructing LogicalConstraints index = None @@ -292,9 +292,9 @@ def construct(self, data=None): timer.report() def _getitem_when_not_present(self, idx): - if self.rule is None: + if self._rule is None: raise KeyError(idx) - con = self._setitem_when_not_present(idx, self.rule(self.parent_block(), idx)) + con = self._setitem_when_not_present(idx, self._rule(self.parent_block(), idx)) if con is None: raise KeyError(idx) return con @@ -314,6 +314,19 @@ def _pprint(self): lambda k, v: [v.body, v.active], ) + @property + def rule(self): + return self._rule + + @rule.setter + @deprecated( + f"The 'LogicalConstraint.rule' attribute will be made read-only", + version='6.9.3.dev0', + remove_in='6.11', + ) + def rule(self, rule): + self._rule = rule + def display(self, prefix="", ostream=None): """ Print component state information @@ -446,7 +459,7 @@ def __init__(self, **kwargs): super().__init__(Set(dimen=1), **kwargs) - self.rule = Initializer( + self._rule = Initializer( _rule, treat_sequences_as_mappings=False, allow_generators=True ) @@ -465,8 +478,8 @@ def construct(self, data=None): for _set in self._anonymous_sets: _set.construct() - if self.rule is not None: - _rule = self.rule(self.parent_block(), ()) + if self._rule is not None: + _rule = self._rule(self.parent_block(), ()) for cc in iter(_rule): if cc is LogicalConstraintList.End: break diff --git a/pyomo/core/base/objective.py b/pyomo/core/base/objective.py index f22a4788bba..bf86ffb7153 100644 --- a/pyomo/core/base/objective.py +++ b/pyomo/core/base/objective.py @@ -14,7 +14,7 @@ from weakref import ref as weakref_ref from pyomo.common.pyomo_typing import overload -from pyomo.common.deprecation import RenamedClass +from pyomo.common.deprecation import RenamedClass, deprecated from pyomo.common.errors import TemplateExpressionError from pyomo.common.enums import ObjectiveSense, minimize, maximize from pyomo.common.log import is_debug_set @@ -185,7 +185,7 @@ def __init__(self, template_info, component, index, sense): def args(self): # Note that it is faster to just generate the expression from # scratch than it is to clone it and replace the IndexTemplate objects - self.set_value(self.parent_component().rule(self.parent_block(), self.index())) + self.set_value(self.parent_component()._rule(self.parent_block(), self.index())) return self._args_ def template_expr(self): @@ -267,7 +267,7 @@ def __init__(self, *args, **kwargs): kwargs.setdefault('ctype', Objective) ActiveIndexedComponent.__init__(self, *args, **kwargs) - self.rule = Initializer(_init) + self._rule = Initializer(_init) self._init_sense = Initializer(_sense) def construct(self, data=None): @@ -286,7 +286,7 @@ def construct(self, data=None): for _set in self._anonymous_sets: _set.construct() - rule = self.rule + rule = self._rule try: # We do not (currently) accept data for constructing Objectives index = None @@ -348,11 +348,11 @@ def construct(self, data=None): timer.report() def _getitem_when_not_present(self, index): - if self.rule is None: + if self._rule is None: raise KeyError(index) block = self.parent_block() - obj = self._setitem_when_not_present(index, self.rule(block, index)) + obj = self._setitem_when_not_present(index, self._rule(block, index)) if obj is None: raise KeyError(index) obj.set_sense(self._init_sense(block, index)) @@ -374,6 +374,20 @@ def _pprint(self): lambda k, v: [v.active, v.sense, v.expr], ) + @property + def rule(self): + return self._rule + + @rule.setter + @deprecated( + f"The 'Objective.rule' attribute will be made " + "read-only in a future Pyomo release.", + version='6.9.3.dev0', + remove_in='6.11', + ) + def rule(self, rule): + self._rule = rule + def display(self, prefix="", ostream=None): """Provide a verbose display of this object""" if not self.active: @@ -573,12 +587,12 @@ def __init__(self, **kwargs): super().__init__(Set(dimen=1), **kwargs) - self.rule = Initializer(_rule, allow_generators=True) + self._rule = Initializer(_rule, allow_generators=True) # HACK to make the "counted call" syntax work. We wait until # after the base class is set up so that is_indexed() is # reliable. - if self.rule is not None and type(self.rule) is IndexedCallInitializer: - self.rule = CountedCallInitializer(self, self.rule, self._starting_index) + if self._rule is not None and type(self._rule) is IndexedCallInitializer: + self._rule = CountedCallInitializer(self, self._rule, self._starting_index) def construct(self, data=None): """ @@ -595,8 +609,8 @@ def construct(self, data=None): for _set in self._anonymous_sets: _set.construct() - if self.rule is not None: - _rule = self.rule(self.parent_block(), ()) + if self._rule is not None: + _rule = self._rule(self.parent_block(), ()) for cc in iter(_rule): if cc is ObjectiveList.End: break diff --git a/pyomo/core/tests/unit/test_con.py b/pyomo/core/tests/unit/test_con.py index 8c58d8663a7..dd3ffe7aa77 100644 --- a/pyomo/core/tests/unit/test_con.py +++ b/pyomo/core/tests/unit/test_con.py @@ -38,6 +38,7 @@ simple_constraint_rule, inequality, ) +from pyomo.common.log import LoggingIntercept from pyomo.core.expr import ( SumExpression, EqualityExpression, @@ -1570,6 +1571,26 @@ def test_unconstructed_singleton(self): self.assertEqual(a.strict_lower, False) self.assertEqual(a.strict_upper, False) + def test_deprecated_rule_attribute(self): + def rule(m): + return m.x <= 0 + + def new_rule(m): + return m.x >= 0 + + m = ConcreteModel() + m.x = Var() + m.con = Constraint(rule=rule) + + self.assertIs(m.con.rule._fcn, rule) + with LoggingIntercept() as LOG: + m.con.rule = new_rule + self.assertIn( + "DEPRECATED: The 'Constraint.rule' attribute will be made read-only", + LOG.getvalue(), + ) + self.assertIs(m.con.rule, new_rule) + def test_rule(self): def rule1(model): return Constraint.Skip diff --git a/pyomo/core/tests/unit/test_logical_constraint.py b/pyomo/core/tests/unit/test_logical_constraint.py index 84174a16925..857c45570a5 100644 --- a/pyomo/core/tests/unit/test_logical_constraint.py +++ b/pyomo/core/tests/unit/test_logical_constraint.py @@ -358,6 +358,26 @@ def test_statement_in_Disjunct_with_logical_to_linear(self): self.check_lor_on_disjunct(model, model.disj.disjuncts[1], model.y, model.z) # TODO look to test_con.py for inspiration + def test_deprecated_rule_attribute(self): + def rule(m): + return m.x.implies(m.x) + + def new_rule(m): + return m.x.implies(~m.x) + + m = ConcreteModel() + m.x = BooleanVar() + m.con = LogicalConstraint(rule=rule) + + self.assertIs(m.con.rule._fcn, rule) + with LoggingIntercept() as LOG: + m.con.rule = new_rule + self.assertIn( + "DEPRECATED: The 'LogicalConstraint.rule' attribute will be made " + "read-\nonly", + LOG.getvalue(), + ) + self.assertIs(m.con.rule, new_rule) class TestLogicalConstraintList(unittest.TestCase): diff --git a/pyomo/core/tests/unit/test_obj.py b/pyomo/core/tests/unit/test_obj.py index 52125107175..ea832e36917 100644 --- a/pyomo/core/tests/unit/test_obj.py +++ b/pyomo/core/tests/unit/test_obj.py @@ -21,7 +21,7 @@ currdir = dirname(abspath(__file__)) + os.sep import pyomo.common.unittest as unittest - +from pyomo.common.log import LoggingIntercept from pyomo.environ import ( ConcreteModel, AbstractModel, @@ -817,6 +817,26 @@ def rule1(model, i): except Exception: self.fail("Error generating objective") + def test_deprecated_rule_attribute(self): + def rule(m): + return m.x + + def new_rule(m): + return -m.x + + m = ConcreteModel() + m.x = Var() + m.obj = Objective(rule=rule) + + self.assertIs(m.obj.rule._fcn, rule) + with LoggingIntercept() as LOG: + m.obj.rule = new_rule + self.assertIn( + "DEPRECATED: The 'Objective.rule' attribute will be made read-only", + LOG.getvalue(), + ) + self.assertIs(m.obj.rule, new_rule) + def test_abstract_index(self): model = AbstractModel() model.A = Set() From de281d4c4d9b48bf2b73ce7422c13ff72a230bc3 Mon Sep 17 00:00:00 2001 From: John Siirola Date: Mon, 21 Jul 2025 11:10:27 -0600 Subject: [PATCH 10/10] Add test for constraint kwargs --- pyomo/core/tests/unit/test_con.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/pyomo/core/tests/unit/test_con.py b/pyomo/core/tests/unit/test_con.py index dd3ffe7aa77..75c5f211073 100644 --- a/pyomo/core/tests/unit/test_con.py +++ b/pyomo/core/tests/unit/test_con.py @@ -1634,6 +1634,16 @@ def rule1(model): except ValueError: pass + def test_rule_kwargs(self): + m = ConcreteModel() + m.x = Var() + + @m.Constraint(rhs=5) + def c(m, *, rhs): + return m.x <= rhs + + self.assertExpressionsEqual(m.c.expr, m.x <= 5) + def test_tuple_constraint_create(self): def rule1(model): return (0.0, model.x)