Coverage for backend/ahuora-builder/src/ahuora_builder/methods/expression_parsing.py: 91%
171 statements
« prev ^ index » next coverage.py v7.10.7, created at 2026-07-22 05:22 +0000
« prev ^ index » next coverage.py v7.10.7, created at 2026-07-22 05:22 +0000
1"""Parse factory-emitted expression strings into Pyomo expressions.
3Supported expression-language functions are intentionally narrow. The Django
4factory emits lowercase ``min(arg, arg, ...)`` and ``max(arg, arg, ...)`` for
5property-key aggregates, plus ``convert(expr, unit)`` when generated formulas
6need an explicit unit basis. Uppercase or mixed-case variants remain invalid so
7that user-facing aggregate names are resolved before expressions reach the
8builder.
9"""
11import re
12from typing import Any
13from pyomo.environ import Expr_if, Expression, Param
14from pyomo.core.base.expression import ExpressionData
15from pyomo.core.base.units_container import units as pyomo_units, _PyomoUnit
16from idaes.core.util.math import smooth_min
17from sympy import Symbol
18from sympy.parsing.sympy_parser import parse_expr
19from pyomo.core.expr.sympy_tools import sympy2pyomo_expression, PyomoSympyBimap
20from pyomo.core.base.indexed_component import IndexedComponent
21from ahuora_builder.properties_manager import PropertiesManager
22from ahuora_builder_types.units import register_project_units
23from .slice_manipulation import is_scalar_reference
24class ExpressionParsingError(Exception):
25 """Custom exception for errors during expression parsing."""
26 pass
28# add Ahuora-specific units to Pyomo's Pint registry
29ureg = pyomo_units._pint_registry
30register_project_units(ureg.define)
32# Builder function grammar: lowercase special function identifiers followed by
33# a parenthesized comma-separated argument list. Uppercase or mixed-case calls
34# are rejected explicitly instead of being handed to SymPy.
35SPECIAL_FUNCTION_NAMES = {"min", "max", "convert"}
36SPECIAL_FUNCTION_PATTERN = re.compile(r"\b(min|max|convert)\s*\(")
37SPECIAL_FUNCTION_CASE_VARIANT_PATTERN = re.compile(r"\b([A-Za-z_][A-Za-z0-9_]*)\s*\(")
38SMOOTH_MIN_EPSILON = 1e-6
41def handle_special_chars(expr: str) -> str:
42 # replace special characters so they can be parsed
43 expr = expr.replace("^", "**")
44 expr = expr.replace("$", "dollar")
46 return expr
49def get_property_from_id(fs, property_id, time_index):
50 """Return the Pyomo property data object referenced by a property id."""
52 properties_map : PropertiesManager = fs.properties_map
53 pyomo_object: IndexedComponent = properties_map.get_component(property_id)
55 if pyomo_object is None: 55 ↛ 56line 55 didn't jump to line 56 because the condition on line 55 was never true
56 raise ValueError(f"Symbol with id {property_id} not found in model")
57 # check if this is a time-indexed var, and if so get the value at the given time index
58 if is_scalar_reference(pyomo_object):
59 # reference with index None
60 return pyomo_object[None]
61 elif pyomo_object.index_set() == fs.time: 61 ↛ 64line 61 didn't jump to line 64 because the condition on line 61 was always true
62 return pyomo_object[time_index]
63 else:
64 raise NotImplementedError("Only 0D and 1D time-indexed properties are supported in expressions")
67def get_property_expression_for_symbol(fs, property_id, time_index):
68 """Return the value expression parsing should substitute for an ``id_`` symbol."""
70 value = get_property_from_id(fs, property_id, time_index)
71 if isinstance(value, ExpressionData):
72 return value.expr
73 return value
76def evaluate_symbol(fs, symbol: str,time_index) -> Any:
77 if symbol.lower() == "time" or symbol.lower() == "t": 77 ↛ 78line 77 didn't jump to line 78 because the condition on line 77 was never true
78 return float(time_index)
79 if symbol.startswith("id_"):
80 # get the property from flowsheet properties_map
81 id = int(symbol[3:])
82 return get_property_expression_for_symbol(fs, id, time_index)
83 else:
84 # assume its a unit, eg. "m" or "kg"
85 # get the unit from pint, pyomo's units library
86 ureg = pyomo_units._pint_registry
87 pint_unit = getattr(ureg, symbol)
88 pyomo_unit = _PyomoUnit(pint_unit, ureg)
89 # We want people to write expressions such as (10 * W + 5 * kW). Pyomo doesn't natively support this,
90 # so we can always convert to base units.
91 if symbol == "delta_degC" or symbol == "delta_degF":
92 # special case, because degC is not a base unit
93 return 1 * pyomo_unit
94 #return _PyomoUnit(ureg.delta_degC)
95 elif symbol == "degC" or symbol == "degF": 95 ↛ 98line 95 didn't jump to line 98 because the condition on line 95 was never true
96 # throw an error (we do not support this, as it is unclear what to do)
97 # https://pyomo.readthedocs.io/en/6.8.1/explanation/modeling/units.html
98 raise ValueError(f"Use relative temperature units (delta_degC, delta_degF) or absolute temperature units (K, degF). Cannot use {symbol} as addition and multiplication is inconsistent on non-absolute units")
99 scale_factor, base_units = ureg.get_base_units(pint_unit, check_nonmult=True) # TODO: handle degC etc.
100 base_pyomo_unit = _PyomoUnit(base_units, ureg)
101 return pyomo_units.convert( 1 * pyomo_unit, to_units=base_pyomo_unit)
104def split_function_args(arguments: str) -> list[str]:
105 """Split function arguments without treating nested commas as separators."""
107 args = []
108 start = 0
109 depth = 0
110 for index, char in enumerate(arguments):
111 if char == "(":
112 depth += 1
113 elif char == ")":
114 depth -= 1
115 if depth < 0: 115 ↛ 116line 115 didn't jump to line 116 because the condition on line 115 was never true
116 raise ExpressionParsingError(
117 f"Unexpected closing parenthesis in function arguments '{arguments}'"
118 )
119 elif char == "," and depth == 0:
120 args.append(arguments[start:index].strip())
121 start = index + 1
122 args.append(arguments[start:].strip())
123 if depth != 0: 123 ↛ 124line 123 didn't jump to line 124 because the condition on line 123 was never true
124 raise ExpressionParsingError(
125 f"Unbalanced parentheses in function arguments '{arguments}'"
126 )
127 return args
130def find_matching_paren(expression: str, open_index: int) -> int:
131 """Find the closing parenthesis matching the opening parenthesis at open_index."""
133 depth = 0
134 for index in range(open_index, len(expression)):
135 if expression[index] == "(":
136 depth += 1
137 elif expression[index] == ")":
138 depth -= 1
139 if depth == 0:
140 return index
141 raise ExpressionParsingError(f"Unclosed function call in expression '{expression}'")
144def _smooth_min_epsilon(model, expression: Any) -> Any:
145 """Return a small smoothing term compatible with IDAES ``smooth_min``.
147 IDAES accepts a numeric epsilon for unitless expressions, but unitful
148 expressions need a Pyomo ``Param`` carrying the same units so Pyomo can
149 prove ``sqrt((a - b)^2 + eps^2)`` is dimensionally consistent.
150 """
152 expression_units = pyomo_units.get_units(expression)
153 if expression_units is None: 153 ↛ 154line 153 didn't jump to line 154 because the condition on line 153 was never true
154 return SMOOTH_MIN_EPSILON
156 unit_key = str(expression_units)
157 epsilon_cache = getattr(model, "_ahuora_smooth_min_epsilon_by_unit", None)
158 if epsilon_cache is None:
159 epsilon_cache = {}
160 setattr(model, "_ahuora_smooth_min_epsilon_by_unit", epsilon_cache)
161 if unit_key in epsilon_cache:
162 return epsilon_cache[unit_key]
164 component_index = len(epsilon_cache)
165 while True:
166 component_name = f"_ahuora_smooth_min_epsilon_{component_index}"
167 if not hasattr(model, component_name): 167 ↛ 169line 167 didn't jump to line 169 because the condition on line 167 was always true
168 break
169 component_index += 1
171 epsilon = Param(initialize=SMOOTH_MIN_EPSILON, units=expression_units)
172 model.add_component(component_name, epsilon)
173 epsilon_cache[unit_key] = epsilon
174 return epsilon
177def _pairwise_min_max(model, function_name: str, args: list[Any]) -> Any:
178 """Build a left-folded expression-language min/max expression.
180 ``min`` lowers to IDAES ``smooth_min`` so objective functions remain
181 differentiable. ``max`` keeps the existing exact conditional semantics
182 until there is a product requirement to smooth it too.
183 """
185 expression = args[0]
186 for arg in args[1:]:
187 expression_units = pyomo_units.get_units(expression)
188 arg_units = pyomo_units.get_units(arg)
189 if expression_units is not None and arg_units is not None: 189 ↛ 191line 189 didn't jump to line 191 because the condition on line 189 was always true
190 arg = pyomo_units.convert(arg, to_units=expression_units)
191 if function_name == "min":
192 expression = smooth_min(expression, arg, eps=_smooth_min_epsilon(model, expression))
193 else:
194 expression = Expr_if(IF_=expression >= arg, THEN_=expression, ELSE_=arg)
195 return expression
198def _convert_to_units(expression: Any, target_quantity: Any) -> Any:
199 """Convert an expression to the units carried by a parsed unit quantity."""
201 target_units = pyomo_units.get_units(target_quantity)
202 if target_units is None: 202 ↛ 203line 202 didn't jump to line 203 because the condition on line 202 was never true
203 raise ExpressionParsingError("convert target must include units")
204 return pyomo_units.convert(expression, to_units=target_units)
207def replace_special_functions(expression: str, model, time_index) -> tuple[str, dict[str, Any]]:
208 """Lower expression-language special calls before handing the rest to SymPy.
210 SymPy can parse the names ``min`` and ``max``, but the result is not a Pyomo
211 expression. Django emits lowercase builder expression-language calls for
212 aggregate ``MIN``/``MAX`` and generated unit coercions; this pass lowers
213 those calls into Pyomo placeholders whose expressions are returned by the
214 bimap unchanged.
215 """
217 special_symbols = {}
219 while True:
220 case_variant = next(
221 (
222 match
223 for match in SPECIAL_FUNCTION_CASE_VARIANT_PATTERN.finditer(expression)
224 if match.group(1).lower() in SPECIAL_FUNCTION_NAMES
225 and match.group(1) != match.group(1).lower()
226 ),
227 None,
228 )
229 if case_variant is not None:
230 raise ExpressionParsingError(
231 f"{case_variant.group(1)} must be written as lowercase "
232 f"{case_variant.group(1).lower()}"
233 )
235 match = SPECIAL_FUNCTION_PATTERN.search(expression)
236 if match is None:
237 return expression, special_symbols
239 open_index = match.end() - 1
240 close_index = find_matching_paren(expression, open_index)
241 function_name = match.group(1)
242 args = split_function_args(expression[open_index + 1:close_index])
243 if any(arg == "" for arg in args):
244 raise ExpressionParsingError(f"{function_name} arguments cannot be empty")
245 if function_name in {"min", "max"} and len(args) < 2:
246 raise ExpressionParsingError(
247 f"{function_name} expects at least 2 arguments, got {len(args)}"
248 )
249 if function_name == "convert" and len(args) != 2:
250 raise ExpressionParsingError(
251 f"convert expects exactly 2 arguments, got {len(args)}"
252 )
253 parsed_args = [parse_expression(arg, model, time_index) for arg in args]
254 placeholder = f"__ahuora_special_{len(special_symbols)}"
255 if function_name == "convert":
256 special_symbols[placeholder] = _convert_to_units(parsed_args[0], parsed_args[1])
257 else:
258 special_symbols[placeholder] = _pairwise_min_max(model, function_name, parsed_args)
259 expression = expression[:match.start()] + placeholder + expression[close_index + 1:]
262class PyomoSympyMap(PyomoSympyBimap):
263 """Map expression symbols to Pyomo objects during SymPy-to-Pyomo conversion."""
265 def __init__(self, model,time_index, special_symbols: dict[str, Any] | None = None):
266 self.model = model
267 self.time_index = time_index
268 self.special_symbols = special_symbols or {}
270 def getPyomoSymbol(self, sympy_object: Symbol, default=None):
271 if not isinstance(sympy_object, Symbol):
272 return None # It's not in pyomo, e.g a number or something
273 if sympy_object.name in self.special_symbols:
274 return self.special_symbols[sympy_object.name]
275 return evaluate_symbol(self.model, sympy_object.name, self.time_index)
277 def getSympySymbol(self, pyomo_object, default=None):
278 raise NotImplementedError(
279 "getSympySymbol not implemented, because it shouldn't be needed"
280 )
281 # we don't care, it only needs to go one way
283 def sympyVars(self):
284 raise NotImplementedError(
285 "sympyVars not implemented, because it shouldn't be needed"
286 )
289def parse_expression(expression, model,time_index) -> Expression:
290 """Parse an Ahuora expression string into a Pyomo expression."""
292 # use the bimap to get the correct pyomo object for each symbol
293 try:
294 expression = handle_special_chars(expression)
295 expression, special_symbols = replace_special_functions(expression, model, time_index)
296 bimap = PyomoSympyMap(model,time_index, special_symbols)
297 sympy_expr = parse_expr(expression)
298 pyomo_expr = sympy2pyomo_expression(sympy_expr, bimap)
299 except Exception as e:
300 raise ExpressionParsingError(f"{expression}: error: {e}")
301 return pyomo_expr