From 7e41f4bd21afb0ee1b750c03cd126a24298f2e5f Mon Sep 17 00:00:00 2001 From: benflexcompute Date: Fri, 30 May 2025 02:15:24 +0000 Subject: [PATCH 01/19] Cross in Pyhton mode works --- flow360/__init__.py | 2 + .../simulation/blueprint/core/generator.py | 8 +++ .../simulation/blueprint/flow360/symbols.py | 31 ++-------- .../blueprint/functions/vector_functions.py | 62 +++++++++++++++++++ flow360/component/simulation/user_code.py | 8 +-- .../variables/solution_variables.py | 8 ++- 6 files changed, 89 insertions(+), 30 deletions(-) create mode 100644 flow360/component/simulation/blueprint/functions/vector_functions.py diff --git a/flow360/__init__.py b/flow360/__init__.py index 8fc37725b..d8a2ec67e 100644 --- a/flow360/__init__.py +++ b/flow360/__init__.py @@ -9,6 +9,7 @@ from flow360.component.project import Project from flow360.component.simulation import migration, services from flow360.component.simulation import units as u +from flow360.component.simulation.blueprint.functions.vector_functions import cross from flow360.component.simulation.entity_info import GeometryEntityInfo from flow360.component.simulation.meshing_param.edge_params import ( AngleBasedRefinement, @@ -277,4 +278,5 @@ "Transformation", "WallRotation", "UserVariable", + "cross", ] diff --git a/flow360/component/simulation/blueprint/core/generator.py b/flow360/component/simulation/blueprint/core/generator.py index c10b3a2eb..4dedea52c 100644 --- a/flow360/component/simulation/blueprint/core/generator.py +++ b/flow360/component/simulation/blueprint/core/generator.py @@ -12,6 +12,7 @@ ListComp, Name, RangeCall, + Subscript, Tuple, UnaryOp, ) @@ -177,6 +178,10 @@ def _list_comp(expr, syntax, name_translator): raise ValueError("List comprehensions are only supported for Python target syntax") +def _subscript(expr, syntax, name_translator): + return f"{expr.value.id}[{expr.slice.value}]" + + def expr_to_code( expr: Any, syntax: TargetSyntax = TargetSyntax.PYTHON, @@ -214,6 +219,9 @@ def expr_to_code( if isinstance(expr, ListComp): return _list_comp(expr, syntax, name_translator) + if isinstance(expr, Subscript): + return _subscript(expr, syntax, name_translator) + raise ValueError(f"Unsupported expression type: {type(expr)}") diff --git a/flow360/component/simulation/blueprint/flow360/symbols.py b/flow360/component/simulation/blueprint/flow360/symbols.py index c34b0ec05..b7002ca8a 100644 --- a/flow360/component/simulation/blueprint/flow360/symbols.py +++ b/flow360/component/simulation/blueprint/flow360/symbols.py @@ -26,12 +26,9 @@ def _import_units(_: str) -> Any: return u -def _import_numpy(_: str) -> Any: - """Import and return allowed numpy callables""" - return np - - WHITELISTED_CALLABLES = { + # TODO: Move functions into blueprint. + "flow360_math_functions": {"prefix": "fl.", "callables": ["cross"], "evaluate": True}, "flow360.units": {"prefix": "u.", "callables": _unit_list(), "evaluate": True}, "flow360.control": { "prefix": "control.", @@ -131,21 +128,6 @@ def _import_numpy(_: str) -> Any: ], "evaluate": False, }, - "numpy": { - "prefix": "np.", - "callables": [ - "array", - "sin", - "tan", - "arcsin", - "arccos", - "arctan", - "dot", - "cross", - "sqrt", - ], - "evaluate": True, - }, } # Define allowed modules @@ -153,24 +135,23 @@ def _import_numpy(_: str) -> Any: ALLOWED_CALLABLES = { **{ - f"{group['prefix']}{name}": None + f"{group['prefix']}{callable}": None for group in WHITELISTED_CALLABLES.values() - for name in group["callables"] + for callable in group["callables"] }, } EVALUATION_BLACKLIST = { **{ - f"{group['prefix']}{name}": None + f"{group['prefix']}{callable}": None for group in WHITELISTED_CALLABLES.values() - for name in group["callables"] + for callable in group["callables"] if not group["evaluate"] }, } IMPORT_FUNCTIONS = { "u": _import_units, - "np": _import_numpy, } resolver = CallableResolver( diff --git a/flow360/component/simulation/blueprint/functions/vector_functions.py b/flow360/component/simulation/blueprint/functions/vector_functions.py new file mode 100644 index 000000000..0169c90b0 --- /dev/null +++ b/flow360/component/simulation/blueprint/functions/vector_functions.py @@ -0,0 +1,62 @@ +import numpy as np +from unyt import ucross, unyt_array + +from flow360.component.simulation.user_code import Expression, Variable + +# ***** General principle ***** +# 1. Defer evaluation of the real cross operation only to when needed (translator). This helps preserving the original user input + + +# pow +def cross(foo, bar): + if isinstance(foo, np.ndarray) and isinstance(bar, np.ndarray): + return np.cross(foo, bar) + + if isinstance(foo, np.ndarray) and isinstance(bar, unyt_array): + return np.cross(foo, bar) * bar.units + + if isinstance(foo, np.ndarray) and isinstance(bar, unyt_array): + return ucross(foo, bar) + + # What else than `SolverVariable`? `UserVariable`? `Expression`? + # How to support symbolic expression now that we get rid of numpy interop? + # Do we only support 1 layer of module? + # Consistent serialize and deserialize + if isinstance(foo, Variable): + foo_length = len(foo.value) + foo = Expression(expression=str(foo)) + else: + foo_length = len(foo) + + if isinstance(bar, Variable): + bar_length = len(bar.value) + bar = Expression(expression=str(bar)) + else: + bar_length = len(bar) + + assert foo_length == bar_length, f"Different len {foo_length} vs {bar_length}" + + if len(foo) == 3: + return [ + bar[2] * foo[1] - bar[1] * foo[2], + bar[0] * foo[2] - bar[2] * foo[0], + bar[0] * foo[1] - bar[1] * foo[0], + ] + raise NotImplementedError() + + # foo_processed = _preprocess(foo) + # bar_processed = _preprocess(bar) + # + # if len(foo_processed) == 2: + # return Expression( + # expression=bar_processed[1] * foo_processed[0] - bar_processed[0] * foo_processed[1] + # ) + # elif len(foo_processed) == 3: + # return Expression( + # expression=[ + # bar_processed[2] * foo_processed[1] - bar_processed[1] * foo_processed[2], + # bar_processed[0] * foo_processed[2] - bar_processed[2] * foo_processed[0], + # bar_processed[0] * foo_processed[1] - bar_processed[1] * foo_processed[0], + # ] + # ) + # return np.cross(foo_processed, bar_processed) diff --git a/flow360/component/simulation/user_code.py b/flow360/component/simulation/user_code.py index 09b29b211..76817791b 100644 --- a/flow360/component/simulation/user_code.py +++ b/flow360/component/simulation/user_code.py @@ -20,7 +20,7 @@ _global_ctx: EvaluationContext = EvaluationContext(resolver) _user_variables: set[str] = set() -_solver_variables: dict[str, str] = {} +_solver_variable_name_map: dict[str, str] = {} def _is_number_string(s: str) -> bool: @@ -268,7 +268,7 @@ class SolverVariable(Variable): def update_context(cls, value): """Auto updating context when new variable is declared""" _global_ctx.set(value.name, value.value) - _solver_variables[value.name] = ( + _solver_variable_name_map[value.name] = ( value.solver_name if value.solver_name is not None else value.name ) return value @@ -370,8 +370,8 @@ def to_solver_code(self, params): """Convert to solver readable code.""" def translate_symbol(name): - if name in _solver_variables: - return _solver_variables[name] + if name in _solver_variable_name_map: + return _solver_variable_name_map[name] if name in _user_variables: value = _global_ctx.get(name) diff --git a/flow360/component/simulation/variables/solution_variables.py b/flow360/component/simulation/variables/solution_variables.py index e97f28691..e1cee6c1a 100644 --- a/flow360/component/simulation/variables/solution_variables.py +++ b/flow360/component/simulation/variables/solution_variables.py @@ -1,5 +1,7 @@ """Solution variables of Flow360""" +import unyt as u + from flow360.component.simulation.user_code import SolverVariable mut = SolverVariable(name="solution.mut", value=float("NaN")) # Turbulent viscosity @@ -34,7 +36,11 @@ residualHeatSolver = SolverVariable( name="solution.residualHeatSolver", value=float("NaN") ) # Residual for heat equation -coordinate = SolverVariable(name="solution.coordinate", value=float("NaN")) # Grid coordinates + +coordinate = SolverVariable( + name="solution.coordinate", + value=[float("NaN"), float("NaN"), float("NaN")] * u.m, +) # Grid coordinates bet_thrust = SolverVariable( name="solution.bet_thrust", value=float("NaN") From e7347a3ecce3f71a1afb85c407e3085b6812dfce Mon Sep 17 00:00:00 2001 From: benflexcompute Date: Fri, 30 May 2025 19:15:19 +0000 Subject: [PATCH 02/19] WIP now needing for refactor to enable function on-demand import since current structure causes circular import --- .../simulation/blueprint/core/resolver.py | 1 + .../simulation/blueprint/flow360/symbols.py | 9 +++- .../blueprint/functions/vector_functions.py | 52 ++++++++++++------- flow360/component/simulation/user_code.py | 13 +++++ .../variables/solution_variables.py | 5 ++ 5 files changed, 60 insertions(+), 20 deletions(-) diff --git a/flow360/component/simulation/blueprint/core/resolver.py b/flow360/component/simulation/blueprint/core/resolver.py index f163683e9..a77fa54db 100644 --- a/flow360/component/simulation/blueprint/core/resolver.py +++ b/flow360/component/simulation/blueprint/core/resolver.py @@ -77,6 +77,7 @@ def get_callable(self, qualname: str) -> Callable[..., Any]: if qualname in self._callable_builtins: return obj # Try importing if it's a whitelisted callable + print(">> self._callable_builtins.keys(): ", self._callable_builtins.keys()) if qualname in self._callable_builtins: for names, import_func in self._import_builtins.items(): if module_name in names: diff --git a/flow360/component/simulation/blueprint/flow360/symbols.py b/flow360/component/simulation/blueprint/flow360/symbols.py index b7002ca8a..d379affbc 100644 --- a/flow360/component/simulation/blueprint/flow360/symbols.py +++ b/flow360/component/simulation/blueprint/flow360/symbols.py @@ -9,6 +9,7 @@ from flow360.component.simulation import units as u from flow360.component.simulation.blueprint.core.resolver import CallableResolver +from flow360.component.simulation.blueprint.functions import vector_functions def _unit_list(): @@ -26,6 +27,10 @@ def _import_units(_: str) -> Any: return u +def _import_functions(_): + return vector_functions + + WHITELISTED_CALLABLES = { # TODO: Move functions into blueprint. "flow360_math_functions": {"prefix": "fl.", "callables": ["cross"], "evaluate": True}, @@ -131,7 +136,7 @@ def _import_units(_: str) -> Any: } # Define allowed modules -ALLOWED_MODULES = {"u", "np", "control", "solution"} +ALLOWED_MODULES = {"u", "fl", "control", "solution"} ALLOWED_CALLABLES = { **{ @@ -150,8 +155,10 @@ def _import_units(_: str) -> Any: }, } +# Note: Keys of IMPORT_FUNCTIONS needs to be consistent with ALLOWED_MODULES IMPORT_FUNCTIONS = { "u": _import_units, + "fl": _import_functions, } resolver = CallableResolver( diff --git a/flow360/component/simulation/blueprint/functions/vector_functions.py b/flow360/component/simulation/blueprint/functions/vector_functions.py index 0169c90b0..7b19f33df 100644 --- a/flow360/component/simulation/blueprint/functions/vector_functions.py +++ b/flow360/component/simulation/blueprint/functions/vector_functions.py @@ -1,14 +1,15 @@ import numpy as np from unyt import ucross, unyt_array -from flow360.component.simulation.user_code import Expression, Variable - # ***** General principle ***** # 1. Defer evaluation of the real cross operation only to when needed (translator). This helps preserving the original user input # pow def cross(foo, bar): + + from flow360.component.simulation.user_code import Expression, Variable + if isinstance(foo, np.ndarray) and isinstance(bar, np.ndarray): return np.cross(foo, bar) @@ -22,26 +23,39 @@ def cross(foo, bar): # How to support symbolic expression now that we get rid of numpy interop? # Do we only support 1 layer of module? # Consistent serialize and deserialize - if isinstance(foo, Variable): - foo_length = len(foo.value) - foo = Expression(expression=str(foo)) - else: - foo_length = len(foo) - - if isinstance(bar, Variable): - bar_length = len(bar.value) - bar = Expression(expression=str(bar)) - else: - bar_length = len(bar) + def _preprocess_input(baz): + if isinstance(baz, Variable): + if isinstance(baz.value, Expression): + return _preprocess_input(baz.value) + baz_length = len(baz.value) + baz = Expression(expression=str(baz)) + elif isinstance(baz, Expression): + vector_form = baz.as_vector() + if not vector_form: # I am scalar expression. + raise ValueError("fl.cross() can not take in scalar expression.") + + baz_length = len(vector_form) + baz = vector_form + else: + baz_length = len(baz) + + return baz, baz_length + + foo, foo_length = _preprocess_input(foo) + bar, bar_length = _preprocess_input(bar) + print("\n>>>> foo, foo_length = ", foo, foo_length) + print(">>>> bar, bar_length = ", bar, bar_length) assert foo_length == bar_length, f"Different len {foo_length} vs {bar_length}" - if len(foo) == 3: - return [ - bar[2] * foo[1] - bar[1] * foo[2], - bar[0] * foo[2] - bar[2] * foo[0], - bar[0] * foo[1] - bar[1] * foo[0], - ] + if foo_length == 3: + return Expression.model_validate( + [ + bar[2] * foo[1] - bar[1] * foo[2], + bar[0] * foo[2] - bar[2] * foo[0], + bar[0] * foo[1] - bar[1] * foo[0], + ] + ) raise NotImplementedError() # foo_processed = _preprocess(foo) diff --git a/flow360/component/simulation/user_code.py b/flow360/component/simulation/user_code.py index 76817791b..79f6117fc 100644 --- a/flow360/component/simulation/user_code.py +++ b/flow360/component/simulation/user_code.py @@ -2,6 +2,7 @@ from __future__ import annotations +import ast import re from numbers import Number from typing import Annotated, Any, Generic, Iterable, Literal, Optional, TypeVar, Union @@ -492,6 +493,17 @@ def __str__(self): def __repr__(self): return f"Expression({self.expression})" + def as_vector(self): + """Parse the expression(str) and if possible, return list of `Expression` instances""" + tree = ast.parse(self.expression, mode="eval") + + if isinstance(tree.body, ast.List): + result = [ast.unparse(elt) for elt in tree.body.elts] + else: + return None + + return [Expression.model_validate(item) for item in result] + T = TypeVar("T") @@ -541,6 +553,7 @@ def _validation_attempt_(input_value): return deserialized def _serializer(value, info) -> dict: + print(">>> Inside serializer: value = ", value) if isinstance(value, Expression): serialized = SerializedValueOrExpression(type_name="expression") diff --git a/flow360/component/simulation/variables/solution_variables.py b/flow360/component/simulation/variables/solution_variables.py index e1cee6c1a..0c808e01b 100644 --- a/flow360/component/simulation/variables/solution_variables.py +++ b/flow360/component/simulation/variables/solution_variables.py @@ -42,6 +42,11 @@ value=[float("NaN"), float("NaN"), float("NaN")] * u.m, ) # Grid coordinates +velocity = SolverVariable( + name="solution.velocity", + value=[float("NaN"), float("NaN"), float("NaN")] * u.m / u.s, +) + bet_thrust = SolverVariable( name="solution.bet_thrust", value=float("NaN") ) # Thrust force for BET disk From be624c4890ac42abd374d906c913f2204d8e8bd7 Mon Sep 17 00:00:00 2001 From: benflexcompute Date: Mon, 2 Jun 2025 00:27:05 +0000 Subject: [PATCH 03/19] Some comments --- flow360/component/simulation/blueprint/core/resolver.py | 1 - .../simulation/blueprint/functions/vector_functions.py | 4 +++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/flow360/component/simulation/blueprint/core/resolver.py b/flow360/component/simulation/blueprint/core/resolver.py index a77fa54db..f163683e9 100644 --- a/flow360/component/simulation/blueprint/core/resolver.py +++ b/flow360/component/simulation/blueprint/core/resolver.py @@ -77,7 +77,6 @@ def get_callable(self, qualname: str) -> Callable[..., Any]: if qualname in self._callable_builtins: return obj # Try importing if it's a whitelisted callable - print(">> self._callable_builtins.keys(): ", self._callable_builtins.keys()) if qualname in self._callable_builtins: for names, import_func in self._import_builtins.items(): if module_name in names: diff --git a/flow360/component/simulation/blueprint/functions/vector_functions.py b/flow360/component/simulation/blueprint/functions/vector_functions.py index 7b19f33df..7e904afa2 100644 --- a/flow360/component/simulation/blueprint/functions/vector_functions.py +++ b/flow360/component/simulation/blueprint/functions/vector_functions.py @@ -7,7 +7,9 @@ # pow def cross(foo, bar): - + """Customized Cross function to work with the `Expression` and Variables""" + # TODO: Move global import here to avoid circular import. + # Cannot find good way of breaking the circular import otherwise. from flow360.component.simulation.user_code import Expression, Variable if isinstance(foo, np.ndarray) and isinstance(bar, np.ndarray): From 7f2f45b99067c6e6ab64a18eb2771b955fa0ae12 Mon Sep 17 00:00:00 2001 From: benflexcompute Date: Mon, 2 Jun 2025 01:57:04 +0000 Subject: [PATCH 04/19] Got symbolic evaluation to work but very HACKY --- .../simulation/blueprint/core/expressions.py | 12 ++++++++++-- .../blueprint/functions/vector_functions.py | 15 ++++++++++++--- flow360/component/simulation/user_code.py | 14 ++++++++++++-- 3 files changed, 34 insertions(+), 7 deletions(-) diff --git a/flow360/component/simulation/blueprint/core/expressions.py b/flow360/component/simulation/blueprint/core/expressions.py index e8091e24a..3f1d48477 100644 --- a/flow360/component/simulation/blueprint/core/expressions.py +++ b/flow360/component/simulation/blueprint/core/expressions.py @@ -52,14 +52,22 @@ class Name(Expression): type: Literal["Name"] = "Name" id: str - def evaluate(self, context: EvaluationContext, strict: bool) -> Any: + def evaluate(self, context: EvaluationContext, strict: bool, symbolic: bool = True) -> Any: + # TODO: Stop hardcoding the symbolic flag if strict and not context.can_evaluate(self.id): raise ValueError(f"Name '{self.id}' cannot be evaluated at client runtime") value = context.get(self.id) - + print("get value from name: ", value) # Recursively evaluate if the returned value is evaluable if isinstance(value, Evaluable): + print("IS an Evaluable, value type = ", type(value), " ", value, "|", self.id) value = value.evaluate(context, strict) + else: + from flow360.component.simulation.user_code import Variable + + # Very ugly implementation + if self.id.startswith(("solution", "control")) and symbolic: + return Variable(name=self.id, value=[1, 2, 3]) return value diff --git a/flow360/component/simulation/blueprint/functions/vector_functions.py b/flow360/component/simulation/blueprint/functions/vector_functions.py index 7e904afa2..469182024 100644 --- a/flow360/component/simulation/blueprint/functions/vector_functions.py +++ b/flow360/component/simulation/blueprint/functions/vector_functions.py @@ -10,6 +10,7 @@ def cross(foo, bar): """Customized Cross function to work with the `Expression` and Variables""" # TODO: Move global import here to avoid circular import. # Cannot find good way of breaking the circular import otherwise. + print("input : \n", foo, "type = ", type(foo), "\n", bar, "type = ", type(bar), "\n") from flow360.component.simulation.user_code import Expression, Variable if isinstance(foo, np.ndarray) and isinstance(bar, np.ndarray): @@ -30,12 +31,13 @@ def _preprocess_input(baz): if isinstance(baz, Variable): if isinstance(baz.value, Expression): return _preprocess_input(baz.value) + # value baz_length = len(baz.value) baz = Expression(expression=str(baz)) elif isinstance(baz, Expression): vector_form = baz.as_vector() if not vector_form: # I am scalar expression. - raise ValueError("fl.cross() can not take in scalar expression.") + raise ValueError(f"fl.cross() can not take in scalar expression. {baz} was given") baz_length = len(vector_form) baz = vector_form @@ -49,7 +51,14 @@ def _preprocess_input(baz): print("\n>>>> foo, foo_length = ", foo, foo_length) print(">>>> bar, bar_length = ", bar, bar_length) assert foo_length == bar_length, f"Different len {foo_length} vs {bar_length}" - + print( + ">> HOW??? ", + [ + bar[2] * foo[1] - bar[1] * foo[2], + bar[0] * foo[2] - bar[2] * foo[0], + bar[0] * foo[1] - bar[1] * foo[0], + ], + ) if foo_length == 3: return Expression.model_validate( [ @@ -58,7 +67,7 @@ def _preprocess_input(baz): bar[0] * foo[1] - bar[1] * foo[0], ] ) - raise NotImplementedError() + raise NotImplementedError("len ==2 not implemented") # foo_processed = _preprocess(foo) # bar_processed = _preprocess(bar) diff --git a/flow360/component/simulation/user_code.py b/flow360/component/simulation/user_code.py index 79f6117fc..e5dd244ab 100644 --- a/flow360/component/simulation/user_code.py +++ b/flow360/component/simulation/user_code.py @@ -494,12 +494,22 @@ def __repr__(self): return f"Expression({self.expression})" def as_vector(self): - """Parse the expression(str) and if possible, return list of `Expression` instances""" + """Parse the expression (str) and if possible, return list of `Expression` instances""" tree = ast.parse(self.expression, mode="eval") - if isinstance(tree.body, ast.List): + # Expression string with list syntax, like "[aa,bb,cc]" result = [ast.unparse(elt) for elt in tree.body.elts] else: + # Expression string with **evaluated result** + # being vector,like "[1,2,3]*u.m", "fl.cross(aa,bb)" + + # TODO: This seems to be a deadlock, here we depend on + # string expression being properly (symbolically) evaluated. + # Hacking so at least "[1,2,3]*u.m" works + result = self.evaluate() + print(">>> result = ", result, type(result)) + if isinstance(self, (list, unyt_array)): + return result return None return [Expression.model_validate(item) for item in result] From 8db30c123b4b507593ef112c80730cb2e184a8de Mon Sep 17 00:00:00 2001 From: Andrzej Krupka Date: Mon, 2 Jun 2025 17:54:56 +0200 Subject: [PATCH 05/19] Refactored expressions module structure to be self-contained (still contains cyclic imports but only runtime, never at init...) --- flow360/__init__.py | 9 +- .../simulation/blueprint/__init__.py | 10 +- .../simulation/blueprint/core/__init__.py | 16 +- .../simulation/blueprint/core/context.py | 12 + .../simulation/blueprint/core/expressions.py | 104 +++++---- .../simulation/blueprint/core/function.py | 2 +- .../simulation/blueprint/core/generator.py | 6 +- .../simulation/blueprint/core/parser.py | 10 +- .../simulation/blueprint/core/resolver.py | 16 -- .../simulation/blueprint/core/statements.py | 62 +++-- .../simulation/blueprint/core/types.py | 11 +- .../simulation/blueprint/flow360/__init__.py | 4 +- .../flow360/expressions.py} | 212 +++++++++++++++--- .../blueprint/flow360/functions/math.py | 7 + .../simulation/blueprint/flow360/symbols.py | 166 -------------- .../flow360/variables/control.py} | 2 +- .../flow360/variables/solution.py} | 2 +- .../blueprint/functions/vector_functions.py | 87 ------- .../simulation/framework/param_utils.py | 2 +- flow360/component/simulation/primitives.py | 2 +- flow360/component/simulation/services.py | 12 +- .../component/simulation/simulation_params.py | 2 +- .../component/simulation/translator/utils.py | 6 +- .../validation_simulation_params.py | 2 +- .../simulation/variables/__init__.py | 0 tests/simulation/test_expressions.py | 15 +- 26 files changed, 353 insertions(+), 426 deletions(-) rename flow360/component/simulation/{user_code.py => blueprint/flow360/expressions.py} (81%) create mode 100644 flow360/component/simulation/blueprint/flow360/functions/math.py delete mode 100644 flow360/component/simulation/blueprint/flow360/symbols.py rename flow360/component/simulation/{variables/control_variables.py => blueprint/flow360/variables/control.py} (96%) rename flow360/component/simulation/{variables/solution_variables.py => blueprint/flow360/variables/solution.py} (97%) delete mode 100644 flow360/component/simulation/blueprint/functions/vector_functions.py delete mode 100644 flow360/component/simulation/variables/__init__.py diff --git a/flow360/__init__.py b/flow360/__init__.py index d8a2ec67e..899ca2e4f 100644 --- a/flow360/__init__.py +++ b/flow360/__init__.py @@ -9,7 +9,7 @@ from flow360.component.project import Project from flow360.component.simulation import migration, services from flow360.component.simulation import units as u -from flow360.component.simulation.blueprint.functions.vector_functions import cross +from flow360.component.simulation.blueprint.flow360.functions.math import cross from flow360.component.simulation.entity_info import GeometryEntityInfo from flow360.component.simulation.meshing_param.edge_params import ( AngleBasedRefinement, @@ -146,12 +146,13 @@ SI_unit_system, imperial_unit_system, ) -from flow360.component.simulation.user_code import UserVariable +from flow360.component.simulation.blueprint.flow360.expressions import UserVariable from flow360.component.simulation.user_defined_dynamics.user_defined_dynamics import ( UserDefinedDynamic, ) -from flow360.component.simulation.variables import control_variables as control -from flow360.component.simulation.variables import solution_variables as solution +from flow360.component.simulation.blueprint.flow360.variables import control +from flow360.component.simulation.blueprint.flow360.variables import solution +from flow360.component.simulation.blueprint.flow360.functions import math from flow360.component.surface_mesh_v2 import SurfaceMeshV2 as SurfaceMesh from flow360.component.volume_mesh import VolumeMeshV2 as VolumeMesh from flow360.environment import Env diff --git a/flow360/component/simulation/blueprint/__init__.py b/flow360/component/simulation/blueprint/__init__.py index 65fecdda6..60d3b5a30 100644 --- a/flow360/component/simulation/blueprint/__init__.py +++ b/flow360/component/simulation/blueprint/__init__.py @@ -6,7 +6,13 @@ function_to_model, ) -from .core.function import Function +from .core.function import BlueprintFunction from .core.types import Evaluable -__all__ = ["Function", "Evaluable", "function_to_model", "model_to_function", "expr_to_model"] +__all__ = [ + "BlueprintFunction", + "Evaluable", + "function_to_model", + "model_to_function", + "expr_to_model", +] diff --git a/flow360/component/simulation/blueprint/core/__init__.py b/flow360/component/simulation/blueprint/core/__init__.py index ec4e79641..0acc1de2d 100644 --- a/flow360/component/simulation/blueprint/core/__init__.py +++ b/flow360/component/simulation/blueprint/core/__init__.py @@ -5,7 +5,7 @@ BinOp, CallModel, Constant, - Expression, + BlueprintExpression, ExpressionType, List, ListComp, @@ -14,7 +14,7 @@ Subscript, Tuple, ) -from .function import Function +from .function import BlueprintFunction from .generator import expr_to_code, model_to_function, stmt_to_code from .parser import function_to_model from .statements import ( @@ -23,7 +23,7 @@ ForLoop, IfElse, Return, - Statement, + BlueprintStatement, StatementType, TupleUnpack, ) @@ -53,7 +53,7 @@ def _model_rebuild() -> None: "TupleUnpack": TupleUnpack, "StatementType": StatementType, # Function type - "Function": Function, + "Function": BlueprintFunction, } # First update expression classes that only depend on ExpressionType @@ -74,7 +74,7 @@ def _model_rebuild() -> None: TupleUnpack.model_rebuild(_types_namespace=namespace) # Finally update Function class - Function.model_rebuild(_types_namespace=namespace) + BlueprintFunction.model_rebuild(_types_namespace=namespace) # Update forward references @@ -82,7 +82,7 @@ def _model_rebuild() -> None: __all__ = [ - "Expression", + "BlueprintExpression", "Name", "Constant", "BinOp", @@ -92,7 +92,7 @@ def _model_rebuild() -> None: "List", "ListComp", "ExpressionType", - "Statement", + "BlueprintStatement", "Assign", "AugAssign", "IfElse", @@ -100,7 +100,7 @@ def _model_rebuild() -> None: "Return", "TupleUnpack", "StatementType", - "Function", + "BlueprintFunction", "EvaluationContext", "ReturnValue", "Evaluable", diff --git a/flow360/component/simulation/blueprint/core/context.py b/flow360/component/simulation/blueprint/core/context.py index 9d45f069b..b0ac4a7b1 100644 --- a/flow360/component/simulation/blueprint/core/context.py +++ b/flow360/component/simulation/blueprint/core/context.py @@ -106,6 +106,18 @@ def can_evaluate(self, name) -> bool: """ return self._resolver.can_evaluate(name) + def inlined(self, name) -> bool: + """ + Check if the function should be inlined during parsing. + + Args: + name (str): The name to check. + + Returns: + bool: True if the function needs inlining, False otherwise. + """ + return self._resolver.inlined(name) + def copy(self) -> "EvaluationContext": """ Create a copy of the current context. diff --git a/flow360/component/simulation/blueprint/core/expressions.py b/flow360/component/simulation/blueprint/core/expressions.py index 3f1d48477..fb42bb9cd 100644 --- a/flow360/component/simulation/blueprint/core/expressions.py +++ b/flow360/component/simulation/blueprint/core/expressions.py @@ -26,7 +26,7 @@ ] -class Expression(pd.BaseModel, Evaluable, metaclass=abc.ABCMeta): +class BlueprintExpression(pd.BaseModel, Evaluable, metaclass=abc.ABCMeta): """ Base class for expressions (like `x > 3`, `range(n)`, etc.). @@ -44,7 +44,7 @@ def used_names(self) -> set[str]: raise NotImplementedError -class Name(Expression): +class Name(BlueprintExpression): """ Expression representing a name qualifier """ @@ -52,30 +52,24 @@ class Name(Expression): type: Literal["Name"] = "Name" id: str - def evaluate(self, context: EvaluationContext, strict: bool, symbolic: bool = True) -> Any: - # TODO: Stop hardcoding the symbolic flag - if strict and not context.can_evaluate(self.id): + def evaluate( + self, context: EvaluationContext, raise_error: bool, force_evaluate: bool = False + ) -> Any: + if raise_error and not context.can_evaluate(self.id): raise ValueError(f"Name '{self.id}' cannot be evaluated at client runtime") + if not force_evaluate and not context.can_evaluate(self.id): + return self value = context.get(self.id) - print("get value from name: ", value) # Recursively evaluate if the returned value is evaluable if isinstance(value, Evaluable): - print("IS an Evaluable, value type = ", type(value), " ", value, "|", self.id) - value = value.evaluate(context, strict) - else: - from flow360.component.simulation.user_code import Variable - - # Very ugly implementation - if self.id.startswith(("solution", "control")) and symbolic: - return Variable(name=self.id, value=[1, 2, 3]) - + value = value.evaluate(context, raise_error, force_evaluate) return value def used_names(self) -> set[str]: return {self.id} -class Constant(Expression): +class Constant(BlueprintExpression): """ Expression representing a constant numeric value """ @@ -83,14 +77,16 @@ class Constant(Expression): type: Literal["Constant"] = "Constant" value: Any - def evaluate(self, context: EvaluationContext, strict: bool) -> Any: # noqa: ARG002 + def evaluate( + self, context: EvaluationContext, raise_error: bool, force_evaluate: bool = False + ) -> Any: # noqa: ARG002 return self.value def used_names(self) -> set[str]: return set() -class UnaryOp(Expression): +class UnaryOp(BlueprintExpression): """ Expression representing a unary operation """ @@ -99,8 +95,10 @@ class UnaryOp(Expression): op: str operand: "ExpressionType" - def evaluate(self, context: EvaluationContext, strict: bool) -> Any: - operand_val = self.operand.evaluate(context, strict) + def evaluate( + self, context: EvaluationContext, raise_error: bool, force_evaluate: bool = False + ) -> Any: + operand_val = self.operand.evaluate(context, raise_error, force_evaluate) if self.op not in UNARY_OPERATORS: raise ValueError(f"Unsupported operator: {self.op}") @@ -111,7 +109,7 @@ def used_names(self) -> set[str]: return self.operand.used_names() -class BinOp(Expression): +class BinOp(BlueprintExpression): """ Expression representing a binary operation """ @@ -121,9 +119,11 @@ class BinOp(Expression): op: str right: "ExpressionType" - def evaluate(self, context: EvaluationContext, strict: bool) -> Any: - left_val = self.left.evaluate(context, strict) - right_val = self.right.evaluate(context, strict) + def evaluate( + self, context: EvaluationContext, raise_error: bool, force_evaluate: bool = False + ) -> Any: + left_val = self.left.evaluate(context, raise_error, force_evaluate) + right_val = self.right.evaluate(context, raise_error, force_evaluate) if self.op not in BINARY_OPERATORS: raise ValueError(f"Unsupported operator: {self.op}") @@ -136,7 +136,7 @@ def used_names(self) -> set[str]: return left.union(right) -class Subscript(Expression): +class Subscript(BlueprintExpression): """ Expression representing an iterable object subscript """ @@ -146,9 +146,11 @@ class Subscript(Expression): slice: "ExpressionType" # No proper slicing for now, only constants.. ctx: str # Only load context - def evaluate(self, context: EvaluationContext, strict: bool) -> Any: - value = self.value.evaluate(context, strict) - item = self.slice.evaluate(context, strict) + def evaluate( + self, context: EvaluationContext, raise_error: bool, force_evaluate: bool = False + ) -> Any: + value = self.value.evaluate(context, raise_error, force_evaluate) + item = self.slice.evaluate(context, raise_error, force_evaluate) if self.ctx == "Load": return value[item] @@ -163,7 +165,7 @@ def used_names(self) -> set[str]: return value.union(item) -class RangeCall(Expression): +class RangeCall(BlueprintExpression): """ Model for something like range(). """ @@ -171,14 +173,16 @@ class RangeCall(Expression): type: Literal["RangeCall"] = "RangeCall" arg: "ExpressionType" - def evaluate(self, context: EvaluationContext, strict: bool) -> range: - return range(self.arg.evaluate(context, strict)) + def evaluate( + self, context: EvaluationContext, raise_error: bool, force_evaluate: bool = False + ) -> range: + return range(self.arg.evaluate(context, raise_error)) def used_names(self) -> set[str]: return self.arg.used_names() -class CallModel(Expression): +class CallModel(BlueprintExpression): """Represents a function or method call expression. This class handles both direct function calls and method calls through a fully qualified name. @@ -193,7 +197,9 @@ class CallModel(Expression): args: list["ExpressionType"] = [] kwargs: dict[str, "ExpressionType"] = {} - def evaluate(self, context: EvaluationContext, strict: bool) -> Any: + def evaluate( + self, context: EvaluationContext, raise_error: bool, force_evaluate: bool = False + ) -> Any: try: # Split into parts for attribute traversal parts = self.func_qualname.split(".") @@ -213,8 +219,10 @@ def evaluate(self, context: EvaluationContext, strict: bool) -> Any: func = getattr(base, parts[-1]) # Evaluate arguments - args = [arg.evaluate(context, strict) for arg in self.args] - kwargs = {k: v.evaluate(context, strict) for k, v in self.kwargs.items()} + args = [arg.evaluate(context, raise_error, force_evaluate) for arg in self.args] + kwargs = { + k: v.evaluate(context, raise_error, force_evaluate) for k, v in self.kwargs.items() + } return func(*args, **kwargs) @@ -237,27 +245,31 @@ def used_names(self) -> set[str]: return names -class Tuple(Expression): +class Tuple(BlueprintExpression): """Model for tuple expressions.""" type: Literal["Tuple"] = "Tuple" elements: list["ExpressionType"] - def evaluate(self, context: EvaluationContext, strict: bool) -> tuple: - return tuple(elem.evaluate(context, strict) for elem in self.elements) + def evaluate( + self, context: EvaluationContext, raise_error: bool, force_evaluate: bool = False + ) -> tuple: + return tuple(elem.evaluate(context, raise_error, force_evaluate) for elem in self.elements) def used_names(self) -> set[str]: return self.arg.used_names() -class List(Expression): +class List(BlueprintExpression): """Model for list expressions.""" type: Literal["List"] = "List" elements: list["ExpressionType"] - def evaluate(self, context: EvaluationContext, strict: bool) -> list: - return [elem.evaluate(context, strict) for elem in self.elements] + def evaluate( + self, context: EvaluationContext, raise_error: bool, force_evaluate: bool = False + ) -> list: + return [elem.evaluate(context, raise_error, force_evaluate) for elem in self.elements] def used_names(self) -> set[str]: names = set() @@ -268,7 +280,7 @@ def used_names(self) -> set[str]: return names -class ListComp(Expression): +class ListComp(BlueprintExpression): """Model for list comprehension expressions.""" type: Literal["ListComp"] = "ListComp" @@ -276,14 +288,16 @@ class ListComp(Expression): target: str # The loop variable name iter: "ExpressionType" # The iterable expression - def evaluate(self, context: EvaluationContext, strict: bool) -> list: + def evaluate( + self, context: EvaluationContext, raise_error: bool, force_evaluate: bool = False + ) -> list: result = [] - iterable = self.iter.evaluate(context, strict) + iterable = self.iter.evaluate(context, raise_error, force_evaluate) for item in iterable: # Create a new context for each iteration with the target variable iter_context = context.copy() iter_context.set(self.target, item) - result.append(self.element.evaluate(iter_context, strict)) + result.append(self.element.evaluate(iter_context, raise_error)) return result def used_names(self) -> set[str]: diff --git a/flow360/component/simulation/blueprint/core/function.py b/flow360/component/simulation/blueprint/core/function.py index f80b94d83..dc194a48e 100644 --- a/flow360/component/simulation/blueprint/core/function.py +++ b/flow360/component/simulation/blueprint/core/function.py @@ -8,7 +8,7 @@ from .statements import StatementType -class Function(pd.BaseModel): +class BlueprintFunction(pd.BaseModel): """ Represents an entire function: def name(arg1, arg2, ...): diff --git a/flow360/component/simulation/blueprint/core/generator.py b/flow360/component/simulation/blueprint/core/generator.py index 4dedea52c..2b2d36d17 100644 --- a/flow360/component/simulation/blueprint/core/generator.py +++ b/flow360/component/simulation/blueprint/core/generator.py @@ -16,7 +16,7 @@ Tuple, UnaryOp, ) -from flow360.component.simulation.blueprint.core.function import Function +from flow360.component.simulation.blueprint.core.function import BlueprintFunction from flow360.component.simulation.blueprint.core.statements import ( Assign, AugAssign, @@ -276,7 +276,9 @@ def stmt_to_code( def model_to_function( - func: Function, syntax: TargetSyntax = TargetSyntax.PYTHON, remap: dict[str, str] = None + func: BlueprintFunction, + syntax: TargetSyntax = TargetSyntax.PYTHON, + remap: dict[str, str] = None, ) -> str: """Convert a Function model back to source code.""" if syntax == TargetSyntax.PYTHON: diff --git a/flow360/component/simulation/blueprint/core/parser.py b/flow360/component/simulation/blueprint/core/parser.py index 8281becf1..d0de50a30 100644 --- a/flow360/component/simulation/blueprint/core/parser.py +++ b/flow360/component/simulation/blueprint/core/parser.py @@ -12,7 +12,7 @@ BinOp, CallModel, Constant, - Expression, + BlueprintExpression, ) from flow360.component.simulation.blueprint.core.expressions import List as ListExpr from flow360.component.simulation.blueprint.core.expressions import ( @@ -23,7 +23,7 @@ Tuple, UnaryOp, ) -from flow360.component.simulation.blueprint.core.function import Function +from flow360.component.simulation.blueprint.core.function import BlueprintFunction from flow360.component.simulation.blueprint.core.statements import ( Assign, AugAssign, @@ -202,7 +202,7 @@ def parse_stmt(node: ast.AST, ctx: EvaluationContext) -> Any: def function_to_model( source: Union[str, Callable[..., Any]], ctx: EvaluationContext, -) -> Function: +) -> BlueprintFunction: """Parse a Python function definition into our intermediate representation. Args: @@ -244,13 +244,13 @@ def function_to_model( # Parse the function body body = [parse_stmt(stmt, ctx) for stmt in func_def.body] - return Function(name=name, args=args, body=body, defaults=defaults) + return BlueprintFunction(name=name, args=args, body=body, defaults=defaults) def expr_to_model( source: str, ctx: EvaluationContext, -) -> Expression: +) -> BlueprintExpression: """Parse a Python rvalue expression Args: diff --git a/flow360/component/simulation/blueprint/core/resolver.py b/flow360/component/simulation/blueprint/core/resolver.py index f163683e9..8f2139fa3 100644 --- a/flow360/component/simulation/blueprint/core/resolver.py +++ b/flow360/component/simulation/blueprint/core/resolver.py @@ -22,18 +22,6 @@ def __init__(self, callables, modules, imports, blacklist) -> None: self._allowed_callables: dict[str, Callable[..., Any]] = {} self._allowed_modules: dict[str, Any] = {} - # Initialize with safe builtins - self._safe_builtins = { - "range": range, - "len": len, - "sum": sum, - "min": min, - "max": max, - "abs": abs, - "round": round, - # Add other safe builtins as needed - } - def register_callable(self, name: str, func: Callable[..., Any]) -> None: """Register a callable for direct use.""" self._allowed_callables[name] = func @@ -63,10 +51,6 @@ def get_callable(self, qualname: str) -> Callable[..., Any]: if qualname in self._allowed_callables: return self._allowed_callables[qualname] - # Check safe builtins - if qualname in self._safe_builtins: - return self._safe_builtins[qualname] - # Handle module attributes if "." in qualname: module_name, *attr_parts = qualname.split(".") diff --git a/flow360/component/simulation/blueprint/core/statements.py b/flow360/component/simulation/blueprint/core/statements.py index 181aec059..0337451fd 100644 --- a/flow360/component/simulation/blueprint/core/statements.py +++ b/flow360/component/simulation/blueprint/core/statements.py @@ -23,16 +23,18 @@ ] -class Statement(pd.BaseModel, Evaluable): +class BlueprintStatement(pd.BaseModel, Evaluable): """ Base class for statements (like 'if', 'for', assignments, etc.). """ - def evaluate(self, context: EvaluationContext, strict: bool) -> None: + def evaluate( + self, context: EvaluationContext, raise_error: bool, force_evaluate: bool = False + ) -> None: raise NotImplementedError -class Assign(Statement): +class Assign(BlueprintStatement): """ Represents something like 'result = '. """ @@ -41,11 +43,13 @@ class Assign(Statement): target: str value: ExpressionType - def evaluate(self, context: EvaluationContext, strict: bool) -> None: - context.set(self.target, self.value.evaluate(context, strict)) + def evaluate( + self, context: EvaluationContext, raise_error: bool, force_evaluate: bool = False + ) -> None: + context.set(self.target, self.value.evaluate(context, raise_error, force_evaluate)) -class AugAssign(Statement): +class AugAssign(BlueprintStatement): """ Represents something like 'result += '. The 'op' is again the operator class name (e.g. 'Add', 'Mult', etc.). @@ -56,9 +60,11 @@ class AugAssign(Statement): op: str value: ExpressionType - def evaluate(self, context: EvaluationContext, strict: bool) -> None: + def evaluate( + self, context: EvaluationContext, raise_error: bool, force_evaluate: bool = False + ) -> None: old_val = context.get(self.target) - increment = self.value.evaluate(context, strict) + increment = self.value.evaluate(context, raise_error, force_evaluate) if self.op == "Add": context.set(self.target, old_val + increment) elif self.op == "Sub": @@ -71,7 +77,7 @@ def evaluate(self, context: EvaluationContext, strict: bool) -> None: raise ValueError(f"Unsupported augmented assignment operator: {self.op}") -class IfElse(Statement): +class IfElse(BlueprintStatement): """ Represents an if/else block: if condition: @@ -85,16 +91,18 @@ class IfElse(Statement): body: list["StatementType"] orelse: list["StatementType"] - def evaluate(self, context: EvaluationContext, strict: bool) -> None: - if self.condition.evaluate(context, strict): + def evaluate( + self, context: EvaluationContext, raise_error: bool, force_evaluate: bool = False + ) -> None: + if self.condition.evaluate(context, raise_error, force_evaluate): for stmt in self.body: - stmt.evaluate(context, strict) + stmt.evaluate(context, raise_error, force_evaluate) else: for stmt in self.orelse: - stmt.evaluate(context, strict) + stmt.evaluate(context, raise_error) -class ForLoop(Statement): +class ForLoop(BlueprintStatement): """ Represents a for loop: for in : @@ -106,15 +114,17 @@ class ForLoop(Statement): iter: ExpressionType body: list["StatementType"] - def evaluate(self, context: EvaluationContext, strict: bool) -> None: - iterable = self.iter.evaluate(context, strict) + def evaluate( + self, context: EvaluationContext, raise_error: bool, force_evaluate: bool = False + ) -> None: + iterable = self.iter.evaluate(context, raise_error, force_evaluate) for item in iterable: context.set(self.target, item) for stmt in self.body: - stmt.evaluate(context, strict) + stmt.evaluate(context, raise_error, force_evaluate) -class Return(Statement): +class Return(BlueprintStatement): """ Represents a return statement: return . We'll raise a custom exception to stop execution in the function. @@ -123,19 +133,25 @@ class Return(Statement): type: Literal["Return"] = "Return" value: ExpressionType - def evaluate(self, context: EvaluationContext, strict: bool) -> None: - val = self.value.evaluate(context, strict) + def evaluate( + self, context: EvaluationContext, raise_error: bool, force_evaluate: bool = False + ) -> None: + val = self.value.evaluate(context, raise_error, force_evaluate) raise ReturnValue(val) -class TupleUnpack(Statement): +class TupleUnpack(BlueprintStatement): """Model for tuple unpacking assignments.""" type: Literal["TupleUnpack"] = "TupleUnpack" targets: list[str] values: list[ExpressionType] - def evaluate(self, context: EvaluationContext, strict: bool) -> None: - evaluated_values = [val.evaluate(context, strict) for val in self.values] + def evaluate( + self, context: EvaluationContext, raise_error: bool, force_evaluate: bool = False + ) -> None: + evaluated_values = [ + val.evaluate(context, raise_error, force_evaluate, inlines) for val in self.values + ] for target, value in zip(self.targets, evaluated_values): context.set(target, value) diff --git a/flow360/component/simulation/blueprint/core/types.py b/flow360/component/simulation/blueprint/core/types.py index bd1d30b8a..9da6af184 100644 --- a/flow360/component/simulation/blueprint/core/types.py +++ b/flow360/component/simulation/blueprint/core/types.py @@ -11,17 +11,20 @@ class Evaluable(metaclass=abc.ABCMeta): """Base class for all classes that allow evaluation from their symbolic form""" - @abc.abstractmethod - def evaluate(self, context: EvaluationContext, strict: bool) -> Any: + def evaluate( + self, context: EvaluationContext, raise_error: bool, force_evaluate: bool = False + ) -> Any: """ Evaluate the expression using the given context. Args: context (EvaluationContext): The context in which to evaluate the expression. - strict (bool): If True, raise an error on non-evaluable symbols; + raise_error (bool): If True, raise an error on non-evaluable symbols; if False, allow graceful failure or fallback behavior. - + force_evaluate (bool): If True, evaluate evaluable objects marked as + non-evaluable, instead of returning their identifier. + inline (bool): If True, inline certain marked function calls. Returns: Any: The evaluated value. """ diff --git a/flow360/component/simulation/blueprint/flow360/__init__.py b/flow360/component/simulation/blueprint/flow360/__init__.py index 4f83c1037..7d8b3a2a3 100644 --- a/flow360/component/simulation/blueprint/flow360/__init__.py +++ b/flow360/component/simulation/blueprint/flow360/__init__.py @@ -1,3 +1 @@ -"""Flow360 implementation of the blueprint module""" - -from .symbols import resolver +"""Flow360-specific implementation of the blueprint module""" diff --git a/flow360/component/simulation/user_code.py b/flow360/component/simulation/blueprint/flow360/expressions.py similarity index 81% rename from flow360/component/simulation/user_code.py rename to flow360/component/simulation/blueprint/flow360/expressions.py index e5dd244ab..a6d51edc6 100644 --- a/flow360/component/simulation/user_code.py +++ b/flow360/component/simulation/blueprint/flow360/expressions.py @@ -2,7 +2,6 @@ from __future__ import annotations -import ast import re from numbers import Number from typing import Annotated, Any, Generic, Iterable, Literal, Optional, TypeVar, Union @@ -11,14 +10,185 @@ from pydantic import BeforeValidator, Discriminator, PlainSerializer, Tag from pydantic_core import InitErrorDetails, core_schema from typing_extensions import Self -from unyt import Unit, unyt_array +from unyt import Unit, unyt_array, unit_symbols from flow360.component.simulation.blueprint import Evaluable, expr_to_model from flow360.component.simulation.blueprint.core import EvaluationContext, expr_to_code +from flow360.component.simulation.blueprint.core.resolver import CallableResolver from flow360.component.simulation.blueprint.core.types import TargetSyntax -from flow360.component.simulation.blueprint.flow360.symbols import resolver + from flow360.component.simulation.framework.base_model import Flow360BaseModel + +def _unit_list(): + symbols = set() + + for _, value in unit_symbols.__dict__.items(): + if isinstance(value, (unyt_array, Unit)): + symbols.add(str(value)) + + return list(symbols) + + +def _import_units(_) -> Any: + """Import and return allowed unit callables""" + from flow360.component.simulation import units as u + return u + + +def _import_math(_) -> Any: + """Import and return allowed function callables""" + from flow360.component.simulation.blueprint.flow360.functions import math + return math + + +def _import_control(_) -> Any: + """Import and return allowed control variable callables""" + from flow360.component.simulation.blueprint.flow360.variables import control + return control + + +def _import_solution(_) -> Any: + """Import and return allowed solution variable callables""" + from flow360.component.simulation.blueprint.flow360.variables import solution + return solution + + +WHITELISTED_CALLABLES = { + "flow360_math": {"prefix": "fn.", "callables": ["cross"], "evaluate": True}, + "flow360.units": {"prefix": "u.", "callables": _unit_list(), "evaluate": True}, + "flow360.control": { + "prefix": "control.", + "callables": [ + "mut", + "mu", + "solutionNavierStokes", + "residualNavierStokes", + "solutionTurbulence", + "residualTurbulence", + "kOmega", + "nuHat", + "solutionTransition", + "residualTransition", + "solutionHeatSolver", + "residualHeatSolver", + "coordinate", + "physicalStep", + "pseudoStep", + "timeStepSize", + "alphaAngle", + "betaAngle", + "pressureFreestream", + "momentLengthX", + "momentLengthY", + "momentLengthZ", + "momentCenterX", + "momentCenterY", + "momentCenterZ", + "bet_thrust", + "bet_torque", + "bet_omega", + "CD", + "CL", + "forceX", + "forceY", + "forceZ", + "momentX", + "momentY", + "momentZ", + "nodeNormals", + "theta", + "omega", + "omegaDot", + "wallFunctionMetric", + "wallShearStress", + "yPlus", + ], + "evaluate": False, + }, + "flow360.solution": { + "prefix": "solution.", + "callables": [ + "mut", + "mu", + "solutionNavierStokes", + "residualNavierStokes", + "solutionTurbulence", + "residualTurbulence", + "kOmega", + "nuHat", + "solutionTransition", + "residualTransition", + "solutionHeatSolver", + "residualHeatSolver", + "coordinate", + "physicalStep", + "pseudoStep", + "timeStepSize", + "alphaAngle", + "betaAngle", + "pressureFreestream", + "momentLengthX", + "momentLengthY", + "momentLengthZ", + "momentCenterX", + "momentCenterY", + "momentCenterZ", + "bet_thrust", + "bet_torque", + "bet_omega", + "CD", + "CL", + "forceX", + "forceY", + "forceZ", + "momentX", + "momentY", + "momentZ", + "nodeNormals", + "theta", + "omega", + "omegaDot", + "wallFunctionMetric", + "wallShearStress", + "yPlus", + ], + "evaluate": False, + }, +} + +# Define allowed modules +ALLOWED_MODULES = {"u", "fl", "control", "solution", "math"} + +ALLOWED_CALLABLES = { + **{ + f"{group['prefix']}{callable}": None + for group in WHITELISTED_CALLABLES.values() + for callable in group["callables"] + }, +} + +EVALUATION_BLACKLIST = { + **{ + f"{group['prefix']}{callable}": None + for group in WHITELISTED_CALLABLES.values() + for callable in group["callables"] + if not group["evaluate"] + }, +} + +# Note: Keys of IMPORT_FUNCTIONS needs to be consistent with ALLOWED_MODULES +IMPORT_FUNCTIONS = { + "u": _import_units, + "math": _import_math, + "control": _import_control, + "solution": _import_solution, +} + +resolver = CallableResolver( + ALLOWED_CALLABLES, ALLOWED_MODULES, IMPORT_FUNCTIONS, EVALUATION_BLACKLIST +) + _global_ctx: EvaluationContext = EvaluationContext(resolver) _user_variables: set[str] = set() _solver_variable_name_map: dict[str, str] = {} @@ -342,13 +512,16 @@ def _validate_expression(cls, value) -> Self: return {"expression": expression} def evaluate( - self, context: EvaluationContext = None, strict: bool = True + self, + context: EvaluationContext = None, + raise_error: bool = True, + force_evaluate: bool = True, ) -> Union[float, list, unyt_array]: """Evaluate this expression against the given context.""" if context is None: context = _global_ctx expr = expr_to_model(self.expression, context) - result = expr.evaluate(context, strict) + result = expr.evaluate(context, raise_error, force_evaluate) return result def user_variables(self): @@ -493,27 +666,6 @@ def __str__(self): def __repr__(self): return f"Expression({self.expression})" - def as_vector(self): - """Parse the expression (str) and if possible, return list of `Expression` instances""" - tree = ast.parse(self.expression, mode="eval") - if isinstance(tree.body, ast.List): - # Expression string with list syntax, like "[aa,bb,cc]" - result = [ast.unparse(elt) for elt in tree.body.elts] - else: - # Expression string with **evaluated result** - # being vector,like "[1,2,3]*u.m", "fl.cross(aa,bb)" - - # TODO: This seems to be a deadlock, here we depend on - # string expression being properly (symbolically) evaluated. - # Hacking so at least "[1,2,3]*u.m" works - result = self.evaluate() - print(">>> result = ", result, type(result)) - if isinstance(self, (list, unyt_array)): - return result - return None - - return [Expression.model_validate(item) for item in result] - T = TypeVar("T") @@ -524,7 +676,7 @@ class ValueOrExpression(Expression, Generic[T]): def __class_getitem__(cls, typevar_values): # pylint:disable=too-many-statements def _internal_validator(value: Expression): try: - result = value.evaluate(strict=False) + result = value.evaluate(raise_error=False) except Exception as err: raise ValueError(f"expression evaluation failed: {err}") from err pd.TypeAdapter(typevar_values).validate_python(result) @@ -569,7 +721,7 @@ def _serializer(value, info) -> dict: serialized.expression = value.expression - evaluated = value.evaluate(strict=False) + evaluated = value.evaluate(raise_error=False) if isinstance(evaluated, Number): serialized.evaluated_value = evaluated @@ -596,7 +748,7 @@ def _serializer(value, info) -> dict: return serialized.model_dump(**info.__dict__) - def _get_discriminator_value(v: Any) -> str: + def _discriminator(v: Any) -> str: # Note: This is ran after deserializer if isinstance(v, SerializedValueOrExpression): return v.type_name @@ -612,7 +764,7 @@ def _get_discriminator_value(v: Any) -> str: Union[ Annotated[expr_type, Tag("expression")], Annotated[typevar_values, Tag("number")] ], - Discriminator(_get_discriminator_value), + Discriminator(_discriminator), BeforeValidator(_deserialize), PlainSerializer(_serializer), ] diff --git a/flow360/component/simulation/blueprint/flow360/functions/math.py b/flow360/component/simulation/blueprint/flow360/functions/math.py new file mode 100644 index 000000000..8ced5c9c5 --- /dev/null +++ b/flow360/component/simulation/blueprint/flow360/functions/math.py @@ -0,0 +1,7 @@ +# + + +def cross(foo, bar): + """Customized Cross function to work with the `Expression` and Variables""" + + # If we ever diff --git a/flow360/component/simulation/blueprint/flow360/symbols.py b/flow360/component/simulation/blueprint/flow360/symbols.py deleted file mode 100644 index d379affbc..000000000 --- a/flow360/component/simulation/blueprint/flow360/symbols.py +++ /dev/null @@ -1,166 +0,0 @@ -"""Resolver and symbols data for Flow360 python client""" - -from __future__ import annotations - -from typing import Any - -import numpy as np -import unyt - -from flow360.component.simulation import units as u -from flow360.component.simulation.blueprint.core.resolver import CallableResolver -from flow360.component.simulation.blueprint.functions import vector_functions - - -def _unit_list(): - unit_symbols = set() - - for _, value in unyt.unit_symbols.__dict__.items(): - if isinstance(value, (unyt.unyt_quantity, unyt.Unit)): - unit_symbols.add(str(value)) - - return list(unit_symbols) - - -def _import_units(_: str) -> Any: - """Import and return allowed flow360 callables""" - return u - - -def _import_functions(_): - return vector_functions - - -WHITELISTED_CALLABLES = { - # TODO: Move functions into blueprint. - "flow360_math_functions": {"prefix": "fl.", "callables": ["cross"], "evaluate": True}, - "flow360.units": {"prefix": "u.", "callables": _unit_list(), "evaluate": True}, - "flow360.control": { - "prefix": "control.", - "callables": [ - "mut", - "mu", - "solutionNavierStokes", - "residualNavierStokes", - "solutionTurbulence", - "residualTurbulence", - "kOmega", - "nuHat", - "solutionTransition", - "residualTransition", - "solutionHeatSolver", - "residualHeatSolver", - "coordinate", - "physicalStep", - "pseudoStep", - "timeStepSize", - "alphaAngle", - "betaAngle", - "pressureFreestream", - "momentLengthX", - "momentLengthY", - "momentLengthZ", - "momentCenterX", - "momentCenterY", - "momentCenterZ", - "bet_thrust", - "bet_torque", - "bet_omega", - "CD", - "CL", - "forceX", - "forceY", - "forceZ", - "momentX", - "momentY", - "momentZ", - "nodeNormals", - "theta", - "omega", - "omegaDot", - "wallFunctionMetric", - "wallShearStress", - "yPlus", - ], - "evaluate": False, - }, - "flow360.solution": { - "prefix": "solution.", - "callables": [ - "mut", - "mu", - "solutionNavierStokes", - "residualNavierStokes", - "solutionTurbulence", - "residualTurbulence", - "kOmega", - "nuHat", - "solutionTransition", - "residualTransition", - "solutionHeatSolver", - "residualHeatSolver", - "coordinate", - "physicalStep", - "pseudoStep", - "timeStepSize", - "alphaAngle", - "betaAngle", - "pressureFreestream", - "momentLengthX", - "momentLengthY", - "momentLengthZ", - "momentCenterX", - "momentCenterY", - "momentCenterZ", - "bet_thrust", - "bet_torque", - "bet_omega", - "CD", - "CL", - "forceX", - "forceY", - "forceZ", - "momentX", - "momentY", - "momentZ", - "nodeNormals", - "theta", - "omega", - "omegaDot", - "wallFunctionMetric", - "wallShearStress", - "yPlus", - ], - "evaluate": False, - }, -} - -# Define allowed modules -ALLOWED_MODULES = {"u", "fl", "control", "solution"} - -ALLOWED_CALLABLES = { - **{ - f"{group['prefix']}{callable}": None - for group in WHITELISTED_CALLABLES.values() - for callable in group["callables"] - }, -} - -EVALUATION_BLACKLIST = { - **{ - f"{group['prefix']}{callable}": None - for group in WHITELISTED_CALLABLES.values() - for callable in group["callables"] - if not group["evaluate"] - }, -} - -# Note: Keys of IMPORT_FUNCTIONS needs to be consistent with ALLOWED_MODULES -IMPORT_FUNCTIONS = { - "u": _import_units, - "fl": _import_functions, -} - -resolver = CallableResolver( - ALLOWED_CALLABLES, ALLOWED_MODULES, IMPORT_FUNCTIONS, EVALUATION_BLACKLIST -) diff --git a/flow360/component/simulation/variables/control_variables.py b/flow360/component/simulation/blueprint/flow360/variables/control.py similarity index 96% rename from flow360/component/simulation/variables/control_variables.py rename to flow360/component/simulation/blueprint/flow360/variables/control.py index d3c1b1929..fde93053e 100644 --- a/flow360/component/simulation/variables/control_variables.py +++ b/flow360/component/simulation/blueprint/flow360/variables/control.py @@ -1,7 +1,7 @@ """Control variables of Flow360""" from flow360.component.simulation import units as u -from flow360.component.simulation.user_code import SolverVariable +from flow360.component.simulation.blueprint.flow360.expressions import SolverVariable # pylint:disable=no-member MachRef = SolverVariable( diff --git a/flow360/component/simulation/variables/solution_variables.py b/flow360/component/simulation/blueprint/flow360/variables/solution.py similarity index 97% rename from flow360/component/simulation/variables/solution_variables.py rename to flow360/component/simulation/blueprint/flow360/variables/solution.py index 0c808e01b..bdbdf9e90 100644 --- a/flow360/component/simulation/variables/solution_variables.py +++ b/flow360/component/simulation/blueprint/flow360/variables/solution.py @@ -2,7 +2,7 @@ import unyt as u -from flow360.component.simulation.user_code import SolverVariable +from flow360.component.simulation.blueprint.flow360.expressions import SolverVariable mut = SolverVariable(name="solution.mut", value=float("NaN")) # Turbulent viscosity mu = SolverVariable(name="solution.mu", value=float("NaN")) # Laminar viscosity diff --git a/flow360/component/simulation/blueprint/functions/vector_functions.py b/flow360/component/simulation/blueprint/functions/vector_functions.py deleted file mode 100644 index 469182024..000000000 --- a/flow360/component/simulation/blueprint/functions/vector_functions.py +++ /dev/null @@ -1,87 +0,0 @@ -import numpy as np -from unyt import ucross, unyt_array - -# ***** General principle ***** -# 1. Defer evaluation of the real cross operation only to when needed (translator). This helps preserving the original user input - - -# pow -def cross(foo, bar): - """Customized Cross function to work with the `Expression` and Variables""" - # TODO: Move global import here to avoid circular import. - # Cannot find good way of breaking the circular import otherwise. - print("input : \n", foo, "type = ", type(foo), "\n", bar, "type = ", type(bar), "\n") - from flow360.component.simulation.user_code import Expression, Variable - - if isinstance(foo, np.ndarray) and isinstance(bar, np.ndarray): - return np.cross(foo, bar) - - if isinstance(foo, np.ndarray) and isinstance(bar, unyt_array): - return np.cross(foo, bar) * bar.units - - if isinstance(foo, np.ndarray) and isinstance(bar, unyt_array): - return ucross(foo, bar) - - # What else than `SolverVariable`? `UserVariable`? `Expression`? - # How to support symbolic expression now that we get rid of numpy interop? - # Do we only support 1 layer of module? - # Consistent serialize and deserialize - - def _preprocess_input(baz): - if isinstance(baz, Variable): - if isinstance(baz.value, Expression): - return _preprocess_input(baz.value) - # value - baz_length = len(baz.value) - baz = Expression(expression=str(baz)) - elif isinstance(baz, Expression): - vector_form = baz.as_vector() - if not vector_form: # I am scalar expression. - raise ValueError(f"fl.cross() can not take in scalar expression. {baz} was given") - - baz_length = len(vector_form) - baz = vector_form - else: - baz_length = len(baz) - - return baz, baz_length - - foo, foo_length = _preprocess_input(foo) - bar, bar_length = _preprocess_input(bar) - print("\n>>>> foo, foo_length = ", foo, foo_length) - print(">>>> bar, bar_length = ", bar, bar_length) - assert foo_length == bar_length, f"Different len {foo_length} vs {bar_length}" - print( - ">> HOW??? ", - [ - bar[2] * foo[1] - bar[1] * foo[2], - bar[0] * foo[2] - bar[2] * foo[0], - bar[0] * foo[1] - bar[1] * foo[0], - ], - ) - if foo_length == 3: - return Expression.model_validate( - [ - bar[2] * foo[1] - bar[1] * foo[2], - bar[0] * foo[2] - bar[2] * foo[0], - bar[0] * foo[1] - bar[1] * foo[0], - ] - ) - raise NotImplementedError("len ==2 not implemented") - - # foo_processed = _preprocess(foo) - # bar_processed = _preprocess(bar) - # - # if len(foo_processed) == 2: - # return Expression( - # expression=bar_processed[1] * foo_processed[0] - bar_processed[0] * foo_processed[1] - # ) - # elif len(foo_processed) == 3: - # return Expression( - # expression=[ - # bar_processed[2] * foo_processed[1] - bar_processed[1] * foo_processed[2], - # bar_processed[0] * foo_processed[2] - bar_processed[2] * foo_processed[0], - # bar_processed[0] * foo_processed[1] - bar_processed[1] * foo_processed[0], - # ] - # ) - # return np.cross(foo_processed, bar_processed) diff --git a/flow360/component/simulation/framework/param_utils.py b/flow360/component/simulation/framework/param_utils.py index d470cb192..7eee05d65 100644 --- a/flow360/component/simulation/framework/param_utils.py +++ b/flow360/component/simulation/framework/param_utils.py @@ -18,7 +18,7 @@ _VolumeEntityBase, ) from flow360.component.simulation.unit_system import LengthType -from flow360.component.simulation.user_code import UserVariable +from flow360.component.simulation.blueprint.flow360.expressions import UserVariable from flow360.component.simulation.utils import model_attribute_unlock diff --git a/flow360/component/simulation/primitives.py b/flow360/component/simulation/primitives.py index e2158887d..ae689d712 100644 --- a/flow360/component/simulation/primitives.py +++ b/flow360/component/simulation/primitives.py @@ -21,7 +21,7 @@ ) from flow360.component.simulation.framework.unique_list import UniqueStringList from flow360.component.simulation.unit_system import AngleType, AreaType, LengthType -from flow360.component.simulation.user_code import ValueOrExpression +from flow360.component.simulation.blueprint.flow360.expressions import ValueOrExpression from flow360.component.simulation.utils import model_attribute_unlock from flow360.component.types import Axis diff --git a/flow360/component/simulation/services.py b/flow360/component/simulation/services.py index 7a439f249..79be71654 100644 --- a/flow360/component/simulation/services.py +++ b/flow360/component/simulation/services.py @@ -20,18 +20,12 @@ from flow360.component.simulation.models.surface_models import Freestream, Wall # Following unused-import for supporting parse_model_dict -from flow360.component.simulation.models.volume_models import ( # pylint: disable=unused-import - BETDisk, -) # pylint: disable=unused-import from flow360.component.simulation.operating_condition.operating_condition import ( AerospaceCondition, - GenericReferenceCondition, - ThermalState, ) from flow360.component.simulation.outputs.outputs import SurfaceOutput -from flow360.component.simulation.primitives import Box # pylint: disable=unused-import from flow360.component.simulation.primitives import Surface # For parse_model_dict from flow360.component.simulation.simulation_params import ( ReferenceGeometry, @@ -56,7 +50,7 @@ u, unit_system_manager, ) -from flow360.component.simulation.user_code import Expression, UserVariable +from flow360.component.simulation.blueprint.flow360.expressions import Expression, UserVariable from flow360.component.simulation.utils import model_attribute_unlock from flow360.component.simulation.validation.validation_context import ( ALL, @@ -800,7 +794,7 @@ def validate_expression(variables: list[dict], expressions: list[str]): try: variable = UserVariable(name=variable["name"], value=variable["value"]) if variable and isinstance(variable.value, Expression): - _ = variable.value.evaluate(strict=False) + _ = variable.value.evaluate(raise_error=False) except pd.ValidationError as err: errors.extend(err.errors()) except Exception as err: # pylint: disable=broad-exception-caught @@ -812,7 +806,7 @@ def validate_expression(variables: list[dict], expressions: list[str]): unit = None try: expression_object = Expression(expression=expression) - result = expression_object.evaluate(strict=False) + result = expression_object.evaluate(raise_error=False) if np.isnan(result): pass elif isinstance(result, Number): diff --git a/flow360/component/simulation/simulation_params.py b/flow360/component/simulation/simulation_params.py index 6f9bb14fe..2f9a6fdc5 100644 --- a/flow360/component/simulation/simulation_params.py +++ b/flow360/component/simulation/simulation_params.py @@ -60,7 +60,7 @@ unit_system_manager, unyt_quantity, ) -from flow360.component.simulation.user_code import UserVariable +from flow360.component.simulation.blueprint.flow360.expressions import UserVariable from flow360.component.simulation.user_defined_dynamics.user_defined_dynamics import ( UserDefinedDynamic, ) diff --git a/flow360/component/simulation/translator/utils.py b/flow360/component/simulation/translator/utils.py index 1b47697ac..b0c383306 100644 --- a/flow360/component/simulation/translator/utils.py +++ b/flow360/component/simulation/translator/utils.py @@ -17,7 +17,7 @@ ) from flow360.component.simulation.simulation_params import SimulationParams from flow360.component.simulation.unit_system import LengthType -from flow360.component.simulation.user_code import Expression +from flow360.component.simulation.blueprint.flow360.expressions import Expression from flow360.component.simulation.utils import is_exact_instance @@ -153,7 +153,7 @@ def inline_expressions_in_dict(input_dict, input_params): new_dict = {} if "expression" in input_dict.keys(): expression = Expression(expression=input_dict["expression"]) - evaluated = expression.evaluate(strict=False) + evaluated = expression.evaluate(raise_error=False) converted = input_params.convert_unit(evaluated, "flow360").v new_dict = converted return new_dict @@ -162,7 +162,7 @@ def inline_expressions_in_dict(input_dict, input_params): # so remove_units_in_dict should handle them correctly... if isinstance(value, dict) and "expression" in value.keys(): expression = Expression(expression=value["expression"]) - evaluated = expression.evaluate(strict=False) + evaluated = expression.evaluate(raise_error=False) converted = input_params.convert_unit(evaluated, "flow360").v if isinstance(converted, np.ndarray): if converted.ndim == 0: diff --git a/flow360/component/simulation/validation/validation_simulation_params.py b/flow360/component/simulation/validation/validation_simulation_params.py index 7f7556d9c..175dadba6 100644 --- a/flow360/component/simulation/validation/validation_simulation_params.py +++ b/flow360/component/simulation/validation/validation_simulation_params.py @@ -22,7 +22,7 @@ VolumeOutput, ) from flow360.component.simulation.time_stepping.time_stepping import Steady, Unsteady -from flow360.component.simulation.user_code import Expression +from flow360.component.simulation.blueprint.flow360.expressions import Expression from flow360.component.simulation.validation.validation_context import ( ALL, CASE, diff --git a/flow360/component/simulation/variables/__init__.py b/flow360/component/simulation/variables/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/simulation/test_expressions.py b/tests/simulation/test_expressions.py index f7dd60662..bc31c5163 100644 --- a/tests/simulation/test_expressions.py +++ b/tests/simulation/test_expressions.py @@ -17,11 +17,9 @@ from flow360.component.simulation.framework.base_model import Flow360BaseModel from flow360.component.simulation.framework.param_utils import AssetCache from flow360.component.simulation.models.material import Water, aluminum -from flow360.component.simulation.models.surface_models import Wall from flow360.component.simulation.outputs.outputs import SurfaceOutput from flow360.component.simulation.primitives import ( GenericVolume, - ReferenceGeometry, Surface, ) from flow360.component.simulation.unit_system import ( @@ -50,7 +48,7 @@ VelocityType, ViscosityType, ) -from flow360.component.simulation.user_code import ( +from flow360.component.simulation.blueprint.flow360.expressions import ( Expression, UserVariable, ValueOrExpression, @@ -189,17 +187,11 @@ class TestModel(Flow360BaseModel): assert model.field.evaluate() == 3 assert str(model.field) == "+x" - # Absolute value - model.field = abs(x) - assert isinstance(model.field, Expression) - assert model.field.evaluate() == 3 - assert str(model.field) == "abs(x)" - # Complex statement - model.field = ((abs(x) - 2 * x) + (x + y) / 2 - 2**x) % 4 + model.field = ((x - 2 * x) + (x + y) / 2 - 2**x) % 4 assert isinstance(model.field, Expression) assert model.field.evaluate() == 3.5 - assert str(model.field) == "(abs(x) - (2 * x) + ((x + y) / 2) - (2 ** x)) % 4" + assert str(model.field) == "(x - (2 * x) + ((x + y) / 2) - (2 ** x)) % 4" def test_dimensioned_expressions(): @@ -707,7 +699,6 @@ def test_variable_space_init(): params, errors, _ = validate_model( params_as_dict=data, validated_by=ValidationCalledBy.LOCAL, root_item_type="Geometry" ) - from flow360.component.simulation.user_code import _global_ctx, _user_variables assert errors is None evaluated = params.reference_geometry.area.evaluate() From 5e7e746d063e40b32c3f815d5053da3f13b7963a Mon Sep 17 00:00:00 2001 From: Andrzej Krupka Date: Mon, 2 Jun 2025 22:30:44 +0200 Subject: [PATCH 06/19] Partial evaluation before solver code translation --- flow360/__init__.py | 8 +- .../simulation/blueprint/__init__.py | 4 +- .../simulation/blueprint/core/__init__.py | 142 ++++----- .../simulation/blueprint/core/context.py | 26 +- .../simulation/blueprint/core/expressions.py | 77 ++--- .../simulation/blueprint/core/function.py | 6 +- .../simulation/blueprint/core/generator.py | 68 ++--- .../simulation/blueprint/core/parser.py | 82 ++--- .../simulation/blueprint/core/statements.py | 52 ++-- .../simulation/blueprint/core/types.py | 3 +- .../simulation/blueprint/flow360/__init__.py | 1 - .../blueprint/flow360/functions/math.py | 7 - .../simulation/framework/param_utils.py | 2 +- flow360/component/simulation/primitives.py | 2 +- flow360/component/simulation/services.py | 7 +- .../component/simulation/simulation_params.py | 2 +- .../component/simulation/translator/utils.py | 2 +- .../simulation/user_code/__init__.py | 0 .../simulation/user_code/core/__init__.py | 0 .../simulation/user_code/core/context.py | 145 +++++++++ .../core/types.py} | 287 ++++-------------- .../simulation/user_code/core/utils.py | 42 +++ .../user_code/functions/__init__.py | 0 .../simulation/user_code/functions/math.py | 37 +++ .../user_code/variables/__init__.py | 0 .../variables/control.py | 2 +- .../variables/solution.py | 2 +- .../validation_simulation_params.py | 2 +- tests/simulation/test_expressions.py | 122 ++------ 29 files changed, 555 insertions(+), 575 deletions(-) delete mode 100644 flow360/component/simulation/blueprint/flow360/__init__.py delete mode 100644 flow360/component/simulation/blueprint/flow360/functions/math.py create mode 100644 flow360/component/simulation/user_code/__init__.py create mode 100644 flow360/component/simulation/user_code/core/__init__.py create mode 100644 flow360/component/simulation/user_code/core/context.py rename flow360/component/simulation/{blueprint/flow360/expressions.py => user_code/core/types.py} (74%) create mode 100644 flow360/component/simulation/user_code/core/utils.py create mode 100644 flow360/component/simulation/user_code/functions/__init__.py create mode 100644 flow360/component/simulation/user_code/functions/math.py create mode 100644 flow360/component/simulation/user_code/variables/__init__.py rename flow360/component/simulation/{blueprint/flow360 => user_code}/variables/control.py (96%) rename flow360/component/simulation/{blueprint/flow360 => user_code}/variables/solution.py (97%) diff --git a/flow360/__init__.py b/flow360/__init__.py index 899ca2e4f..9e1dd4276 100644 --- a/flow360/__init__.py +++ b/flow360/__init__.py @@ -9,7 +9,6 @@ from flow360.component.project import Project from flow360.component.simulation import migration, services from flow360.component.simulation import units as u -from flow360.component.simulation.blueprint.flow360.functions.math import cross from flow360.component.simulation.entity_info import GeometryEntityInfo from flow360.component.simulation.meshing_param.edge_params import ( AngleBasedRefinement, @@ -146,13 +145,12 @@ SI_unit_system, imperial_unit_system, ) -from flow360.component.simulation.blueprint.flow360.expressions import UserVariable +from flow360.component.simulation.user_code.core.types import UserVariable +from flow360.component.simulation.user_code.functions import math +from flow360.component.simulation.user_code.variables import control, solution from flow360.component.simulation.user_defined_dynamics.user_defined_dynamics import ( UserDefinedDynamic, ) -from flow360.component.simulation.blueprint.flow360.variables import control -from flow360.component.simulation.blueprint.flow360.variables import solution -from flow360.component.simulation.blueprint.flow360.functions import math from flow360.component.surface_mesh_v2 import SurfaceMeshV2 as SurfaceMesh from flow360.component.volume_mesh import VolumeMeshV2 as VolumeMesh from flow360.environment import Env diff --git a/flow360/component/simulation/blueprint/__init__.py b/flow360/component/simulation/blueprint/__init__.py index 60d3b5a30..30b3f33c0 100644 --- a/flow360/component/simulation/blueprint/__init__.py +++ b/flow360/component/simulation/blueprint/__init__.py @@ -6,11 +6,11 @@ function_to_model, ) -from .core.function import BlueprintFunction +from .core.function import FunctionNode from .core.types import Evaluable __all__ = [ - "BlueprintFunction", + "FunctionNode", "Evaluable", "function_to_model", "model_to_function", diff --git a/flow360/component/simulation/blueprint/core/__init__.py b/flow360/component/simulation/blueprint/core/__init__.py index 0acc1de2d..f521ad40d 100644 --- a/flow360/component/simulation/blueprint/core/__init__.py +++ b/flow360/component/simulation/blueprint/core/__init__.py @@ -2,30 +2,30 @@ from .context import EvaluationContext, ReturnValue from .expressions import ( - BinOp, - CallModel, - Constant, - BlueprintExpression, - ExpressionType, - List, - ListComp, - Name, - RangeCall, - Subscript, - Tuple, + BinOpNode, + CallModelNode, + ConstantNode, + ExpressionNode, + ExpressionNodeType, + ListCompNode, + ListNode, + NameNode, + RangeCallNode, + SubscriptNode, + TupleNode, ) -from .function import BlueprintFunction +from .function import FunctionNode from .generator import expr_to_code, model_to_function, stmt_to_code from .parser import function_to_model from .statements import ( - Assign, - AugAssign, - ForLoop, - IfElse, - Return, - BlueprintStatement, - StatementType, - TupleUnpack, + AssignNode, + AugAssignNode, + ForLoopNode, + IfElseNode, + ReturnNode, + StatementNode, + StatementNodeType, + TupleUnpackNode, ) from .types import Evaluable, TargetSyntax @@ -34,47 +34,47 @@ def _model_rebuild() -> None: """Update forward references in the correct order.""" namespace = { # Expression types - "Name": Name, - "Constant": Constant, - "BinOp": BinOp, - "RangeCall": RangeCall, - "CallModel": CallModel, - "Tuple": Tuple, - "List": List, - "ListComp": ListComp, - "Subscript": Subscript, - "ExpressionType": ExpressionType, + "Name": NameNode, + "Constant": ConstantNode, + "BinOp": BinOpNode, + "RangeCall": RangeCallNode, + "CallModel": CallModelNode, + "Tuple": TupleNode, + "List": ListNode, + "ListComp": ListCompNode, + "Subscript": SubscriptNode, + "ExpressionType": ExpressionNodeType, # Statement types - "Assign": Assign, - "AugAssign": AugAssign, - "IfElse": IfElse, - "ForLoop": ForLoop, - "Return": Return, - "TupleUnpack": TupleUnpack, - "StatementType": StatementType, + "Assign": AssignNode, + "AugAssign": AugAssignNode, + "IfElse": IfElseNode, + "ForLoop": ForLoopNode, + "Return": ReturnNode, + "TupleUnpack": TupleUnpackNode, + "StatementType": StatementNodeType, # Function type - "Function": BlueprintFunction, + "Function": FunctionNode, } # First update expression classes that only depend on ExpressionType - BinOp.model_rebuild(_types_namespace=namespace) - RangeCall.model_rebuild(_types_namespace=namespace) - CallModel.model_rebuild(_types_namespace=namespace) - Tuple.model_rebuild(_types_namespace=namespace) - List.model_rebuild(_types_namespace=namespace) - ListComp.model_rebuild(_types_namespace=namespace) - Subscript.model_rebuild(_types_namespace=namespace) + BinOpNode.model_rebuild(_types_namespace=namespace) + RangeCallNode.model_rebuild(_types_namespace=namespace) + CallModelNode.model_rebuild(_types_namespace=namespace) + TupleNode.model_rebuild(_types_namespace=namespace) + ListNode.model_rebuild(_types_namespace=namespace) + ListCompNode.model_rebuild(_types_namespace=namespace) + SubscriptNode.model_rebuild(_types_namespace=namespace) # Then update statement classes that depend on both types - Assign.model_rebuild(_types_namespace=namespace) - AugAssign.model_rebuild(_types_namespace=namespace) - IfElse.model_rebuild(_types_namespace=namespace) - ForLoop.model_rebuild(_types_namespace=namespace) - Return.model_rebuild(_types_namespace=namespace) - TupleUnpack.model_rebuild(_types_namespace=namespace) + AssignNode.model_rebuild(_types_namespace=namespace) + AugAssignNode.model_rebuild(_types_namespace=namespace) + IfElseNode.model_rebuild(_types_namespace=namespace) + ForLoopNode.model_rebuild(_types_namespace=namespace) + ReturnNode.model_rebuild(_types_namespace=namespace) + TupleUnpackNode.model_rebuild(_types_namespace=namespace) # Finally update Function class - BlueprintFunction.model_rebuild(_types_namespace=namespace) + FunctionNode.model_rebuild(_types_namespace=namespace) # Update forward references @@ -82,25 +82,25 @@ def _model_rebuild() -> None: __all__ = [ - "BlueprintExpression", - "Name", - "Constant", - "BinOp", - "RangeCall", - "CallModel", - "Tuple", - "List", - "ListComp", - "ExpressionType", - "BlueprintStatement", - "Assign", - "AugAssign", - "IfElse", - "ForLoop", - "Return", - "TupleUnpack", - "StatementType", - "BlueprintFunction", + "ExpressionNode", + "NameNode", + "ConstantNode", + "BinOpNode", + "RangeCallNode", + "CallModelNode", + "TupleNode", + "ListNode", + "ListCompNode", + "ExpressionNodeType", + "StatementNode", + "AssignNode", + "AugAssignNode", + "IfElseNode", + "ForLoopNode", + "ReturnNode", + "TupleUnpackNode", + "StatementNodeType", + "FunctionNode", "EvaluationContext", "ReturnValue", "Evaluable", diff --git a/flow360/component/simulation/blueprint/core/context.py b/flow360/component/simulation/blueprint/core/context.py index b0ac4a7b1..821ccf6d7 100644 --- a/flow360/component/simulation/blueprint/core/context.py +++ b/flow360/component/simulation/blueprint/core/context.py @@ -2,6 +2,8 @@ from typing import Any, Optional +import pydantic as pd + from flow360.component.simulation.blueprint.core.resolver import CallableResolver @@ -37,6 +39,7 @@ def __init__( the context with. """ self._values = initial_values or {} + self._data_models = {} self._resolver = resolver def get(self, name: str, resolve: bool = True) -> Any: @@ -69,16 +72,25 @@ def get(self, name: str, resolve: bool = True) -> Any: raise NameError(f"Name '{name}' is not defined") from err return self._values[name] - def set(self, name: str, value: Any) -> None: + def get_data_model(self, name: str) -> Optional[pd.BaseModel]: + if name not in self._data_models: + return None + return self._data_models[name] + + def set(self, name: str, value: Any, data_model: pd.BaseModel = None) -> None: """ Assign a value to a name in the context. Args: name (str): The variable name to set. value (Any): The value to assign. + data_model (BaseModel, optional): The type of the associate with this entry """ self._values[name] = value + if data_model: + self._data_models[name] = data_model + def resolve(self, name): """ Resolve a name using the provided resolver. @@ -106,18 +118,6 @@ def can_evaluate(self, name) -> bool: """ return self._resolver.can_evaluate(name) - def inlined(self, name) -> bool: - """ - Check if the function should be inlined during parsing. - - Args: - name (str): The name to check. - - Returns: - bool: True if the function needs inlining, False otherwise. - """ - return self._resolver.inlined(name) - def copy(self) -> "EvaluationContext": """ Create a copy of the current context. diff --git a/flow360/component/simulation/blueprint/core/expressions.py b/flow360/component/simulation/blueprint/core/expressions.py index fb42bb9cd..0288d5399 100644 --- a/flow360/component/simulation/blueprint/core/expressions.py +++ b/flow360/component/simulation/blueprint/core/expressions.py @@ -9,7 +9,7 @@ from .context import EvaluationContext from .types import Evaluable -ExpressionType = Annotated[ +ExpressionNodeType = Annotated[ # pylint: disable=duplicate-code Union[ "Name", @@ -26,7 +26,7 @@ ] -class BlueprintExpression(pd.BaseModel, Evaluable, metaclass=abc.ABCMeta): +class ExpressionNode(pd.BaseModel, Evaluable, metaclass=abc.ABCMeta): """ Base class for expressions (like `x > 3`, `range(n)`, etc.). @@ -44,7 +44,7 @@ def used_names(self) -> set[str]: raise NotImplementedError -class Name(BlueprintExpression): +class NameNode(ExpressionNode): """ Expression representing a name qualifier """ @@ -53,12 +53,19 @@ class Name(BlueprintExpression): id: str def evaluate( - self, context: EvaluationContext, raise_error: bool, force_evaluate: bool = False + self, + context: EvaluationContext, + raise_error: bool = True, + force_evaluate: bool = True, ) -> Any: if raise_error and not context.can_evaluate(self.id): raise ValueError(f"Name '{self.id}' cannot be evaluated at client runtime") if not force_evaluate and not context.can_evaluate(self.id): - return self + data_model = context.get_data_model(self.id) + if data_model: + return data_model.model_validate({"name": self.id, "value": context.get(self.id)}) + else: + raise ValueError(f"Partially evaluable symbols need to possess a type annotation") value = context.get(self.id) # Recursively evaluate if the returned value is evaluable if isinstance(value, Evaluable): @@ -69,7 +76,7 @@ def used_names(self) -> set[str]: return {self.id} -class Constant(BlueprintExpression): +class ConstantNode(ExpressionNode): """ Expression representing a constant numeric value """ @@ -78,7 +85,7 @@ class Constant(BlueprintExpression): value: Any def evaluate( - self, context: EvaluationContext, raise_error: bool, force_evaluate: bool = False + self, context: EvaluationContext, raise_error: bool = True, force_evaluate: bool = True ) -> Any: # noqa: ARG002 return self.value @@ -86,17 +93,17 @@ def used_names(self) -> set[str]: return set() -class UnaryOp(BlueprintExpression): +class UnaryOpNode(ExpressionNode): """ Expression representing a unary operation """ type: Literal["UnaryOp"] = "UnaryOp" op: str - operand: "ExpressionType" + operand: "ExpressionNodeType" def evaluate( - self, context: EvaluationContext, raise_error: bool, force_evaluate: bool = False + self, context: EvaluationContext, raise_error: bool = True, force_evaluate: bool = True ) -> Any: operand_val = self.operand.evaluate(context, raise_error, force_evaluate) @@ -109,18 +116,18 @@ def used_names(self) -> set[str]: return self.operand.used_names() -class BinOp(BlueprintExpression): +class BinOpNode(ExpressionNode): """ Expression representing a binary operation """ type: Literal["BinOp"] = "BinOp" - left: "ExpressionType" + left: "ExpressionNodeType" op: str - right: "ExpressionType" + right: "ExpressionNodeType" def evaluate( - self, context: EvaluationContext, raise_error: bool, force_evaluate: bool = False + self, context: EvaluationContext, raise_error: bool = True, force_evaluate: bool = True ) -> Any: left_val = self.left.evaluate(context, raise_error, force_evaluate) right_val = self.right.evaluate(context, raise_error, force_evaluate) @@ -136,18 +143,18 @@ def used_names(self) -> set[str]: return left.union(right) -class Subscript(BlueprintExpression): +class SubscriptNode(ExpressionNode): """ Expression representing an iterable object subscript """ type: Literal["Subscript"] = "Subscript" - value: "ExpressionType" - slice: "ExpressionType" # No proper slicing for now, only constants.. + value: "ExpressionNodeType" + slice: "ExpressionNodeType" # No proper slicing for now, only constants.. ctx: str # Only load context def evaluate( - self, context: EvaluationContext, raise_error: bool, force_evaluate: bool = False + self, context: EvaluationContext, raise_error: bool = True, force_evaluate: bool = True ) -> Any: value = self.value.evaluate(context, raise_error, force_evaluate) item = self.slice.evaluate(context, raise_error, force_evaluate) @@ -165,16 +172,16 @@ def used_names(self) -> set[str]: return value.union(item) -class RangeCall(BlueprintExpression): +class RangeCallNode(ExpressionNode): """ Model for something like range(). """ type: Literal["RangeCall"] = "RangeCall" - arg: "ExpressionType" + arg: "ExpressionNodeType" def evaluate( - self, context: EvaluationContext, raise_error: bool, force_evaluate: bool = False + self, context: EvaluationContext, raise_error: bool = True, force_evaluate: bool = True ) -> range: return range(self.arg.evaluate(context, raise_error)) @@ -182,7 +189,7 @@ def used_names(self) -> set[str]: return self.arg.used_names() -class CallModel(BlueprintExpression): +class CallModelNode(ExpressionNode): """Represents a function or method call expression. This class handles both direct function calls and method calls through a fully qualified name. @@ -194,11 +201,11 @@ class CallModel(BlueprintExpression): type: Literal["CallModel"] = "CallModel" func_qualname: str - args: list["ExpressionType"] = [] - kwargs: dict[str, "ExpressionType"] = {} + args: list["ExpressionNodeType"] = [] + kwargs: dict[str, "ExpressionNodeType"] = {} def evaluate( - self, context: EvaluationContext, raise_error: bool, force_evaluate: bool = False + self, context: EvaluationContext, raise_error: bool = True, force_evaluate: bool = True ) -> Any: try: # Split into parts for attribute traversal @@ -245,14 +252,14 @@ def used_names(self) -> set[str]: return names -class Tuple(BlueprintExpression): +class TupleNode(ExpressionNode): """Model for tuple expressions.""" type: Literal["Tuple"] = "Tuple" - elements: list["ExpressionType"] + elements: list["ExpressionNodeType"] def evaluate( - self, context: EvaluationContext, raise_error: bool, force_evaluate: bool = False + self, context: EvaluationContext, raise_error: bool = True, force_evaluate: bool = True ) -> tuple: return tuple(elem.evaluate(context, raise_error, force_evaluate) for elem in self.elements) @@ -260,14 +267,14 @@ def used_names(self) -> set[str]: return self.arg.used_names() -class List(BlueprintExpression): +class ListNode(ExpressionNode): """Model for list expressions.""" type: Literal["List"] = "List" - elements: list["ExpressionType"] + elements: list["ExpressionNodeType"] def evaluate( - self, context: EvaluationContext, raise_error: bool, force_evaluate: bool = False + self, context: EvaluationContext, raise_error: bool = True, force_evaluate: bool = True ) -> list: return [elem.evaluate(context, raise_error, force_evaluate) for elem in self.elements] @@ -280,16 +287,16 @@ def used_names(self) -> set[str]: return names -class ListComp(BlueprintExpression): +class ListCompNode(ExpressionNode): """Model for list comprehension expressions.""" type: Literal["ListComp"] = "ListComp" - element: "ExpressionType" # The expression to evaluate for each item + element: "ExpressionNodeType" # The expression to evaluate for each item target: str # The loop variable name - iter: "ExpressionType" # The iterable expression + iter: "ExpressionNodeType" # The iterable expression def evaluate( - self, context: EvaluationContext, raise_error: bool, force_evaluate: bool = False + self, context: EvaluationContext, raise_error: bool = True, force_evaluate: bool = True ) -> list: result = [] iterable = self.iter.evaluate(context, raise_error, force_evaluate) diff --git a/flow360/component/simulation/blueprint/core/function.py b/flow360/component/simulation/blueprint/core/function.py index dc194a48e..560ba9873 100644 --- a/flow360/component/simulation/blueprint/core/function.py +++ b/flow360/component/simulation/blueprint/core/function.py @@ -5,10 +5,10 @@ import pydantic as pd from .context import EvaluationContext, ReturnValue -from .statements import StatementType +from .statements import StatementNodeType -class BlueprintFunction(pd.BaseModel): +class FunctionNode(pd.BaseModel): """ Represents an entire function: def name(arg1, arg2, ...): @@ -18,7 +18,7 @@ def name(arg1, arg2, ...): name: str args: list[str] defaults: dict[str, Any] - body: list[StatementType] + body: list[StatementNodeType] def __call__(self, context: EvaluationContext, *call_args: Any) -> Any: # Add default values diff --git a/flow360/component/simulation/blueprint/core/generator.py b/flow360/component/simulation/blueprint/core/generator.py index 2b2d36d17..467ca739e 100644 --- a/flow360/component/simulation/blueprint/core/generator.py +++ b/flow360/component/simulation/blueprint/core/generator.py @@ -5,25 +5,25 @@ from typing import Any, Callable from flow360.component.simulation.blueprint.core.expressions import ( - BinOp, - CallModel, - Constant, - List, - ListComp, - Name, - RangeCall, - Subscript, - Tuple, - UnaryOp, + BinOpNode, + CallModelNode, + ConstantNode, + ListCompNode, + ListNode, + NameNode, + RangeCallNode, + SubscriptNode, + TupleNode, + UnaryOpNode, ) -from flow360.component.simulation.blueprint.core.function import BlueprintFunction +from flow360.component.simulation.blueprint.core.function import FunctionNode from flow360.component.simulation.blueprint.core.statements import ( - Assign, - AugAssign, - ForLoop, - IfElse, - Return, - TupleUnpack, + AssignNode, + AugAssignNode, + ForLoopNode, + IfElseNode, + ReturnNode, + TupleUnpackNode, ) from flow360.component.simulation.blueprint.core.types import TargetSyntax from flow360.component.simulation.blueprint.utils.operators import ( @@ -192,34 +192,34 @@ def expr_to_code( return _empty(syntax) # Names and constants are language-agnostic (apart from symbol remaps) - if isinstance(expr, Name): + if isinstance(expr, NameNode): return _name(expr, name_translator) - if isinstance(expr, Constant): + if isinstance(expr, ConstantNode): return _constant(expr) - if isinstance(expr, UnaryOp): + if isinstance(expr, UnaryOpNode): return _unary_op(expr, syntax, name_translator) - if isinstance(expr, BinOp): + if isinstance(expr, BinOpNode): return _binary_op(expr, syntax, name_translator) - if isinstance(expr, RangeCall): + if isinstance(expr, RangeCallNode): return _range_call(expr, syntax, name_translator) - if isinstance(expr, CallModel): + if isinstance(expr, CallModelNode): return _call_model(expr, syntax, name_translator) - if isinstance(expr, Tuple): + if isinstance(expr, TupleNode): return _tuple(expr, syntax, name_translator) - if isinstance(expr, List): + if isinstance(expr, ListNode): return _list(expr, syntax, name_translator) - if isinstance(expr, ListComp): + if isinstance(expr, ListCompNode): return _list_comp(expr, syntax, name_translator) - if isinstance(expr, Subscript): + if isinstance(expr, SubscriptNode): return _subscript(expr, syntax, name_translator) raise ValueError(f"Unsupported expression type: {type(expr)}") @@ -230,12 +230,12 @@ def stmt_to_code( ) -> str: """Convert a statement model back to source code.""" if syntax == TargetSyntax.PYTHON: - if isinstance(stmt, Assign): + if isinstance(stmt, AssignNode): if stmt.target == "_": # Expression statement return expr_to_code(stmt.value) return f"{stmt.target} = {expr_to_code(stmt.value, syntax, remap)}" - if isinstance(stmt, AugAssign): + if isinstance(stmt, AugAssignNode): op_map = { "Add": "+=", "Sub": "-=", @@ -245,7 +245,7 @@ def stmt_to_code( op_str = op_map.get(stmt.op, f"{stmt.op}=") return f"{stmt.target} {op_str} {expr_to_code(stmt.value, syntax, remap)}" - if isinstance(stmt, IfElse): + if isinstance(stmt, IfElseNode): code = [f"if {expr_to_code(stmt.condition)}:"] code.append(_indent("\n".join(stmt_to_code(s, syntax, remap) for s in stmt.body))) if stmt.orelse: @@ -253,15 +253,15 @@ def stmt_to_code( code.append(_indent("\n".join(stmt_to_code(s, syntax, remap) for s in stmt.orelse))) return "\n".join(code) - if isinstance(stmt, ForLoop): + if isinstance(stmt, ForLoopNode): code = [f"for {stmt.target} in {expr_to_code(stmt.iter)}:"] code.append(_indent("\n".join(stmt_to_code(s, syntax, remap) for s in stmt.body))) return "\n".join(code) - if isinstance(stmt, Return): + if isinstance(stmt, ReturnNode): return f"return {expr_to_code(stmt.value, syntax, remap)}" - if isinstance(stmt, TupleUnpack): + if isinstance(stmt, TupleUnpackNode): targets = ", ".join(stmt.targets) if len(stmt.values) == 1: # Single expression that evaluates to a tuple @@ -276,7 +276,7 @@ def stmt_to_code( def model_to_function( - func: BlueprintFunction, + func: FunctionNode, syntax: TargetSyntax = TargetSyntax.PYTHON, remap: dict[str, str] = None, ) -> str: diff --git a/flow360/component/simulation/blueprint/core/parser.py b/flow360/component/simulation/blueprint/core/parser.py index d0de50a30..61bb99fc3 100644 --- a/flow360/component/simulation/blueprint/core/parser.py +++ b/flow360/component/simulation/blueprint/core/parser.py @@ -9,40 +9,40 @@ from flow360.component.simulation.blueprint.core.context import EvaluationContext from flow360.component.simulation.blueprint.core.expressions import ( - BinOp, - CallModel, - Constant, - BlueprintExpression, + BinOpNode, + CallModelNode, + ConstantNode, + ExpressionNode, + ListCompNode, ) -from flow360.component.simulation.blueprint.core.expressions import List as ListExpr +from flow360.component.simulation.blueprint.core.expressions import ListNode as ListExpr from flow360.component.simulation.blueprint.core.expressions import ( - ListComp, - Name, - RangeCall, - Subscript, - Tuple, - UnaryOp, + NameNode, + RangeCallNode, + SubscriptNode, + TupleNode, + UnaryOpNode, ) -from flow360.component.simulation.blueprint.core.function import BlueprintFunction +from flow360.component.simulation.blueprint.core.function import FunctionNode from flow360.component.simulation.blueprint.core.statements import ( - Assign, - AugAssign, - ForLoop, - IfElse, - Return, - TupleUnpack, + AssignNode, + AugAssignNode, + ForLoopNode, + IfElseNode, + ReturnNode, + TupleUnpackNode, ) def parse_expr(node: ast.AST, ctx: EvaluationContext) -> Any: """Parse a Python AST expression into our intermediate representation.""" if isinstance(node, ast.Name): - return Name(id=node.id) + return NameNode(id=node.id) if isinstance(node, ast.Constant): if hasattr(node, "value"): - return Constant(value=node.value) - return Constant(value=node.s) + return ConstantNode(value=node.value) + return ConstantNode(value=node.s) if isinstance(node, ast.Attribute): # Handle attribute access (e.g., td.inf) @@ -54,14 +54,14 @@ def parse_expr(node: ast.AST, ctx: EvaluationContext) -> Any: if isinstance(current, ast.Name): parts.append(current.id) # Create a Name node with the full qualified name - return Name(id=".".join(reversed(parts))) + return NameNode(id=".".join(reversed(parts))) raise ValueError(f"Unsupported attribute access: {ast.dump(node)}") if isinstance(node, ast.UnaryOp): - return UnaryOp(op=type(node.op).__name__, operand=parse_expr(node.operand, ctx)) + return UnaryOpNode(op=type(node.op).__name__, operand=parse_expr(node.operand, ctx)) if isinstance(node, ast.BinOp): - return BinOp( + return BinOpNode( op=type(node.op).__name__, left=parse_expr(node.left, ctx), right=parse_expr(node.right, ctx), @@ -70,14 +70,14 @@ def parse_expr(node: ast.AST, ctx: EvaluationContext) -> Any: if isinstance(node, ast.Compare): if len(node.ops) > 1 or len(node.comparators) > 1: raise ValueError("Only single comparisons are supported") - return BinOp( + return BinOpNode( op=type(node.ops[0]).__name__, left=parse_expr(node.left, ctx), right=parse_expr(node.comparators[0], ctx), ) if isinstance(node, ast.Subscript): - return Subscript( + return SubscriptNode( value=parse_expr(node.value, ctx), slice=parse_expr(node.slice, ctx), ctx=type(node.ctx).__name__, @@ -85,7 +85,7 @@ def parse_expr(node: ast.AST, ctx: EvaluationContext) -> Any: if isinstance(node, ast.Call): if isinstance(node.func, ast.Name) and node.func.id == "range" and len(node.args) == 1: - return RangeCall(arg=parse_expr(node.args[0], ctx)) + return RangeCallNode(arg=parse_expr(node.args[0], ctx)) # Build the full qualified name for the function if isinstance(node.func, ast.Name): @@ -113,14 +113,14 @@ def parse_expr(node: ast.AST, ctx: EvaluationContext) -> Any: if kw.arg is not None and kw.value is not None # Ensure value is not None } - return CallModel( + return CallModelNode( func_qualname=func_name, args=args, kwargs=kwargs, ) if isinstance(node, ast.Tuple): - return Tuple(elements=[parse_expr(elt, ctx) for elt in node.elts]) + return TupleNode(elements=[parse_expr(elt, ctx) for elt in node.elts]) if isinstance(node, ast.List): return ListExpr(elements=[parse_expr(elt, ctx) for elt in node.elts]) @@ -133,7 +133,7 @@ def parse_expr(node: ast.AST, ctx: EvaluationContext) -> Any: raise ValueError("Only simple targets in list comprehensions are supported") if gen.ifs: raise ValueError("If conditions in list comprehensions are not supported") - return ListComp( + return ListCompNode( element=parse_expr(node.elt, ctx), target=gen.target.id, iter=parse_expr(gen.iter, ctx), @@ -150,22 +150,22 @@ def parse_stmt(node: ast.AST, ctx: EvaluationContext) -> Any: target = node.targets[0] if isinstance(target, ast.Name): - return Assign(target=target.id, value=parse_expr(node.value, ctx)) + return AssignNode(target=target.id, value=parse_expr(node.value, ctx)) if isinstance(target, ast.Tuple): if not all(isinstance(elt, ast.Name) for elt in target.elts): raise ValueError("Only simple names supported in tuple unpacking") targets = [elt.id for elt in target.elts] if isinstance(node.value, ast.Tuple): values = [parse_expr(val, ctx) for val in node.value.elts] - return TupleUnpack(targets=targets, values=values) - return TupleUnpack(targets=targets, values=[parse_expr(node.value, ctx)]) + return TupleUnpackNode(targets=targets, values=values) + return TupleUnpackNode(targets=targets, values=[parse_expr(node.value, ctx)]) raise ValueError(f"Unsupported assignment target: {type(target)}") if isinstance(node, ast.AugAssign): if not isinstance(node.target, ast.Name): raise ValueError("Only simple names supported in augmented assignment") - return AugAssign( + return AugAssignNode( target=node.target.id, op=type(node.op).__name__, value=parse_expr(node.value, ctx), @@ -173,10 +173,10 @@ def parse_stmt(node: ast.AST, ctx: EvaluationContext) -> Any: if isinstance(node, ast.Expr): # For expression statements, we use "_" as a dummy target - return Assign(target="_", value=parse_expr(node.value, ctx)) + return AssignNode(target="_", value=parse_expr(node.value, ctx)) if isinstance(node, ast.If): - return IfElse( + return IfElseNode( condition=parse_expr(node.test, ctx), body=[parse_stmt(stmt, ctx) for stmt in node.body], orelse=[parse_stmt(stmt, ctx) for stmt in node.orelse] if node.orelse else [], @@ -185,7 +185,7 @@ def parse_stmt(node: ast.AST, ctx: EvaluationContext) -> Any: if isinstance(node, ast.For): if not isinstance(node.target, ast.Name): raise ValueError("Only simple names supported as loop targets") - return ForLoop( + return ForLoopNode( target=node.target.id, iter=parse_expr(node.iter, ctx), body=[parse_stmt(stmt, ctx) for stmt in node.body], @@ -194,7 +194,7 @@ def parse_stmt(node: ast.AST, ctx: EvaluationContext) -> Any: if isinstance(node, ast.Return): if node.value is None: raise ValueError("Return statements must have a value") - return Return(value=parse_expr(node.value, ctx)) + return ReturnNode(value=parse_expr(node.value, ctx)) raise ValueError(f"Unsupported statement type: {type(node)}") @@ -202,7 +202,7 @@ def parse_stmt(node: ast.AST, ctx: EvaluationContext) -> Any: def function_to_model( source: Union[str, Callable[..., Any]], ctx: EvaluationContext, -) -> BlueprintFunction: +) -> FunctionNode: """Parse a Python function definition into our intermediate representation. Args: @@ -244,13 +244,13 @@ def function_to_model( # Parse the function body body = [parse_stmt(stmt, ctx) for stmt in func_def.body] - return BlueprintFunction(name=name, args=args, body=body, defaults=defaults) + return FunctionNode(name=name, args=args, body=body, defaults=defaults) def expr_to_model( source: str, ctx: EvaluationContext, -) -> BlueprintExpression: +) -> ExpressionNode: """Parse a Python rvalue expression Args: diff --git a/flow360/component/simulation/blueprint/core/statements.py b/flow360/component/simulation/blueprint/core/statements.py index 0337451fd..85fbe75d5 100644 --- a/flow360/component/simulation/blueprint/core/statements.py +++ b/flow360/component/simulation/blueprint/core/statements.py @@ -5,11 +5,11 @@ import pydantic as pd from .context import EvaluationContext, ReturnValue -from .expressions import ExpressionType +from .expressions import ExpressionNodeType from .types import Evaluable # Forward declaration of type -StatementType = Annotated[ +StatementNodeType = Annotated[ # pylint: disable=duplicate-code Union[ "Assign", @@ -23,33 +23,33 @@ ] -class BlueprintStatement(pd.BaseModel, Evaluable): +class StatementNode(pd.BaseModel, Evaluable): """ Base class for statements (like 'if', 'for', assignments, etc.). """ def evaluate( - self, context: EvaluationContext, raise_error: bool, force_evaluate: bool = False + self, context: EvaluationContext, raise_error: bool = True, force_evaluate: bool = True ) -> None: raise NotImplementedError -class Assign(BlueprintStatement): +class AssignNode(StatementNode): """ Represents something like 'result = '. """ type: Literal["Assign"] = "Assign" target: str - value: ExpressionType + value: ExpressionNodeType def evaluate( - self, context: EvaluationContext, raise_error: bool, force_evaluate: bool = False + self, context: EvaluationContext, raise_error: bool = True, force_evaluate: bool = True ) -> None: context.set(self.target, self.value.evaluate(context, raise_error, force_evaluate)) -class AugAssign(BlueprintStatement): +class AugAssignNode(StatementNode): """ Represents something like 'result += '. The 'op' is again the operator class name (e.g. 'Add', 'Mult', etc.). @@ -58,10 +58,10 @@ class AugAssign(BlueprintStatement): type: Literal["AugAssign"] = "AugAssign" target: str op: str - value: ExpressionType + value: ExpressionNodeType def evaluate( - self, context: EvaluationContext, raise_error: bool, force_evaluate: bool = False + self, context: EvaluationContext, raise_error: bool = True, force_evaluate: bool = True ) -> None: old_val = context.get(self.target) increment = self.value.evaluate(context, raise_error, force_evaluate) @@ -77,7 +77,7 @@ def evaluate( raise ValueError(f"Unsupported augmented assignment operator: {self.op}") -class IfElse(BlueprintStatement): +class IfElseNode(StatementNode): """ Represents an if/else block: if condition: @@ -87,12 +87,12 @@ class IfElse(BlueprintStatement): """ type: Literal["IfElse"] = "IfElse" - condition: ExpressionType - body: list["StatementType"] - orelse: list["StatementType"] + condition: ExpressionNodeType + body: list["StatementNodeType"] + orelse: list["StatementNodeType"] def evaluate( - self, context: EvaluationContext, raise_error: bool, force_evaluate: bool = False + self, context: EvaluationContext, raise_error: bool = True, force_evaluate: bool = True ) -> None: if self.condition.evaluate(context, raise_error, force_evaluate): for stmt in self.body: @@ -102,7 +102,7 @@ def evaluate( stmt.evaluate(context, raise_error) -class ForLoop(BlueprintStatement): +class ForLoopNode(StatementNode): """ Represents a for loop: for in : @@ -111,11 +111,11 @@ class ForLoop(BlueprintStatement): type: Literal["ForLoop"] = "ForLoop" target: str - iter: ExpressionType - body: list["StatementType"] + iter: ExpressionNodeType + body: list["StatementNodeType"] def evaluate( - self, context: EvaluationContext, raise_error: bool, force_evaluate: bool = False + self, context: EvaluationContext, raise_error: bool = True, force_evaluate: bool = True ) -> None: iterable = self.iter.evaluate(context, raise_error, force_evaluate) for item in iterable: @@ -124,34 +124,34 @@ def evaluate( stmt.evaluate(context, raise_error, force_evaluate) -class Return(BlueprintStatement): +class ReturnNode(StatementNode): """ Represents a return statement: return . We'll raise a custom exception to stop execution in the function. """ type: Literal["Return"] = "Return" - value: ExpressionType + value: ExpressionNodeType def evaluate( - self, context: EvaluationContext, raise_error: bool, force_evaluate: bool = False + self, context: EvaluationContext, raise_error: bool = True, force_evaluate: bool = True ) -> None: val = self.value.evaluate(context, raise_error, force_evaluate) raise ReturnValue(val) -class TupleUnpack(BlueprintStatement): +class TupleUnpackNode(StatementNode): """Model for tuple unpacking assignments.""" type: Literal["TupleUnpack"] = "TupleUnpack" targets: list[str] - values: list[ExpressionType] + values: list[ExpressionNodeType] def evaluate( - self, context: EvaluationContext, raise_error: bool, force_evaluate: bool = False + self, context: EvaluationContext, raise_error: bool = True, force_evaluate: bool = True ) -> None: evaluated_values = [ - val.evaluate(context, raise_error, force_evaluate, inlines) for val in self.values + val.evaluate(context, raise_error, force_evaluate) for val in self.values ] for target, value in zip(self.targets, evaluated_values): context.set(target, value) diff --git a/flow360/component/simulation/blueprint/core/types.py b/flow360/component/simulation/blueprint/core/types.py index 9da6af184..a5d3a6830 100644 --- a/flow360/component/simulation/blueprint/core/types.py +++ b/flow360/component/simulation/blueprint/core/types.py @@ -11,9 +11,10 @@ class Evaluable(metaclass=abc.ABCMeta): """Base class for all classes that allow evaluation from their symbolic form""" + @abc.abstractmethod def evaluate( - self, context: EvaluationContext, raise_error: bool, force_evaluate: bool = False + self, context: EvaluationContext, raise_error: bool = True, force_evaluate: bool = True ) -> Any: """ Evaluate the expression using the given context. diff --git a/flow360/component/simulation/blueprint/flow360/__init__.py b/flow360/component/simulation/blueprint/flow360/__init__.py deleted file mode 100644 index 7d8b3a2a3..000000000 --- a/flow360/component/simulation/blueprint/flow360/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Flow360-specific implementation of the blueprint module""" diff --git a/flow360/component/simulation/blueprint/flow360/functions/math.py b/flow360/component/simulation/blueprint/flow360/functions/math.py deleted file mode 100644 index 8ced5c9c5..000000000 --- a/flow360/component/simulation/blueprint/flow360/functions/math.py +++ /dev/null @@ -1,7 +0,0 @@ -# - - -def cross(foo, bar): - """Customized Cross function to work with the `Expression` and Variables""" - - # If we ever diff --git a/flow360/component/simulation/framework/param_utils.py b/flow360/component/simulation/framework/param_utils.py index 7eee05d65..7f252019e 100644 --- a/flow360/component/simulation/framework/param_utils.py +++ b/flow360/component/simulation/framework/param_utils.py @@ -18,7 +18,7 @@ _VolumeEntityBase, ) from flow360.component.simulation.unit_system import LengthType -from flow360.component.simulation.blueprint.flow360.expressions import UserVariable +from flow360.component.simulation.user_code.core.types import UserVariable from flow360.component.simulation.utils import model_attribute_unlock diff --git a/flow360/component/simulation/primitives.py b/flow360/component/simulation/primitives.py index ae689d712..8619beb27 100644 --- a/flow360/component/simulation/primitives.py +++ b/flow360/component/simulation/primitives.py @@ -21,7 +21,7 @@ ) from flow360.component.simulation.framework.unique_list import UniqueStringList from flow360.component.simulation.unit_system import AngleType, AreaType, LengthType -from flow360.component.simulation.blueprint.flow360.expressions import ValueOrExpression +from flow360.component.simulation.user_code.core.types import ValueOrExpression from flow360.component.simulation.utils import model_attribute_unlock from flow360.component.types import Axis diff --git a/flow360/component/simulation/services.py b/flow360/component/simulation/services.py index 79be71654..06758af5f 100644 --- a/flow360/component/simulation/services.py +++ b/flow360/component/simulation/services.py @@ -19,8 +19,6 @@ from flow360.component.simulation.meshing_param.volume_params import AutomatedFarfield from flow360.component.simulation.models.surface_models import Freestream, Wall -# Following unused-import for supporting parse_model_dict - # pylint: disable=unused-import from flow360.component.simulation.operating_condition.operating_condition import ( AerospaceCondition, @@ -50,7 +48,7 @@ u, unit_system_manager, ) -from flow360.component.simulation.blueprint.flow360.expressions import Expression, UserVariable +from flow360.component.simulation.user_code.core.types import Expression, UserVariable from flow360.component.simulation.utils import model_attribute_unlock from flow360.component.simulation.validation.validation_context import ( ALL, @@ -61,6 +59,9 @@ from flow360.plugins.report.report import get_default_report_summary_template from flow360.version import __version__ +# Following unused-import for supporting parse_model_dict + + # Required for correct global scope initialization diff --git a/flow360/component/simulation/simulation_params.py b/flow360/component/simulation/simulation_params.py index 2f9a6fdc5..2a1364f45 100644 --- a/flow360/component/simulation/simulation_params.py +++ b/flow360/component/simulation/simulation_params.py @@ -60,7 +60,7 @@ unit_system_manager, unyt_quantity, ) -from flow360.component.simulation.blueprint.flow360.expressions import UserVariable +from flow360.component.simulation.user_code.core.types import UserVariable from flow360.component.simulation.user_defined_dynamics.user_defined_dynamics import ( UserDefinedDynamic, ) diff --git a/flow360/component/simulation/translator/utils.py b/flow360/component/simulation/translator/utils.py index b0c383306..f525499aa 100644 --- a/flow360/component/simulation/translator/utils.py +++ b/flow360/component/simulation/translator/utils.py @@ -17,7 +17,7 @@ ) from flow360.component.simulation.simulation_params import SimulationParams from flow360.component.simulation.unit_system import LengthType -from flow360.component.simulation.blueprint.flow360.expressions import Expression +from flow360.component.simulation.user_code.core.types import Expression from flow360.component.simulation.utils import is_exact_instance diff --git a/flow360/component/simulation/user_code/__init__.py b/flow360/component/simulation/user_code/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/flow360/component/simulation/user_code/core/__init__.py b/flow360/component/simulation/user_code/core/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/flow360/component/simulation/user_code/core/context.py b/flow360/component/simulation/user_code/core/context.py new file mode 100644 index 000000000..043bad009 --- /dev/null +++ b/flow360/component/simulation/user_code/core/context.py @@ -0,0 +1,145 @@ +from typing import Any + +from flow360.component.simulation.blueprint.core import EvaluationContext +from flow360.component.simulation.blueprint.core.resolver import CallableResolver + + +def _unit_list(): + """Import a list of available unit symbols from the unyt module""" + from unyt import Unit, unit_symbols, unyt_array + + symbols = set() + + for _, value in unit_symbols.__dict__.items(): + if isinstance(value, (unyt_array, Unit)): + symbols.add(str(value)) + + return list(symbols) + + +def _import_units(_) -> Any: + """Import and return allowed unit callables""" + from flow360.component.simulation import units as u + + return u + + +def _import_math(_) -> Any: + """Import and return allowed function callables""" + from flow360.component.simulation.user_code.functions import math + + return math + + +def _import_control(_) -> Any: + """Import and return allowed control variable callables""" + from flow360.component.simulation.user_code.variables import control + + return control + + +def _import_solution(_) -> Any: + """Import and return allowed solution variable callables""" + from flow360.component.simulation.user_code.variables import solution + + return solution + + +WHITELISTED_CALLABLES = { + "flow360_math": {"prefix": "fn.", "callables": ["cross"], "evaluate": True}, + "flow360.units": {"prefix": "u.", "callables": _unit_list(), "evaluate": True}, + "flow360.control": { + "prefix": "control.", + "callables": [ + "MachRef", + "Tref", + "t", + "physicalStep", + "pseudoStep", + "timeStepSize", + "alphaAngle", + "betaAngle", + "pressureFreestream", + "momentLengthX", + "momentLengthY", + "momentLengthZ", + "momentCenterX", + "momentCenterY", + "momentCenterZ", + "theta", + "omega", + "omegaDot", + ], + "evaluate": False, + }, + "flow360.solution": { + "prefix": "solution.", + "callables": [ + "mut", + "mu", + "solutionNavierStokes", + "residualNavierStokes", + "solutionTurbulence", + "residualTurbulence", + "kOmega", + "nuHat", + "solutionTransition", + "residualTransition", + "solutionHeatSolver", + "residualHeatSolver", + "coordinate", + "velocity", + "bet_thrust", + "bet_torque", + "bet_omega", + "CD", + "CL", + "forceX", + "forceY", + "forceZ", + "momentX", + "momentY", + "momentZ", + "nodeNormals", + "wallFunctionMetric", + "wallShearStress", + "yPlus", + ], + "evaluate": False, + }, +} + +# Define allowed modules +ALLOWED_MODULES = {"u", "fl", "control", "solution", "math"} + +ALLOWED_CALLABLES = { + **{ + f"{group['prefix']}{callable}": None + for group in WHITELISTED_CALLABLES.values() + for callable in group["callables"] + }, +} + +EVALUATION_BLACKLIST = { + **{ + f"{group['prefix']}{callable}": None + for group in WHITELISTED_CALLABLES.values() + for callable in group["callables"] + if not group["evaluate"] + }, +} + +# Note: Keys of IMPORT_FUNCTIONS needs to be consistent with ALLOWED_MODULES +IMPORT_FUNCTIONS = { + "u": _import_units, + "math": _import_math, + "control": _import_control, + "solution": _import_solution, +} + +default_context = EvaluationContext( + CallableResolver(ALLOWED_CALLABLES, ALLOWED_MODULES, IMPORT_FUNCTIONS, EVALUATION_BLACKLIST) +) + +user_variables: set[str] = set() +solver_variable_name_map: dict[str, str] = {} diff --git a/flow360/component/simulation/blueprint/flow360/expressions.py b/flow360/component/simulation/user_code/core/types.py similarity index 74% rename from flow360/component/simulation/blueprint/flow360/expressions.py rename to flow360/component/simulation/user_code/core/types.py index a6d51edc6..600c1aa9f 100644 --- a/flow360/component/simulation/blueprint/flow360/expressions.py +++ b/flow360/component/simulation/user_code/core/types.py @@ -6,207 +6,60 @@ from numbers import Number from typing import Annotated, Any, Generic, Iterable, Literal, Optional, TypeVar, Union +import numpy as np import pydantic as pd from pydantic import BeforeValidator, Discriminator, PlainSerializer, Tag from pydantic_core import InitErrorDetails, core_schema from typing_extensions import Self -from unyt import Unit, unyt_array, unit_symbols +from unyt import Unit, unyt_array from flow360.component.simulation.blueprint import Evaluable, expr_to_model from flow360.component.simulation.blueprint.core import EvaluationContext, expr_to_code -from flow360.component.simulation.blueprint.core.resolver import CallableResolver from flow360.component.simulation.blueprint.core.types import TargetSyntax - from flow360.component.simulation.framework.base_model import Flow360BaseModel - - -def _unit_list(): - symbols = set() - - for _, value in unit_symbols.__dict__.items(): - if isinstance(value, (unyt_array, Unit)): - symbols.add(str(value)) - - return list(symbols) - - -def _import_units(_) -> Any: - """Import and return allowed unit callables""" - from flow360.component.simulation import units as u - return u - - -def _import_math(_) -> Any: - """Import and return allowed function callables""" - from flow360.component.simulation.blueprint.flow360.functions import math - return math - - -def _import_control(_) -> Any: - """Import and return allowed control variable callables""" - from flow360.component.simulation.blueprint.flow360.variables import control - return control - - -def _import_solution(_) -> Any: - """Import and return allowed solution variable callables""" - from flow360.component.simulation.blueprint.flow360.variables import solution - return solution - - -WHITELISTED_CALLABLES = { - "flow360_math": {"prefix": "fn.", "callables": ["cross"], "evaluate": True}, - "flow360.units": {"prefix": "u.", "callables": _unit_list(), "evaluate": True}, - "flow360.control": { - "prefix": "control.", - "callables": [ - "mut", - "mu", - "solutionNavierStokes", - "residualNavierStokes", - "solutionTurbulence", - "residualTurbulence", - "kOmega", - "nuHat", - "solutionTransition", - "residualTransition", - "solutionHeatSolver", - "residualHeatSolver", - "coordinate", - "physicalStep", - "pseudoStep", - "timeStepSize", - "alphaAngle", - "betaAngle", - "pressureFreestream", - "momentLengthX", - "momentLengthY", - "momentLengthZ", - "momentCenterX", - "momentCenterY", - "momentCenterZ", - "bet_thrust", - "bet_torque", - "bet_omega", - "CD", - "CL", - "forceX", - "forceY", - "forceZ", - "momentX", - "momentY", - "momentZ", - "nodeNormals", - "theta", - "omega", - "omegaDot", - "wallFunctionMetric", - "wallShearStress", - "yPlus", - ], - "evaluate": False, - }, - "flow360.solution": { - "prefix": "solution.", - "callables": [ - "mut", - "mu", - "solutionNavierStokes", - "residualNavierStokes", - "solutionTurbulence", - "residualTurbulence", - "kOmega", - "nuHat", - "solutionTransition", - "residualTransition", - "solutionHeatSolver", - "residualHeatSolver", - "coordinate", - "physicalStep", - "pseudoStep", - "timeStepSize", - "alphaAngle", - "betaAngle", - "pressureFreestream", - "momentLengthX", - "momentLengthY", - "momentLengthZ", - "momentCenterX", - "momentCenterY", - "momentCenterZ", - "bet_thrust", - "bet_torque", - "bet_omega", - "CD", - "CL", - "forceX", - "forceY", - "forceZ", - "momentX", - "momentY", - "momentZ", - "nodeNormals", - "theta", - "omega", - "omegaDot", - "wallFunctionMetric", - "wallShearStress", - "yPlus", - ], - "evaluate": False, - }, -} - -# Define allowed modules -ALLOWED_MODULES = {"u", "fl", "control", "solution", "math"} - -ALLOWED_CALLABLES = { - **{ - f"{group['prefix']}{callable}": None - for group in WHITELISTED_CALLABLES.values() - for callable in group["callables"] - }, -} - -EVALUATION_BLACKLIST = { - **{ - f"{group['prefix']}{callable}": None - for group in WHITELISTED_CALLABLES.values() - for callable in group["callables"] - if not group["evaluate"] - }, -} - -# Note: Keys of IMPORT_FUNCTIONS needs to be consistent with ALLOWED_MODULES -IMPORT_FUNCTIONS = { - "u": _import_units, - "math": _import_math, - "control": _import_control, - "solution": _import_solution, -} - -resolver = CallableResolver( - ALLOWED_CALLABLES, ALLOWED_MODULES, IMPORT_FUNCTIONS, EVALUATION_BLACKLIST +from flow360.component.simulation.user_code.core.context import default_context +from flow360.component.simulation.user_code.core.utils import ( + handle_syntax_error, + is_number_string, + split_keep_delimiters, ) -_global_ctx: EvaluationContext = EvaluationContext(resolver) _user_variables: set[str] = set() _solver_variable_name_map: dict[str, str] = {} -def _is_number_string(s: str) -> bool: - try: - float(s) - return True - except ValueError: - return False +def __soft_fail_add__(self, other): + if not isinstance(other, Expression) and not isinstance(other, Variable): + return np.ndarray.__add__(self, other) + else: + return NotImplemented + + +def __soft_fail_sub__(self, other): + if not isinstance(other, Expression) and not isinstance(other, Variable): + return np.ndarray.__sub__(self, other) + else: + return NotImplemented + + +def __soft_fail_mul__(self, other): + if not isinstance(other, Expression) and not isinstance(other, Variable): + return np.ndarray.__mul__(self, other) + else: + return NotImplemented + +def __soft_fail_truediv__(self, other): + if not isinstance(other, Expression) and not isinstance(other, Variable): + return np.ndarray.__truediv__(self, other) + else: + return NotImplemented -def _split_keep_delimiters(value: str, delimiters: list) -> list: - escaped_delimiters = [re.escape(d) for d in delimiters] - pattern = f"({'|'.join(escaped_delimiters)})" - result = re.split(pattern, value) - return [part for part in result if part != ""] + +unyt_array.__add__ = __soft_fail_add__ +unyt_array.__sub__ = __soft_fail_sub__ +unyt_array.__mul__ = __soft_fail_mul__ +unyt_array.__truediv__ = __soft_fail_truediv__ def _convert_numeric(value): @@ -216,20 +69,20 @@ def _convert_numeric(value): arg = str(value) elif isinstance(value, Unit): unit = str(value) - tokens = _split_keep_delimiters(unit, unit_delimiters) + tokens = split_keep_delimiters(unit, unit_delimiters) arg = "" for token in tokens: - if token not in unit_delimiters and not _is_number_string(token): + if token not in unit_delimiters and not is_number_string(token): token = f"u.{token}" arg += token else: arg += token elif isinstance(value, unyt_array): unit = str(value.units) - tokens = _split_keep_delimiters(unit, unit_delimiters) + tokens = split_keep_delimiters(unit, unit_delimiters) arg = f"{_convert_argument(value.value.tolist())[0]} * " for token in tokens: - if token not in unit_delimiters and not _is_number_string(token): + if token not in unit_delimiters and not is_number_string(token): token = f"u.{token}" arg += token else: @@ -401,7 +254,7 @@ class UserVariable(Variable): @classmethod def update_context(cls, value): """Auto updating context when new variable is declared""" - _global_ctx.set(value.name, value.value) + default_context.set(value.name, value.value) _user_variables.add(value.name) return value @@ -413,7 +266,7 @@ def check_dependencies(cls, value): stack = [(value.name, [value.name])] while stack: (current_name, current_path) = stack.pop() - current_value = _global_ctx.get(current_name) + current_value = default_context.get(current_name) if isinstance(current_value, Expression): used_names = current_value.user_variable_names() if [name for name in used_names if name in current_path]: @@ -438,36 +291,13 @@ class SolverVariable(Variable): @classmethod def update_context(cls, value): """Auto updating context when new variable is declared""" - _global_ctx.set(value.name, value.value) + default_context.set(value.name, value.value, SolverVariable) _solver_variable_name_map[value.name] = ( value.solver_name if value.solver_name is not None else value.name ) return value -def _handle_syntax_error(se: SyntaxError, source: str): - caret = " " * (se.offset - 1) + "^" if se.text and se.offset else None - msg = f"{se.msg} at line {se.lineno}, column {se.offset}" - if caret: - msg += f"\n{se.text.rstrip()}\n{caret}" - - raise pd.ValidationError.from_exception_data( - "expression_syntax", - [ - InitErrorDetails( - type="value_error", - msg=se.msg, - input=source, - ctx={ - "line": se.lineno, - "column": se.offset, - "error": msg, - }, - ) - ], - ) - - class Expression(Flow360BaseModel, Evaluable): """ A symbolic, validated representation of a mathematical expression. @@ -502,9 +332,9 @@ def _validate_expression(cls, value) -> Self: ) raise pd.ValidationError.from_exception_data("Expression type error", [details]) try: - expr_to_model(expression, _global_ctx) + expr_to_model(expression, default_context) except SyntaxError as s_err: - _handle_syntax_error(s_err, expression) + handle_syntax_error(s_err, expression) except ValueError as v_err: details = InitErrorDetails(type="value_error", ctx={"error": v_err}) raise pd.ValidationError.from_exception_data("Expression value error", [details]) @@ -519,22 +349,22 @@ def evaluate( ) -> Union[float, list, unyt_array]: """Evaluate this expression against the given context.""" if context is None: - context = _global_ctx + context = default_context expr = expr_to_model(self.expression, context) result = expr.evaluate(context, raise_error, force_evaluate) return result def user_variables(self): """Get list of user variables used in expression.""" - expr = expr_to_model(self.expression, _global_ctx) + expr = expr_to_model(self.expression, default_context) names = expr.used_names() names = [name for name in names if name in _user_variables] - return [UserVariable(name=name, value=_global_ctx.get(name)) for name in names] + return [UserVariable(name=name, value=default_context.get(name)) for name in names] def user_variable_names(self): """Get list of user variable names used in expression.""" - expr = expr_to_model(self.expression, _global_ctx) + expr = expr_to_model(self.expression, default_context) names = expr.used_names() names = [name for name in names if name in _user_variables] @@ -547,12 +377,6 @@ def translate_symbol(name): if name in _solver_variable_name_map: return _solver_variable_name_map[name] - if name in _user_variables: - value = _global_ctx.get(name) - if isinstance(value, Expression): - return f"{value.to_solver_code(params)}" - return _convert_numeric(value) - match = re.fullmatch("u\\.(.+)", name) if match: @@ -563,9 +387,14 @@ def translate_symbol(name): return name - expr = expr_to_model(self.expression, _global_ctx) - source = expr_to_code(expr, TargetSyntax.CPP, translate_symbol) - return source + partial_result = self.evaluate(default_context, raise_error=False, force_evaluate=False) + + if isinstance(partial_result, Expression): + expr = expr_to_model(partial_result.expression, default_context) + else: + expr = expr_to_model(_convert_numeric(partial_result), default_context) + + return expr_to_code(expr, TargetSyntax.CPP, translate_symbol) def __hash__(self): return hash(self.expression) diff --git a/flow360/component/simulation/user_code/core/utils.py b/flow360/component/simulation/user_code/core/utils.py new file mode 100644 index 000000000..1b9f05187 --- /dev/null +++ b/flow360/component/simulation/user_code/core/utils.py @@ -0,0 +1,42 @@ +import re + +import pydantic as pd +from pydantic_core import InitErrorDetails + + +def is_number_string(s: str) -> bool: + try: + float(s) + return True + except ValueError: + return False + + +def split_keep_delimiters(value: str, delimiters: list) -> list: + escaped_delimiters = [re.escape(d) for d in delimiters] + pattern = f"({'|'.join(escaped_delimiters)})" + result = re.split(pattern, value) + return [part for part in result if part != ""] + + +def handle_syntax_error(se: SyntaxError, source: str): + caret = " " * (se.offset - 1) + "^" if se.text and se.offset else None + msg = f"{se.msg} at line {se.lineno}, column {se.offset}" + if caret: + msg += f"\n{se.text.rstrip()}\n{caret}" + + raise pd.ValidationError.from_exception_data( + "expression_syntax", + [ + InitErrorDetails( + type="value_error", + msg=se.msg, + input=source, + ctx={ + "line": se.lineno, + "column": se.offset, + "error": msg, + }, + ) + ], + ) diff --git a/flow360/component/simulation/user_code/functions/__init__.py b/flow360/component/simulation/user_code/functions/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/flow360/component/simulation/user_code/functions/math.py b/flow360/component/simulation/user_code/functions/math.py new file mode 100644 index 000000000..9cbd28219 --- /dev/null +++ b/flow360/component/simulation/user_code/functions/math.py @@ -0,0 +1,37 @@ +from enum import Enum + +from flow360.component.simulation.user_code.core.types import Expression, Variable + + +def _convert_argument(value): + """Convert argument for use in builtin expression math functions""" + + # If the argument is a Variable, convert it to an expression + if isinstance(value, Variable): + return Expression.model_validate(value) + + return value + + +def cross(left, right): + """Customized Cross function to work with the `Expression` and Variables""" + + left = _convert_argument(left) + right = _convert_argument(right) + + result = [ + left[1] * right[2] - left[2] * right[1], + left[2] * right[0] - left[0] * right[2], + left[0] * right[1] - left[1] * right[0], + ] + + is_expression_type = False + + for item in result: + if isinstance(item, Expression): + is_expression_type = True + + if is_expression_type: + return Expression.model_validate(result) + else: + return result diff --git a/flow360/component/simulation/user_code/variables/__init__.py b/flow360/component/simulation/user_code/variables/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/flow360/component/simulation/blueprint/flow360/variables/control.py b/flow360/component/simulation/user_code/variables/control.py similarity index 96% rename from flow360/component/simulation/blueprint/flow360/variables/control.py rename to flow360/component/simulation/user_code/variables/control.py index fde93053e..4639a8972 100644 --- a/flow360/component/simulation/blueprint/flow360/variables/control.py +++ b/flow360/component/simulation/user_code/variables/control.py @@ -1,7 +1,7 @@ """Control variables of Flow360""" from flow360.component.simulation import units as u -from flow360.component.simulation.blueprint.flow360.expressions import SolverVariable +from flow360.component.simulation.user_code.core.types import SolverVariable # pylint:disable=no-member MachRef = SolverVariable( diff --git a/flow360/component/simulation/blueprint/flow360/variables/solution.py b/flow360/component/simulation/user_code/variables/solution.py similarity index 97% rename from flow360/component/simulation/blueprint/flow360/variables/solution.py rename to flow360/component/simulation/user_code/variables/solution.py index bdbdf9e90..4b5f6b90a 100644 --- a/flow360/component/simulation/blueprint/flow360/variables/solution.py +++ b/flow360/component/simulation/user_code/variables/solution.py @@ -2,7 +2,7 @@ import unyt as u -from flow360.component.simulation.blueprint.flow360.expressions import SolverVariable +from flow360.component.simulation.user_code.core.types import SolverVariable mut = SolverVariable(name="solution.mut", value=float("NaN")) # Turbulent viscosity mu = SolverVariable(name="solution.mu", value=float("NaN")) # Laminar viscosity diff --git a/flow360/component/simulation/validation/validation_simulation_params.py b/flow360/component/simulation/validation/validation_simulation_params.py index 175dadba6..cf6722d7d 100644 --- a/flow360/component/simulation/validation/validation_simulation_params.py +++ b/flow360/component/simulation/validation/validation_simulation_params.py @@ -22,7 +22,7 @@ VolumeOutput, ) from flow360.component.simulation.time_stepping.time_stepping import Steady, Unsteady -from flow360.component.simulation.blueprint.flow360.expressions import Expression +from flow360.component.simulation.user_code.core.types import Expression from flow360.component.simulation.validation.validation_context import ( ALL, CASE, diff --git a/tests/simulation/test_expressions.py b/tests/simulation/test_expressions.py index bc31c5163..e64254b13 100644 --- a/tests/simulation/test_expressions.py +++ b/tests/simulation/test_expressions.py @@ -10,18 +10,14 @@ SimulationParams, Solid, Unsteady, - control, - solution, + math, u, ) from flow360.component.simulation.framework.base_model import Flow360BaseModel from flow360.component.simulation.framework.param_utils import AssetCache from flow360.component.simulation.models.material import Water, aluminum from flow360.component.simulation.outputs.outputs import SurfaceOutput -from flow360.component.simulation.primitives import ( - GenericVolume, - Surface, -) +from flow360.component.simulation.primitives import GenericVolume, Surface from flow360.component.simulation.unit_system import ( AbsoluteTemperatureType, AngleType, @@ -48,11 +44,12 @@ VelocityType, ViscosityType, ) -from flow360.component.simulation.blueprint.flow360.expressions import ( +from flow360.component.simulation.user_code.core.types import ( Expression, UserVariable, ValueOrExpression, ) +from flow360.component.simulation.user_code.variables import control, solution @pytest.fixture(autouse=True) @@ -424,94 +421,6 @@ class TestModel(Flow360BaseModel): assert str(deserialized.field) == "4.0 m/s" -# def test_expression_vectors_scalars(): -# class ScalarModel(Flow360BaseModel): -# scalar: ValueOrExpression[float] = pd.Field() -# -# x = UserVariable(name="x", value=1) -# -# # Since numpy arrays already give us the behavior we want there is no point -# # to building our own vector arithmetic. We just add the symbols to the whitelist -# -# # Using expression types inside numpy arrays works OK -# a = np.array([x + 1, 0, x**2]) -# b = np.array([0, x / 2, 3]) -# -# c = np.linalg.norm(a + b) # This yields an expression containing the inlined dot product... -# -# # Sadly it seems like we cannot stop numpy from inlining some functions by -# # implementing a specific method (like with trigonometic functions for example) -# -# d = np.sin(c) # This yields an expression -# e = np.cos(c) # This also yields an expression -# -# model = ScalarModel( -# scalar=np.arctan(d + e + 1) -# ) # So we can later compose those into expressions further... -# -# assert str(model.scalar) == ( -# "np.arctan(np.sin(np.sqrt((x + 1 + 0) * (x + 1 + 0) + " -# "((0 + x / 2) * (0 + x / 2)) + ((x ** 2 + 3) * (x ** 2 + 3)))) + " -# "(np.cos(np.sqrt((x + 1 + 0) * (x + 1 + 0) + ((0 + x / 2) * " -# "(0 + x / 2)) + ((x ** 2 + 3) * (x ** 2 + 3))))) + 1)" -# ) -# -# result = model.scalar.evaluate() -# -# assert result == -0.1861456975646416 -# -# # Notice that when we inline some operations (like cross/dot product or norm, for example) -# # we make the underlying generated string of the expression ugly... -# # -# # Luckily this is transparent to the user. When the user is defining expressions in python he does -# # not have to worry about the internal string representation or the CUDA-generated code -# # (for CUDA code inlining might actually probably be our best bet to reduce function calls...) -# # -# # Conversely, when we are dealing with frontend strings we can deal with non-inlined numpy functions -# # because they are whitelisted by the blueprint parser: -# -# # Let's simulate a frontend use case by parsing raw string input: -# -# # The user defines some variables for convenience -# -# a = UserVariable(name="a", value="np.array([x + 1, 0, x ** 2])") -# b = UserVariable(name="b", value="np.array([0, x / 2, 3])") -# -# c = UserVariable(name="c", value="np.linalg.norm(a + b)") -# -# d = UserVariable(name="d", value="np.sin(c)") -# e = UserVariable(name="e", value="np.cos(c)") -# -# # Then he inputs the actual expression somewhere within -# # simulation.json using the helper variables defined before -# -# model = ScalarModel(scalar="np.arctan(d + e + 1)") -# -# assert str(model.scalar) == "np.arctan(d + e + 1)" -# -# result = model.scalar.evaluate() -# -# assert result == -0.1861456975646416 -# -# -# def test_numpy_interop_vectors(): -# Vec3 = tuple[float, float, float] -# -# class VectorModel(Flow360BaseModel): -# vector: ValueOrExpression[Vec3] = pd.Field() -# -# x = UserVariable(name="x", value=np.array([2, 3, 4])) -# y = UserVariable(name="y", value=2 * x) -# -# model = VectorModel(vector=x**2 + y + np.array([1, 0, 0])) -# -# assert str(model.vector) == "x ** 2 + y + np.array([1,0,0])" -# -# result = model.vector.evaluate() -# -# assert np.array_equal(result, np.array([9, 15, 24])) - - def test_subscript_access(): class ScalarModel(Flow360BaseModel): scalar: ValueOrExpression[float] = pd.Field() @@ -641,11 +550,11 @@ def test_solver_translation(): # 3. User variables are inlined (for expression value types) expression = Expression.model_validate(y * u.m**2) - assert expression.to_solver_code(params) == "((4.0 + 1) * pow(0.5, 2))" + assert expression.to_solver_code(params) == "(5.0 * pow(0.5, 2))" # 4. For solver variables, the units are stripped (assumed to be in solver units so factor == 1.0) expression = Expression.model_validate(y * u.m / u.s + control.MachRef) - assert expression.to_solver_code(params) == "((((4.0 + 1) * 0.5) / 500.0) + machRef)" + assert expression.to_solver_code(params) == "(((5.0 * 0.5) / 500.0) + machRef)" def test_cyclic_dependencies(): @@ -704,3 +613,22 @@ def test_variable_space_init(): evaluated = params.reference_geometry.area.evaluate() assert evaluated == 1.0 * u.m**2 + + +def test_cross_product(): + class TestModel(Flow360BaseModel): + field: ValueOrExpression[VelocityType] = pd.Field() + + x = UserVariable(name="x", value=[1, 2, 3]) + + model = TestModel(field=math.cross(x, [3, 2, 1]) * u.m / u.s) + assert str(model.field) == "(([((x)[1]) * 1 - (((x)[2]) * 2),((x)[2]) * 3 - (((x)[0]) * 1),((x)[0]) * 2 - (((x)[1]) * 3)]) * u.m) / u.s" + + result = model.field.evaluate() + assert (result == [-4, 8, -4] * u.m / u.s).all() + + model = TestModel(field="math.cross(x, [3, 2, 1]) * u.m / u.s") + assert str(model.field) == "math.cross(x, [3, 2, 1]) * u.m / u.s" + + result = model.field.evaluate() + assert (result == [-4, 8, -4] * u.m / u.s).all() From 135f14c122545ad575909378c1824a683ddd355c Mon Sep 17 00:00:00 2001 From: Andrzej Krupka Date: Tue, 3 Jun 2025 01:52:27 +0200 Subject: [PATCH 07/19] More fixes, simplify deserializer logic --- .../simulation/blueprint/core/context.py | 7 +++ .../simulation/blueprint/core/expressions.py | 1 + flow360/component/simulation/unit_system.py | 4 +- .../simulation/user_code/core/types.py | 62 ++++++++----------- .../simulation/user_code/functions/math.py | 35 +++++++++-- .../data/{variables.json => simulation.json} | 1 + tests/simulation/test_expressions.py | 36 ++++++++++- 7 files changed, 100 insertions(+), 46 deletions(-) rename tests/simulation/data/{variables.json => simulation.json} (99%) diff --git a/flow360/component/simulation/blueprint/core/context.py b/flow360/component/simulation/blueprint/core/context.py index 821ccf6d7..a8f909de9 100644 --- a/flow360/component/simulation/blueprint/core/context.py +++ b/flow360/component/simulation/blueprint/core/context.py @@ -41,6 +41,7 @@ def __init__( self._values = initial_values or {} self._data_models = {} self._resolver = resolver + self._aliases: dict[str, str] = {} def get(self, name: str, resolve: bool = True) -> Any: """ @@ -77,6 +78,12 @@ def get_data_model(self, name: str) -> Optional[pd.BaseModel]: return None return self._data_models[name] + def set_alias(self, name, alias) -> None: + self._aliases[name] = alias + + def get_alias(self, name) -> Optional[str]: + return self._aliases.get(name) + def set(self, name: str, value: Any, data_model: pd.BaseModel = None) -> None: """ Assign a value to a name in the context. diff --git a/flow360/component/simulation/blueprint/core/expressions.py b/flow360/component/simulation/blueprint/core/expressions.py index 0288d5399..79a7d3297 100644 --- a/flow360/component/simulation/blueprint/core/expressions.py +++ b/flow360/component/simulation/blueprint/core/expressions.py @@ -3,6 +3,7 @@ import abc from typing import Annotated, Any, Literal, Union +import numpy as np import pydantic as pd from ..utils.operators import BINARY_OPERATORS, UNARY_OPERATORS diff --git a/flow360/component/simulation/unit_system.py b/flow360/component/simulation/unit_system.py index f7c223504..2025bc800 100644 --- a/flow360/component/simulation/unit_system.py +++ b/flow360/component/simulation/unit_system.py @@ -424,7 +424,7 @@ def __get_pydantic_core_schema__(con_cls, *args, **kwargs) -> pd.CoreSchema: # pylint: disable=invalid-name # pylint: disable=too-many-arguments @classmethod - def Constrained(cls, gt=None, ge=None, lt=None, le=None, allow_inf_nan=False): + def Constrained(cls, gt=None, ge=None, lt=None, le=None, allow_inf_nan=True): """ Utility method to generate a dimensioned type with constraints based on the pydantic confloat """ @@ -524,7 +524,7 @@ def validate(vec_cls, value, *args, **kwargs): value, vec_cls.type.dim, vec_cls.type.expect_delta_unit ) - if kwargs.get("allow_inf_nan", False) is False: + if kwargs.get("allow_inf_nan", True) is False: value = _nan_inf_vector_validator(value) value = _has_dimensions_validator( diff --git a/flow360/component/simulation/user_code/core/types.py b/flow360/component/simulation/user_code/core/types.py index 600c1aa9f..13cab1af1 100644 --- a/flow360/component/simulation/user_code/core/types.py +++ b/flow360/component/simulation/user_code/core/types.py @@ -25,8 +25,6 @@ ) _user_variables: set[str] = set() -_solver_variable_name_map: dict[str, str] = {} - def __soft_fail_add__(self, other): if not isinstance(other, Expression) and not isinstance(other, Variable): @@ -109,10 +107,10 @@ class SerializedValueOrExpression(Flow360BaseModel): """Serialized frontend-compatible format of an arbitrary value/expression field""" type_name: Union[Literal["number"], Literal["expression"]] = pd.Field(None) - value: Optional[Union[Number, Iterable[Number]]] = pd.Field(None) + value: Optional[Union[Number, list[Number]]] = pd.Field(None) units: Optional[str] = pd.Field(None) expression: Optional[str] = pd.Field(None) - evaluated_value: Optional[Union[Number, Iterable[Number]]] = pd.Field(None) + evaluated_value: Optional[Union[Number, list[Number]]] = pd.Field(None) evaluated_units: Optional[str] = pd.Field(None) @@ -292,9 +290,8 @@ class SolverVariable(Variable): def update_context(cls, value): """Auto updating context when new variable is declared""" default_context.set(value.name, value.value, SolverVariable) - _solver_variable_name_map[value.name] = ( - value.solver_name if value.solver_name is not None else value.name - ) + if value.solver_name: + default_context.set_alias(value.name, value.solver_name) return value @@ -374,8 +371,10 @@ def to_solver_code(self, params): """Convert to solver readable code.""" def translate_symbol(name): - if name in _solver_variable_name_map: - return _solver_variable_name_map[name] + alias = default_context.get_alias(name) + + if alias: + return alias match = re.fullmatch("u\\.(.+)", name) @@ -514,37 +513,26 @@ def _internal_validator(value: Expression): expr_type = Annotated[Expression, pd.AfterValidator(_internal_validator)] def _deserialize(value) -> Self: - def _validation_attempt_(input_value): - deserialized = None - try: - deserialized = SerializedValueOrExpression.model_validate(input_value) - except: # pylint:disable=bare-except - pass - return deserialized - - ### - deserialized = None - if isinstance(value, dict) and "type_name" not in value: - # Deserializing legacy simulation.json where there is only "units" + "value" - deserialized = _validation_attempt_({**value, "type_name": "number"}) - else: - deserialized = _validation_attempt_(value) - if deserialized is None: - # All validation attempt failed - deserialized = value - else: - if deserialized.type_name == "number": - if deserialized.units is not None: - # Note: Flow360 unyt_array could not be constructed here. - return unyt_array(deserialized.value, deserialized.units) - return deserialized.value - if deserialized.type_name == "expression": - return expr_type(expression=deserialized.expression) + is_serialized = False - return deserialized + try: + value = SerializedValueOrExpression.model_validate(value) + is_serialized = True + except Exception as err: + pass + + if is_serialized: + if value.type_name == "number": + if value.units is not None: + return unyt_array(value.value, value.units) + else: + return value.value + elif value.type_name == "expression": + return expr_type(expression=value.expression) + else: + return value def _serializer(value, info) -> dict: - print(">>> Inside serializer: value = ", value) if isinstance(value, Expression): serialized = SerializedValueOrExpression(type_name="expression") diff --git a/flow360/component/simulation/user_code/functions/math.py b/flow360/component/simulation/user_code/functions/math.py index 9cbd28219..3f2b2dcaf 100644 --- a/flow360/component/simulation/user_code/functions/math.py +++ b/flow360/component/simulation/user_code/functions/math.py @@ -1,4 +1,5 @@ -from enum import Enum +import numpy as np +from unyt import unyt_array from flow360.component.simulation.user_code.core.types import Expression, Variable @@ -13,6 +14,19 @@ def _convert_argument(value): return value +def _extract_units(value): + units = 1 # Neutral element of multiplication + + if isinstance(value, Expression): + result = value.evaluate(raise_error=False) + if isinstance(result, unyt_array): + units = result.units + elif isinstance(value, unyt_array): + units = value.units + + return units + + def cross(left, right): """Customized Cross function to work with the `Expression` and Variables""" @@ -32,6 +46,19 @@ def cross(left, right): is_expression_type = True if is_expression_type: - return Expression.model_validate(result) - else: - return result + result = Expression.model_validate(result) + + unit = 1 + + left_units = _extract_units(left) + right_units = _extract_units(right) + + if left_units != 1: + unit = left_units + if right_units != 1: + unit = unit * right_units if unit != 1 else right_units + + if unit != 1: + result *= unit + + return result diff --git a/tests/simulation/data/variables.json b/tests/simulation/data/simulation.json similarity index 99% rename from tests/simulation/data/variables.json rename to tests/simulation/data/simulation.json index 895c37d39..f38572dbd 100644 --- a/tests/simulation/data/variables.json +++ b/tests/simulation/data/simulation.json @@ -34,6 +34,7 @@ }, "reference_geometry": { "moment_center": { + "type_name": "number", "value": [ 0, 0, diff --git a/tests/simulation/test_expressions.py b/tests/simulation/test_expressions.py index e64254b13..7530cc306 100644 --- a/tests/simulation/test_expressions.py +++ b/tests/simulation/test_expressions.py @@ -18,6 +18,7 @@ from flow360.component.simulation.models.material import Water, aluminum from flow360.component.simulation.outputs.outputs import SurfaceOutput from flow360.component.simulation.primitives import GenericVolume, Surface +from flow360.component.simulation.services import ValidationCalledBy, validate_model from flow360.component.simulation.unit_system import ( AbsoluteTemperatureType, AngleType, @@ -601,9 +602,8 @@ class TestModel(Flow360BaseModel): def test_variable_space_init(): # Simulating loading a SimulationParams object from file - ensure that the variable space is loaded correctly - with open("data/variables.json", "r+") as fh: + with open("data/simulation.json", "r+") as fh: data = json.load(fh) - from flow360.component.simulation.services import ValidationCalledBy, validate_model params, errors, _ = validate_model( params_as_dict=data, validated_by=ValidationCalledBy.LOCAL, root_item_type="Geometry" @@ -617,7 +617,7 @@ def test_variable_space_init(): def test_cross_product(): class TestModel(Flow360BaseModel): - field: ValueOrExpression[VelocityType] = pd.Field() + field: ValueOrExpression[VelocityType.Vector] = pd.Field() x = UserVariable(name="x", value=[1, 2, 3]) @@ -632,3 +632,33 @@ class TestModel(Flow360BaseModel): result = model.field.evaluate() assert (result == [-4, 8, -4] * u.m / u.s).all() + + +def test_vector_solver_variable_cross_product_translation(): + with open("data/simulation.json", "r+") as fh: + data = json.load(fh) + + params, errors, _ = validate_model( + params_as_dict=data, validated_by=ValidationCalledBy.LOCAL, root_item_type="Geometry" + ) + + class TestModel(Flow360BaseModel): + field: ValueOrExpression[LengthType.Vector] = pd.Field() + + # From string + expr_1 = TestModel(field="math.cross([1, 2, 3], solution.coordinate)").field + assert str(expr_1) == "math.cross([1, 2, 3], solution.coordinate)" + + # During solver translation both options are inlined the same way through partial evaluation + solver_1 = expr_1.to_solver_code(params) + print(solver_1) + + # From python code + expr_2 = TestModel(field=math.cross([1, 2, 3], solution.coordinate)).field + assert str(expr_2) == "([2 * ((solution.coordinate)[2]) - (3 * ((solution.coordinate)[1])),3 * ((solution.coordinate)[0]) - (1 * ((solution.coordinate)[2])),1 * ((solution.coordinate)[1]) - (2 * ((solution.coordinate)[0]))]) * u.m" + + # During solver translation both options are inlined the same way through partial evaluation + solver_2 = expr_2.to_solver_code(params) + print(solver_2) # <- TODO: This currently will break, because the overloaded unyt operators in types.py don't + # handle List[Expression]... We should do some sort of implicit conversion perhaps? + From 97b68b75d6bb9da12ada3451770e02d77f7bd849 Mon Sep 17 00:00:00 2001 From: benflexcompute Date: Tue, 3 Jun 2025 00:53:59 +0000 Subject: [PATCH 08/19] Format --- .../simulation/user_code/core/types.py | 1 + .../simulation/user_code/functions/math.py | 2 +- tests/simulation/test_expressions.py | 17 ++++++++++++----- 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/flow360/component/simulation/user_code/core/types.py b/flow360/component/simulation/user_code/core/types.py index 13cab1af1..49413111c 100644 --- a/flow360/component/simulation/user_code/core/types.py +++ b/flow360/component/simulation/user_code/core/types.py @@ -26,6 +26,7 @@ _user_variables: set[str] = set() + def __soft_fail_add__(self, other): if not isinstance(other, Expression) and not isinstance(other, Variable): return np.ndarray.__add__(self, other) diff --git a/flow360/component/simulation/user_code/functions/math.py b/flow360/component/simulation/user_code/functions/math.py index 3f2b2dcaf..44bc372b0 100644 --- a/flow360/component/simulation/user_code/functions/math.py +++ b/flow360/component/simulation/user_code/functions/math.py @@ -15,7 +15,7 @@ def _convert_argument(value): def _extract_units(value): - units = 1 # Neutral element of multiplication + units = 1 # Neutral element of multiplication if isinstance(value, Expression): result = value.evaluate(raise_error=False) diff --git a/tests/simulation/test_expressions.py b/tests/simulation/test_expressions.py index 7530cc306..ea440d952 100644 --- a/tests/simulation/test_expressions.py +++ b/tests/simulation/test_expressions.py @@ -622,7 +622,10 @@ class TestModel(Flow360BaseModel): x = UserVariable(name="x", value=[1, 2, 3]) model = TestModel(field=math.cross(x, [3, 2, 1]) * u.m / u.s) - assert str(model.field) == "(([((x)[1]) * 1 - (((x)[2]) * 2),((x)[2]) * 3 - (((x)[0]) * 1),((x)[0]) * 2 - (((x)[1]) * 3)]) * u.m) / u.s" + assert ( + str(model.field) + == "(([((x)[1]) * 1 - (((x)[2]) * 2),((x)[2]) * 3 - (((x)[0]) * 1),((x)[0]) * 2 - (((x)[1]) * 3)]) * u.m) / u.s" + ) result = model.field.evaluate() assert (result == [-4, 8, -4] * u.m / u.s).all() @@ -655,10 +658,14 @@ class TestModel(Flow360BaseModel): # From python code expr_2 = TestModel(field=math.cross([1, 2, 3], solution.coordinate)).field - assert str(expr_2) == "([2 * ((solution.coordinate)[2]) - (3 * ((solution.coordinate)[1])),3 * ((solution.coordinate)[0]) - (1 * ((solution.coordinate)[2])),1 * ((solution.coordinate)[1]) - (2 * ((solution.coordinate)[0]))]) * u.m" + assert ( + str(expr_2) + == "([2 * ((solution.coordinate)[2]) - (3 * ((solution.coordinate)[1])),3 * ((solution.coordinate)[0]) - (1 * ((solution.coordinate)[2])),1 * ((solution.coordinate)[1]) - (2 * ((solution.coordinate)[0]))]) * u.m" + ) # During solver translation both options are inlined the same way through partial evaluation solver_2 = expr_2.to_solver_code(params) - print(solver_2) # <- TODO: This currently will break, because the overloaded unyt operators in types.py don't - # handle List[Expression]... We should do some sort of implicit conversion perhaps? - + print( + solver_2 + ) # <- TODO: This currently will break, because the overloaded unyt operators in types.py don't + # handle List[Expression]... We should do some sort of implicit conversion perhaps? From 073f7e9cb74590a0ae7d28b58fa0236725a29609 Mon Sep 17 00:00:00 2001 From: benflexcompute Date: Tue, 3 Jun 2025 01:42:34 +0000 Subject: [PATCH 09/19] Fixed unit test as many as possible, only 1 left --- flow360/component/simulation/framework/updater.py | 8 ++++++++ flow360/component/simulation/services.py | 11 ++++++++--- flow360/component/simulation/unit_system.py | 4 ++-- tests/simulation/service/test_services_v2.py | 2 +- 4 files changed, 19 insertions(+), 6 deletions(-) diff --git a/flow360/component/simulation/framework/updater.py b/flow360/component/simulation/framework/updater.py index 5f2ec66a3..ba07ae3a5 100644 --- a/flow360/component/simulation/framework/updater.py +++ b/flow360/component/simulation/framework/updater.py @@ -189,6 +189,14 @@ def _to_25_6_0(params_as_dict): if velocity_direction: model["velocity_direction"] = velocity_direction + # What version is this? + if "reference_geometry" in params_as_dict and "area" in params_as_dict["reference_geometry"]: + if ( + params_as_dict["reference_geometry"]["area"] is not None + and "type_name" not in params_as_dict["reference_geometry"]["area"] + ): + params_as_dict["reference_geometry"]["area"]["type_name"] = "number" + return params_as_dict diff --git a/flow360/component/simulation/services.py b/flow360/component/simulation/services.py index 06758af5f..7ce9e1615 100644 --- a/flow360/component/simulation/services.py +++ b/flow360/component/simulation/services.py @@ -19,11 +19,19 @@ from flow360.component.simulation.meshing_param.volume_params import AutomatedFarfield from flow360.component.simulation.models.surface_models import Freestream, Wall +# Following unused-import for supporting parse_model_dict +from flow360.component.simulation.models.volume_models import ( # pylint: disable=unused-import + BETDisk, +) + # pylint: disable=unused-import from flow360.component.simulation.operating_condition.operating_condition import ( AerospaceCondition, + GenericReferenceCondition, + ThermalState, ) from flow360.component.simulation.outputs.outputs import SurfaceOutput +from flow360.component.simulation.primitives import Box # pylint: disable=unused-import from flow360.component.simulation.primitives import Surface # For parse_model_dict from flow360.component.simulation.simulation_params import ( ReferenceGeometry, @@ -59,9 +67,6 @@ from flow360.plugins.report.report import get_default_report_summary_template from flow360.version import __version__ -# Following unused-import for supporting parse_model_dict - - # Required for correct global scope initialization diff --git a/flow360/component/simulation/unit_system.py b/flow360/component/simulation/unit_system.py index 2025bc800..f7c223504 100644 --- a/flow360/component/simulation/unit_system.py +++ b/flow360/component/simulation/unit_system.py @@ -424,7 +424,7 @@ def __get_pydantic_core_schema__(con_cls, *args, **kwargs) -> pd.CoreSchema: # pylint: disable=invalid-name # pylint: disable=too-many-arguments @classmethod - def Constrained(cls, gt=None, ge=None, lt=None, le=None, allow_inf_nan=True): + def Constrained(cls, gt=None, ge=None, lt=None, le=None, allow_inf_nan=False): """ Utility method to generate a dimensioned type with constraints based on the pydantic confloat """ @@ -524,7 +524,7 @@ def validate(vec_cls, value, *args, **kwargs): value, vec_cls.type.dim, vec_cls.type.expect_delta_unit ) - if kwargs.get("allow_inf_nan", True) is False: + if kwargs.get("allow_inf_nan", False) is False: value = _nan_inf_vector_validator(value) value = _has_dimensions_validator( diff --git a/tests/simulation/service/test_services_v2.py b/tests/simulation/service/test_services_v2.py index b4bf8921b..b9471585b 100644 --- a/tests/simulation/service/test_services_v2.py +++ b/tests/simulation/service/test_services_v2.py @@ -146,7 +146,7 @@ def test_validate_error(): "reference_geometry": { "moment_center": {"value": [0, 0, 0], "units": "m"}, "moment_length": {"value": 1.0, "units": "m"}, - "area": {"value": 1.0, "units": "m**2"}, + "area": {"value": 1.0, "units": "m**2", "type_name": "number"}, }, "time_stepping": { "type_name": "Steady", From e63d85ca194381de53e42f70916883c292dfe0d7 Mon Sep 17 00:00:00 2001 From: benflexcompute Date: Tue, 3 Jun 2025 02:02:01 +0000 Subject: [PATCH 10/19] Fixing most of the pylint issues --- flow360/__init__.py | 4 +++- .../simulation/blueprint/core/context.py | 3 +++ .../simulation/blueprint/core/expressions.py | 4 +--- .../simulation/blueprint/core/generator.py | 2 +- .../simulation/user_code/core/context.py | 9 ++++++- .../simulation/user_code/core/types.py | 24 +++++++------------ .../simulation/user_code/core/utils.py | 5 ++++ .../simulation/user_code/functions/math.py | 3 ++- .../user_code/variables/solution.py | 1 + 9 files changed, 33 insertions(+), 22 deletions(-) diff --git a/flow360/__init__.py b/flow360/__init__.py index 9e1dd4276..68d4d4b81 100644 --- a/flow360/__init__.py +++ b/flow360/__init__.py @@ -277,5 +277,7 @@ "Transformation", "WallRotation", "UserVariable", - "cross", + "math", + "control", + "solution", ] diff --git a/flow360/component/simulation/blueprint/core/context.py b/flow360/component/simulation/blueprint/core/context.py index a8f909de9..655e28e9f 100644 --- a/flow360/component/simulation/blueprint/core/context.py +++ b/flow360/component/simulation/blueprint/core/context.py @@ -74,14 +74,17 @@ def get(self, name: str, resolve: bool = True) -> Any: return self._values[name] def get_data_model(self, name: str) -> Optional[pd.BaseModel]: + """Get the Validation model for the given name.""" if name not in self._data_models: return None return self._data_models[name] def set_alias(self, name, alias) -> None: + """Set alias used for code generation.""" self._aliases[name] = alias def get_alias(self, name) -> Optional[str]: + """Get alias used for code generation.""" return self._aliases.get(name) def set(self, name: str, value: Any, data_model: pd.BaseModel = None) -> None: diff --git a/flow360/component/simulation/blueprint/core/expressions.py b/flow360/component/simulation/blueprint/core/expressions.py index 79a7d3297..3b9092ad1 100644 --- a/flow360/component/simulation/blueprint/core/expressions.py +++ b/flow360/component/simulation/blueprint/core/expressions.py @@ -3,7 +3,6 @@ import abc from typing import Annotated, Any, Literal, Union -import numpy as np import pydantic as pd from ..utils.operators import BINARY_OPERATORS, UNARY_OPERATORS @@ -65,8 +64,7 @@ def evaluate( data_model = context.get_data_model(self.id) if data_model: return data_model.model_validate({"name": self.id, "value": context.get(self.id)}) - else: - raise ValueError(f"Partially evaluable symbols need to possess a type annotation") + raise ValueError("Partially evaluable symbols need to possess a type annotation.") value = context.get(self.id) # Recursively evaluate if the returned value is evaluable if isinstance(value, Evaluable): diff --git a/flow360/component/simulation/blueprint/core/generator.py b/flow360/component/simulation/blueprint/core/generator.py index 467ca739e..314f958c1 100644 --- a/flow360/component/simulation/blueprint/core/generator.py +++ b/flow360/component/simulation/blueprint/core/generator.py @@ -178,7 +178,7 @@ def _list_comp(expr, syntax, name_translator): raise ValueError("List comprehensions are only supported for Python target syntax") -def _subscript(expr, syntax, name_translator): +def _subscript(expr, syntax, name_translator): # pylint:disable=unused-argument return f"{expr.value.id}[{expr.slice.value}]" diff --git a/flow360/component/simulation/user_code/core/context.py b/flow360/component/simulation/user_code/core/context.py index 043bad009..d12161770 100644 --- a/flow360/component/simulation/user_code/core/context.py +++ b/flow360/component/simulation/user_code/core/context.py @@ -1,12 +1,15 @@ +"""Context handler module""" + from typing import Any +from unyt import Unit, unit_symbols, unyt_array + from flow360.component.simulation.blueprint.core import EvaluationContext from flow360.component.simulation.blueprint.core.resolver import CallableResolver def _unit_list(): """Import a list of available unit symbols from the unyt module""" - from unyt import Unit, unit_symbols, unyt_array symbols = set() @@ -19,6 +22,7 @@ def _unit_list(): def _import_units(_) -> Any: """Import and return allowed unit callables""" + # pylint:disable=import-outside-toplevel from flow360.component.simulation import units as u return u @@ -26,6 +30,7 @@ def _import_units(_) -> Any: def _import_math(_) -> Any: """Import and return allowed function callables""" + # pylint:disable=import-outside-toplevel from flow360.component.simulation.user_code.functions import math return math @@ -33,6 +38,7 @@ def _import_math(_) -> Any: def _import_control(_) -> Any: """Import and return allowed control variable callables""" + # pylint:disable=import-outside-toplevel from flow360.component.simulation.user_code.variables import control return control @@ -40,6 +46,7 @@ def _import_control(_) -> Any: def _import_solution(_) -> Any: """Import and return allowed solution variable callables""" + # pylint:disable=import-outside-toplevel from flow360.component.simulation.user_code.variables import solution return solution diff --git a/flow360/component/simulation/user_code/core/types.py b/flow360/component/simulation/user_code/core/types.py index 49413111c..e8b5fa9ed 100644 --- a/flow360/component/simulation/user_code/core/types.py +++ b/flow360/component/simulation/user_code/core/types.py @@ -4,7 +4,7 @@ import re from numbers import Number -from typing import Annotated, Any, Generic, Iterable, Literal, Optional, TypeVar, Union +from typing import Annotated, Any, Generic, Literal, Optional, TypeVar, Union import numpy as np import pydantic as pd @@ -30,29 +30,25 @@ def __soft_fail_add__(self, other): if not isinstance(other, Expression) and not isinstance(other, Variable): return np.ndarray.__add__(self, other) - else: - return NotImplemented + return NotImplemented def __soft_fail_sub__(self, other): if not isinstance(other, Expression) and not isinstance(other, Variable): return np.ndarray.__sub__(self, other) - else: - return NotImplemented + return NotImplemented def __soft_fail_mul__(self, other): if not isinstance(other, Expression) and not isinstance(other, Variable): return np.ndarray.__mul__(self, other) - else: - return NotImplemented + return NotImplemented def __soft_fail_truediv__(self, other): if not isinstance(other, Expression) and not isinstance(other, Variable): return np.ndarray.__truediv__(self, other) - else: - return NotImplemented + return NotImplemented unyt_array.__add__ = __soft_fail_add__ @@ -519,19 +515,17 @@ def _deserialize(value) -> Self: try: value = SerializedValueOrExpression.model_validate(value) is_serialized = True - except Exception as err: + except Exception: # pylint:disable=broad-exception-caught pass if is_serialized: if value.type_name == "number": if value.units is not None: return unyt_array(value.value, value.units) - else: - return value.value - elif value.type_name == "expression": + return value.value + if value.type_name == "expression": return expr_type(expression=value.expression) - else: - return value + return value def _serializer(value, info) -> dict: if isinstance(value, Expression): diff --git a/flow360/component/simulation/user_code/core/utils.py b/flow360/component/simulation/user_code/core/utils.py index 1b9f05187..a287afae6 100644 --- a/flow360/component/simulation/user_code/core/utils.py +++ b/flow360/component/simulation/user_code/core/utils.py @@ -1,3 +1,5 @@ +"""Utility functions for the user code module""" + import re import pydantic as pd @@ -5,6 +7,7 @@ def is_number_string(s: str) -> bool: + """Check if the string represents a single scalar number""" try: float(s) return True @@ -13,6 +16,7 @@ def is_number_string(s: str) -> bool: def split_keep_delimiters(value: str, delimiters: list) -> list: + """split string but keep the delimiters""" escaped_delimiters = [re.escape(d) for d in delimiters] pattern = f"({'|'.join(escaped_delimiters)})" result = re.split(pattern, value) @@ -20,6 +24,7 @@ def split_keep_delimiters(value: str, delimiters: list) -> list: def handle_syntax_error(se: SyntaxError, source: str): + """Handle expression syntax error.""" caret = " " * (se.offset - 1) + "^" if se.text and se.offset else None msg = f"{se.msg} at line {se.lineno}, column {se.offset}" if caret: diff --git a/flow360/component/simulation/user_code/functions/math.py b/flow360/component/simulation/user_code/functions/math.py index 44bc372b0..8b1a0b1af 100644 --- a/flow360/component/simulation/user_code/functions/math.py +++ b/flow360/component/simulation/user_code/functions/math.py @@ -1,4 +1,5 @@ -import numpy as np +"""""" + from unyt import unyt_array from flow360.component.simulation.user_code.core.types import Expression, Variable diff --git a/flow360/component/simulation/user_code/variables/solution.py b/flow360/component/simulation/user_code/variables/solution.py index 4b5f6b90a..289dc7649 100644 --- a/flow360/component/simulation/user_code/variables/solution.py +++ b/flow360/component/simulation/user_code/variables/solution.py @@ -4,6 +4,7 @@ from flow360.component.simulation.user_code.core.types import SolverVariable +# pylint:disable = no-member mut = SolverVariable(name="solution.mut", value=float("NaN")) # Turbulent viscosity mu = SolverVariable(name="solution.mu", value=float("NaN")) # Laminar viscosity solutionNavierStokes = SolverVariable( From a50bb0b14870fb85f716a63b78101374559838f7 Mon Sep 17 00:00:00 2001 From: Andrzej Krupka Date: Tue, 3 Jun 2025 15:26:15 +0200 Subject: [PATCH 11/19] Fixed allow_inf_nan when evaluating expressions with solver variables --- flow360/component/simulation/unit_system.py | 15 ++++++++++----- .../component/simulation/user_code/core/types.py | 2 +- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/flow360/component/simulation/unit_system.py b/flow360/component/simulation/unit_system.py index f7c223504..d26165ead 100644 --- a/flow360/component/simulation/unit_system.py +++ b/flow360/component/simulation/unit_system.py @@ -491,7 +491,7 @@ def __get_pydantic_json_schema__( return schema - def validate(vec_cls, value, *args, **kwargs): + def validate(vec_cls, value, info, *args, **kwargs): """additional validator for value""" try: value = _unit_object_parser(value, [u.unyt_array, _Flow360BaseUnit.factory]) @@ -524,7 +524,12 @@ def validate(vec_cls, value, *args, **kwargs): value, vec_cls.type.dim, vec_cls.type.expect_delta_unit ) - if kwargs.get("allow_inf_nan", False) is False: + allow_inf_nan = kwargs.get("allow_inf_nan", False) + + if info.context and "allow_inf_nan" in info.context: + allow_inf_nan = info.context.get("allow_inf_nan", False) + + if allow_inf_nan is False: value = _nan_inf_vector_validator(value) value = _has_dimensions_validator( @@ -539,9 +544,9 @@ def validate(vec_cls, value, *args, **kwargs): raise pd.ValidationError.from_exception_data("validation error", [details]) def __get_pydantic_core_schema__(vec_cls, *args, **kwargs) -> pd.CoreSchema: - return core_schema.no_info_plain_validator_function( - lambda *val_args: validate(vec_cls, *val_args) - ) + def validate_with_info(value, info): + return validate(vec_cls, value, info, *args, **kwargs) + return core_schema.with_info_plain_validator_function(validate_with_info) cls_obj = type("_VectorType", (), {}) cls_obj.type = dim_type diff --git a/flow360/component/simulation/user_code/core/types.py b/flow360/component/simulation/user_code/core/types.py index e8b5fa9ed..6bfd402b2 100644 --- a/flow360/component/simulation/user_code/core/types.py +++ b/flow360/component/simulation/user_code/core/types.py @@ -504,7 +504,7 @@ def _internal_validator(value: Expression): result = value.evaluate(raise_error=False) except Exception as err: raise ValueError(f"expression evaluation failed: {err}") from err - pd.TypeAdapter(typevar_values).validate_python(result) + pd.TypeAdapter(typevar_values).validate_python(result, context={"allow_inf_nan": True}) return value expr_type = Annotated[Expression, pd.AfterValidator(_internal_validator)] From eb4b2c834f7aaf5e1ee2a582e57b9859e0d65c9f Mon Sep 17 00:00:00 2001 From: benflexcompute Date: Wed, 4 Jun 2025 02:54:19 +0000 Subject: [PATCH 12/19] eagerly evaluation and also taking advantage of unyt pacakge --- flow360/component/simulation/unit_system.py | 20 +++ .../simulation/user_code/functions/math.py | 40 ++--- tests/simulation/test_expressions.py | 162 +++++++++++++++++- 3 files changed, 184 insertions(+), 38 deletions(-) diff --git a/flow360/component/simulation/unit_system.py b/flow360/component/simulation/unit_system.py index d26165ead..2a6fae9db 100644 --- a/flow360/component/simulation/unit_system.py +++ b/flow360/component/simulation/unit_system.py @@ -180,6 +180,24 @@ def _is_unit_validator(value): return value +def _list_of_unyt_quantity_to_unyt_array(value): + """ + Convert list of unyt_quantity (may come from `Expression`) to unyt_array + Only handles situation where all components share exact same unit. + We cab relax this to cover more expression results in the future when we decide how to convert. + """ + + if not isinstance(value, list): + return value + if not all(isinstance(item, unyt_quantity) for item in value): + return value + units = set([item.units for item in value]) + if not len(units) == 1: + return value + shared_unit = units.pop() + return [item.value for item in value] * shared_unit + + # pylint: disable=too-many-return-statements def _unit_inference_validator(value, dim_name, is_array=False, is_matrix=False): """ @@ -495,6 +513,7 @@ def validate(vec_cls, value, info, *args, **kwargs): """additional validator for value""" try: value = _unit_object_parser(value, [u.unyt_array, _Flow360BaseUnit.factory]) + value = _list_of_unyt_quantity_to_unyt_array(value) value = _is_unit_validator(value) is_collection = _check_if_input_is_nested_collection(value=value, nest_level=1) @@ -546,6 +565,7 @@ def validate(vec_cls, value, info, *args, **kwargs): def __get_pydantic_core_schema__(vec_cls, *args, **kwargs) -> pd.CoreSchema: def validate_with_info(value, info): return validate(vec_cls, value, info, *args, **kwargs) + return core_schema.with_info_plain_validator_function(validate_with_info) cls_obj = type("_VectorType", (), {}) diff --git a/flow360/component/simulation/user_code/functions/math.py b/flow360/component/simulation/user_code/functions/math.py index 8b1a0b1af..ca252beeb 100644 --- a/flow360/component/simulation/user_code/functions/math.py +++ b/flow360/component/simulation/user_code/functions/math.py @@ -1,6 +1,6 @@ """""" -from unyt import unyt_array +from unyt import ucross, unyt_array from flow360.component.simulation.user_code.core.types import Expression, Variable @@ -10,30 +10,25 @@ def _convert_argument(value): # If the argument is a Variable, convert it to an expression if isinstance(value, Variable): - return Expression.model_validate(value) - - return value - - -def _extract_units(value): - units = 1 # Neutral element of multiplication + return Expression.model_validate(value).evaluate(raise_error=False, force_evaluate=False) if isinstance(value, Expression): - result = value.evaluate(raise_error=False) - if isinstance(result, unyt_array): - units = result.units - elif isinstance(value, unyt_array): - units = value.units - - return units + # TODO: Test numerical value? + return value.evaluate(raise_error=False, force_evaluate=False) + return value def cross(left, right): """Customized Cross function to work with the `Expression` and Variables""" - + # print("Old left:", left, " | ", left.__class__.__name__) + # print("Old right:", right, " | ", right.__class__.__name__) left = _convert_argument(left) right = _convert_argument(right) + # Taking advantage of unyt as much as possible: + if isinstance(left, unyt_array) and isinstance(right, unyt_array): + return ucross(left, right) + result = [ left[1] * right[2] - left[2] * right[1], left[2] * right[0] - left[0] * right[2], @@ -49,17 +44,4 @@ def cross(left, right): if is_expression_type: result = Expression.model_validate(result) - unit = 1 - - left_units = _extract_units(left) - right_units = _extract_units(right) - - if left_units != 1: - unit = left_units - if right_units != 1: - unit = unit * right_units if unit != 1 else right_units - - if unit != 1: - result *= unit - return result diff --git a/tests/simulation/test_expressions.py b/tests/simulation/test_expressions.py index ea440d952..0096b8a4a 100644 --- a/tests/simulation/test_expressions.py +++ b/tests/simulation/test_expressions.py @@ -622,13 +622,9 @@ class TestModel(Flow360BaseModel): x = UserVariable(name="x", value=[1, 2, 3]) model = TestModel(field=math.cross(x, [3, 2, 1]) * u.m / u.s) - assert ( - str(model.field) - == "(([((x)[1]) * 1 - (((x)[2]) * 2),((x)[2]) * 3 - (((x)[0]) * 1),((x)[0]) * 2 - (((x)[1]) * 3)]) * u.m) / u.s" - ) + assert str(model.field) == "[-4 8 -4] m/s" - result = model.field.evaluate() - assert (result == [-4, 8, -4] * u.m / u.s).all() + assert (model.field == [-4, 8, -4] * u.m / u.s).all() model = TestModel(field="math.cross(x, [3, 2, 1]) * u.m / u.s") assert str(model.field) == "math.cross(x, [3, 2, 1]) * u.m / u.s" @@ -649,8 +645,9 @@ class TestModel(Flow360BaseModel): field: ValueOrExpression[LengthType.Vector] = pd.Field() # From string - expr_1 = TestModel(field="math.cross([1, 2, 3], solution.coordinate)").field - assert str(expr_1) == "math.cross([1, 2, 3], solution.coordinate)" + # TODO: LengthType.Vector should be accepting client-time evaluable (constant result) exprs + expr_1 = TestModel(field="math.cross([1, 2, 3], [1, 2, 3]*u.m)").field + assert str(expr_1) == "math.cross([1, 2, 3], [1, 2, 3]*u.m)" # During solver translation both options are inlined the same way through partial evaluation solver_1 = expr_1.to_solver_code(params) @@ -660,7 +657,7 @@ class TestModel(Flow360BaseModel): expr_2 = TestModel(field=math.cross([1, 2, 3], solution.coordinate)).field assert ( str(expr_2) - == "([2 * ((solution.coordinate)[2]) - (3 * ((solution.coordinate)[1])),3 * ((solution.coordinate)[0]) - (1 * ((solution.coordinate)[2])),1 * ((solution.coordinate)[1]) - (2 * ((solution.coordinate)[0]))]) * u.m" + == "[2 * (solution.coordinate[2]) - (3 * (solution.coordinate[1])),3 * (solution.coordinate[0]) - (1 * (solution.coordinate[2])),1 * (solution.coordinate[1]) - (2 * (solution.coordinate[0]))]" ) # During solver translation both options are inlined the same way through partial evaluation @@ -669,3 +666,150 @@ class TestModel(Flow360BaseModel): solver_2 ) # <- TODO: This currently will break, because the overloaded unyt operators in types.py don't # handle List[Expression]... We should do some sort of implicit conversion perhaps? + + +def test_cross_function_use_case(): + + def printer(expr: Expression): + res = expr.evaluate(raise_error=False, force_evaluate=False) + print(">> Evaluation Result type: ", res.__class__.__name__) + if isinstance(res, list): + print(">> Component type: ", [item.__class__.__name__ for item in res]) + for idx, item in enumerate(res): + print(f"[{idx}] ", item) + + print("\n1 Python mode\n") + a = UserVariable(name="aaa", value=math.cross([3, 2, 1] * u.m, solution.coordinate)) + res = a.value.evaluate(raise_error=False, force_evaluate=False) + assert ( + res[0].expression + == "2 * u.m * (solution.coordinate[2]) - (1 * u.m * (solution.coordinate[1]))" + ) + assert ( + res[1].expression + == "1 * u.m * (solution.coordinate[0]) - (3 * u.m * (solution.coordinate[2]))" + ) + assert ( + res[2].expression + == "3 * u.m * (solution.coordinate[1]) - (2 * u.m * (solution.coordinate[0]))" + ) + + print("\n1.1 Python mode but arg swapped\n") + a = UserVariable(name="aaa", value=math.cross(solution.coordinate, [3, 2, 1] * u.m)) + res = a.value.evaluate(raise_error=False, force_evaluate=False) + + assert ( + res[0].expression + == "((solution.coordinate[1]) * 1) * u.m - (((solution.coordinate[2]) * 2) * u.m)" + ) + assert ( + res[1].expression + == "((solution.coordinate[2]) * 3) * u.m - (((solution.coordinate[0]) * 1) * u.m)" + ) + assert ( + res[2].expression + == "((solution.coordinate[0]) * 2) * u.m - (((solution.coordinate[1]) * 3) * u.m)" + ) + + print("\n2 Taking advantage of unyt as much as possible\n") + a = UserVariable(name="aaa", value=math.cross([3, 2, 1] * u.m, [2, 2, 1] * u.m)) + assert all(a.value == [0, -1, 2] * u.m * u.m) + + print("\n3 (Unfortunate ill LengthType usage)\n") + a = UserVariable( + name="aaa", value=math.cross([3 * u.m, 2 * u.m, 1 * u.m], [2 * u.m, 2 * u.m, 1 * u.m]) + ) + assert a.value == [0 * u.m * u.m, -1 * u.m * u.m, 2 * u.m * u.m] + + print("\n4 Serialized version\n") + # TODO: Why string mode always evaluate to a single expression but not the Python counter part? + a = UserVariable(name="aaa", value="math.cross([3, 2, 1] * u.m, solution.coordinate)") + res = a.value.evaluate(raise_error=False, force_evaluate=False) + + assert ( + res.expression + == "[2 * u.m * (solution.coordinate[2]) - (1 * u.m * (solution.coordinate[1]))," + + "1 * u.m * (solution.coordinate[0]) - (3 * u.m * (solution.coordinate[2])),3 * u.m" + + " * (solution.coordinate[1]) - (2 * u.m * (solution.coordinate[0]))]" + ) + + print("\n5 Recursive cross in Python mode\n") + a = UserVariable( + name="aaa", + value=math.cross(math.cross([3, 2, 1] * u.m, solution.coordinate), [3, 2, 1] * u.m), + ) + res = a.value.evaluate(raise_error=False, force_evaluate=False) + assert ( + res[0].expression + == "((1 * u.m * (solution.coordinate[0]) - (3 * u.m * (solution.coordinate[2]))) * 1) * u.m - (((3 * u.m * (solution.coordinate[1]) - (2 * u.m * (solution.coordinate[0]))) * 2) * u.m)" + ) + assert ( + res[1].expression + == "((3 * u.m * (solution.coordinate[1]) - (2 * u.m * (solution.coordinate[0]))) * 3) * u.m - (((2 * u.m * (solution.coordinate[2]) - (1 * u.m * (solution.coordinate[1]))) * 1) * u.m)" + ) + assert ( + res[2].expression + == "((2 * u.m * (solution.coordinate[2]) - (1 * u.m * (solution.coordinate[1]))) * 2) * u.m - (((1 * u.m * (solution.coordinate[0]) - (3 * u.m * (solution.coordinate[2]))) * 3) * u.m)" + ) + + print("\n6 Recursive cross in String mode\n") + a = UserVariable( + name="aaa", + value="math.cross(math.cross([3, 2, 1] * u.m, solution.coordinate), [3, 2, 1] * u.m)", + ) + res = a.value.evaluate(raise_error=False, force_evaluate=False) + assert ( + res.expression + == "[(1 * u.m * (solution.coordinate[0]) - (3 * u.m * (solution.coordinate[2]))) * 1 * u.m - ((3 * u.m * (solution.coordinate[1]) - (2 * u.m * (solution.coordinate[0]))) * 2 * u.m),(3 * u.m * (solution.coordinate[1]) - (2 * u.m * (solution.coordinate[0]))) * 3 * u.m - ((2 * u.m * (solution.coordinate[2]) - (1 * u.m * (solution.coordinate[1]))) * 1 * u.m),(2 * u.m * (solution.coordinate[2]) - (1 * u.m * (solution.coordinate[1]))) * 2 * u.m - ((1 * u.m * (solution.coordinate[0]) - (3 * u.m * (solution.coordinate[2]))) * 3 * u.m)]" + ) + + print("\n7 Using other variabels in Python mode\n") + b = UserVariable(name="bbb", value=math.cross([3, 2, 1] * u.m, solution.coordinate)) + a = UserVariable(name="aaa", value=math.cross(b, [3, 2, 1] * u.m)) + res = a.value.evaluate(raise_error=False, force_evaluate=False) + assert ( + res[0].expression + == "((1 * u.m * (solution.coordinate[0]) - (3 * u.m * (solution.coordinate[2]))) * 1) * u.m - (((3 * u.m * (solution.coordinate[1]) - (2 * u.m * (solution.coordinate[0]))) * 2) * u.m)" + ) + assert ( + res[1].expression + == "((3 * u.m * (solution.coordinate[1]) - (2 * u.m * (solution.coordinate[0]))) * 3) * u.m - (((2 * u.m * (solution.coordinate[2]) - (1 * u.m * (solution.coordinate[1]))) * 1) * u.m)" + ) + assert ( + res[2].expression + == "((2 * u.m * (solution.coordinate[2]) - (1 * u.m * (solution.coordinate[1]))) * 2) * u.m - (((1 * u.m * (solution.coordinate[0]) - (3 * u.m * (solution.coordinate[2]))) * 3) * u.m)" + ) + + print("\n8 Using other constant variabels in Python mode\n") + b = UserVariable(name="bbb", value=[3, 2, 1] * u.m) + a = UserVariable(name="aaa", value=math.cross(b, solution.coordinate)) + res = a.value.evaluate(raise_error=False, force_evaluate=False) + assert ( + res[0].expression + == "2 * u.m * (solution.coordinate[2]) - (1 * u.m * (solution.coordinate[1]))" + ) + assert ( + res[1].expression + == "1 * u.m * (solution.coordinate[0]) - (3 * u.m * (solution.coordinate[2]))" + ) + assert ( + res[2].expression + == "3 * u.m * (solution.coordinate[1]) - (2 * u.m * (solution.coordinate[0]))" + ) + + print("\n9 Using non-unyt_array\n") + b = UserVariable(name="bbb", value=[3 * u.m, 2 * u.m, 1 * u.m]) + a = UserVariable(name="aaa", value=math.cross(b, solution.coordinate)) + res = a.value.evaluate(raise_error=False, force_evaluate=False) + assert ( + res[0].expression + == "2 * u.m * (solution.coordinate[2]) - (1 * u.m * (solution.coordinate[1]))" + ) + assert ( + res[1].expression + == "1 * u.m * (solution.coordinate[0]) - (3 * u.m * (solution.coordinate[2]))" + ) + assert ( + res[2].expression + == "3 * u.m * (solution.coordinate[1]) - (2 * u.m * (solution.coordinate[0]))" + ) From 9afab837bd0648850b983ee66fdee59f7d566a7c Mon Sep 17 00:00:00 2001 From: Andrzej Krupka Date: Wed, 4 Jun 2025 15:32:35 +0200 Subject: [PATCH 13/19] Small fixes --- .../simulation/user_code/core/types.py | 17 +- .../simulation/user_code/functions/math.py | 40 ++-- tests/simulation/test_expressions.py | 173 ++++++------------ 3 files changed, 96 insertions(+), 134 deletions(-) diff --git a/flow360/component/simulation/user_code/core/types.py b/flow360/component/simulation/user_code/core/types.py index 6bfd402b2..3c4b79502 100644 --- a/flow360/component/simulation/user_code/core/types.py +++ b/flow360/component/simulation/user_code/core/types.py @@ -340,12 +340,27 @@ def evaluate( context: EvaluationContext = None, raise_error: bool = True, force_evaluate: bool = True, - ) -> Union[float, list, unyt_array]: + ) -> Union[float, list[float], unyt_array, Expression]: """Evaluate this expression against the given context.""" if context is None: context = default_context expr = expr_to_model(self.expression, context) result = expr.evaluate(context, raise_error, force_evaluate) + + # Sometimes we may yield a list of expressions instead of + # an expression containing a list, so we check this here + # and convert if necessary + + if isinstance(result, list): + is_expression_list = False + + for item in result: + if isinstance(item, Expression): + is_expression_list = True + + if is_expression_list: + result = Expression.model_validate(result) + return result def user_variables(self): diff --git a/flow360/component/simulation/user_code/functions/math.py b/flow360/component/simulation/user_code/functions/math.py index ca252beeb..5d4ee9c41 100644 --- a/flow360/component/simulation/user_code/functions/math.py +++ b/flow360/component/simulation/user_code/functions/math.py @@ -1,6 +1,8 @@ """""" -from unyt import ucross, unyt_array +from typing import Any, Union + +from unyt import ucross, unyt_array, unyt_quantity from flow360.component.simulation.user_code.core.types import Expression, Variable @@ -12,16 +14,28 @@ def _convert_argument(value): if isinstance(value, Variable): return Expression.model_validate(value).evaluate(raise_error=False, force_evaluate=False) - if isinstance(value, Expression): - # TODO: Test numerical value? - return value.evaluate(raise_error=False, force_evaluate=False) return value -def cross(left, right): +def _handle_expression_list(value: list[Any]): + is_expression_list = False + + for item in value: + if isinstance(item, Expression): + is_expression_list = True + + if is_expression_list: + value = Expression.model_validate(value) + + return value + + +VectorInputType = Union[list[float], unyt_array, Expression] +ScalarInputType = Union[float, unyt_quantity, Expression] + + +def cross(left: VectorInputType, right: VectorInputType): """Customized Cross function to work with the `Expression` and Variables""" - # print("Old left:", left, " | ", left.__class__.__name__) - # print("Old right:", right, " | ", right.__class__.__name__) left = _convert_argument(left) right = _convert_argument(right) @@ -29,19 +43,11 @@ def cross(left, right): if isinstance(left, unyt_array) and isinstance(right, unyt_array): return ucross(left, right) + # Otherwise result = [ left[1] * right[2] - left[2] * right[1], left[2] * right[0] - left[0] * right[2], left[0] * right[1] - left[1] * right[0], ] - is_expression_type = False - - for item in result: - if isinstance(item, Expression): - is_expression_type = True - - if is_expression_type: - result = Expression.model_validate(result) - - return result + return _handle_expression_list(result) diff --git a/tests/simulation/test_expressions.py b/tests/simulation/test_expressions.py index 0096b8a4a..7135e8e0a 100644 --- a/tests/simulation/test_expressions.py +++ b/tests/simulation/test_expressions.py @@ -645,7 +645,6 @@ class TestModel(Flow360BaseModel): field: ValueOrExpression[LengthType.Vector] = pd.Field() # From string - # TODO: LengthType.Vector should be accepting client-time evaluable (constant result) exprs expr_1 = TestModel(field="math.cross([1, 2, 3], [1, 2, 3]*u.m)").field assert str(expr_1) == "math.cross([1, 2, 3], [1, 2, 3]*u.m)" @@ -662,154 +661,96 @@ class TestModel(Flow360BaseModel): # During solver translation both options are inlined the same way through partial evaluation solver_2 = expr_2.to_solver_code(params) - print( - solver_2 - ) # <- TODO: This currently will break, because the overloaded unyt operators in types.py don't - # handle List[Expression]... We should do some sort of implicit conversion perhaps? + print(solver_2) def test_cross_function_use_case(): - - def printer(expr: Expression): - res = expr.evaluate(raise_error=False, force_evaluate=False) - print(">> Evaluation Result type: ", res.__class__.__name__) - if isinstance(res, list): - print(">> Component type: ", [item.__class__.__name__ for item in res]) - for idx, item in enumerate(res): - print(f"[{idx}] ", item) - print("\n1 Python mode\n") - a = UserVariable(name="aaa", value=math.cross([3, 2, 1] * u.m, solution.coordinate)) + a = UserVariable(name="a", value=math.cross([3, 2, 1] * u.m, solution.coordinate)) res = a.value.evaluate(raise_error=False, force_evaluate=False) - assert ( - res[0].expression - == "2 * u.m * (solution.coordinate[2]) - (1 * u.m * (solution.coordinate[1]))" - ) - assert ( - res[1].expression - == "1 * u.m * (solution.coordinate[0]) - (3 * u.m * (solution.coordinate[2]))" - ) - assert ( - res[2].expression - == "3 * u.m * (solution.coordinate[1]) - (2 * u.m * (solution.coordinate[0]))" + assert str(res) == ( + "[2 * u.m * (solution.coordinate[2]) - (1 * u.m * (solution.coordinate[1]))," + "1 * u.m * (solution.coordinate[0]) - (3 * u.m * (solution.coordinate[2]))," + "3 * u.m * (solution.coordinate[1]) - (2 * u.m * (solution.coordinate[0]))]" ) print("\n1.1 Python mode but arg swapped\n") - a = UserVariable(name="aaa", value=math.cross(solution.coordinate, [3, 2, 1] * u.m)) + a.value = math.cross(solution.coordinate, [3, 2, 1] * u.m) res = a.value.evaluate(raise_error=False, force_evaluate=False) - - assert ( - res[0].expression - == "((solution.coordinate[1]) * 1) * u.m - (((solution.coordinate[2]) * 2) * u.m)" - ) - assert ( - res[1].expression - == "((solution.coordinate[2]) * 3) * u.m - (((solution.coordinate[0]) * 1) * u.m)" - ) - assert ( - res[2].expression - == "((solution.coordinate[0]) * 2) * u.m - (((solution.coordinate[1]) * 3) * u.m)" + assert str(res) == ( + "[((solution.coordinate[1]) * 1) * u.m - (((solution.coordinate[2]) * 2) * u.m)," + "((solution.coordinate[2]) * 3) * u.m - (((solution.coordinate[0]) * 1) * u.m)," + "((solution.coordinate[0]) * 2) * u.m - (((solution.coordinate[1]) * 3) * u.m)]" ) print("\n2 Taking advantage of unyt as much as possible\n") - a = UserVariable(name="aaa", value=math.cross([3, 2, 1] * u.m, [2, 2, 1] * u.m)) + a.value = math.cross([3, 2, 1] * u.m, [2, 2, 1] * u.m) assert all(a.value == [0, -1, 2] * u.m * u.m) - print("\n3 (Unfortunate ill LengthType usage)\n") - a = UserVariable( - name="aaa", value=math.cross([3 * u.m, 2 * u.m, 1 * u.m], [2 * u.m, 2 * u.m, 1 * u.m]) - ) + print("\n3 (Units defined in components)\n") + a.value = math.cross([3 * u.m, 2 * u.m, 1 * u.m], [2 * u.m, 2 * u.m, 1 * u.m]) assert a.value == [0 * u.m * u.m, -1 * u.m * u.m, 2 * u.m * u.m] print("\n4 Serialized version\n") - # TODO: Why string mode always evaluate to a single expression but not the Python counter part? - a = UserVariable(name="aaa", value="math.cross([3, 2, 1] * u.m, solution.coordinate)") + a.value = "math.cross([3, 2, 1] * u.m, solution.coordinate)" res = a.value.evaluate(raise_error=False, force_evaluate=False) - - assert ( - res.expression - == "[2 * u.m * (solution.coordinate[2]) - (1 * u.m * (solution.coordinate[1]))," - + "1 * u.m * (solution.coordinate[0]) - (3 * u.m * (solution.coordinate[2])),3 * u.m" - + " * (solution.coordinate[1]) - (2 * u.m * (solution.coordinate[0]))]" + assert str(res) == ( + "[2 * u.m * (solution.coordinate[2]) - (1 * u.m * (solution.coordinate[1]))," + "1 * u.m * (solution.coordinate[0]) - (3 * u.m * (solution.coordinate[2]))," + "3 * u.m * (solution.coordinate[1]) - (2 * u.m * (solution.coordinate[0]))]" ) print("\n5 Recursive cross in Python mode\n") - a = UserVariable( - name="aaa", - value=math.cross(math.cross([3, 2, 1] * u.m, solution.coordinate), [3, 2, 1] * u.m), - ) + a.value = math.cross(math.cross([3, 2, 1] * u.m, solution.coordinate), [3, 2, 1] * u.m) res = a.value.evaluate(raise_error=False, force_evaluate=False) - assert ( - res[0].expression - == "((1 * u.m * (solution.coordinate[0]) - (3 * u.m * (solution.coordinate[2]))) * 1) * u.m - (((3 * u.m * (solution.coordinate[1]) - (2 * u.m * (solution.coordinate[0]))) * 2) * u.m)" - ) - assert ( - res[1].expression - == "((3 * u.m * (solution.coordinate[1]) - (2 * u.m * (solution.coordinate[0]))) * 3) * u.m - (((2 * u.m * (solution.coordinate[2]) - (1 * u.m * (solution.coordinate[1]))) * 1) * u.m)" - ) - assert ( - res[2].expression - == "((2 * u.m * (solution.coordinate[2]) - (1 * u.m * (solution.coordinate[1]))) * 2) * u.m - (((1 * u.m * (solution.coordinate[0]) - (3 * u.m * (solution.coordinate[2]))) * 3) * u.m)" + assert str(res) == ( + "[((1 * u.m * (solution.coordinate[0]) - (3 * u.m * (solution.coordinate[2]))) * 1) * u.m - (((3 * u.m * (solution.coordinate[1]) - (2 * u.m * (solution.coordinate[0]))) * 2) * u.m)," + "((3 * u.m * (solution.coordinate[1]) - (2 * u.m * (solution.coordinate[0]))) * 3) * u.m - (((2 * u.m * (solution.coordinate[2]) - (1 * u.m * (solution.coordinate[1]))) * 1) * u.m)," + "((2 * u.m * (solution.coordinate[2]) - (1 * u.m * (solution.coordinate[1]))) * 2) * u.m - (((1 * u.m * (solution.coordinate[0]) - (3 * u.m * (solution.coordinate[2]))) * 3) * u.m)]" ) print("\n6 Recursive cross in String mode\n") - a = UserVariable( - name="aaa", - value="math.cross(math.cross([3, 2, 1] * u.m, solution.coordinate), [3, 2, 1] * u.m)", - ) + a.value = "math.cross(math.cross([3, 2, 1] * u.m, solution.coordinate), [3, 2, 1] * u.m)" res = a.value.evaluate(raise_error=False, force_evaluate=False) - assert ( - res.expression - == "[(1 * u.m * (solution.coordinate[0]) - (3 * u.m * (solution.coordinate[2]))) * 1 * u.m - ((3 * u.m * (solution.coordinate[1]) - (2 * u.m * (solution.coordinate[0]))) * 2 * u.m),(3 * u.m * (solution.coordinate[1]) - (2 * u.m * (solution.coordinate[0]))) * 3 * u.m - ((2 * u.m * (solution.coordinate[2]) - (1 * u.m * (solution.coordinate[1]))) * 1 * u.m),(2 * u.m * (solution.coordinate[2]) - (1 * u.m * (solution.coordinate[1]))) * 2 * u.m - ((1 * u.m * (solution.coordinate[0]) - (3 * u.m * (solution.coordinate[2]))) * 3 * u.m)]" - ) + # This is extremely long because every use of the inner math.cross() is inlined... + assert str(res) == ("[(([2 * u.m * (solution.coordinate[2]) - (1 * u.m * (solution.coordinate[1]))," + "1 * u.m * (solution.coordinate[0]) - (3 * u.m * (solution.coordinate[2]))," + "3 * u.m * (solution.coordinate[1]) - (2 * u.m * (solution.coordinate[0]))])[1]) * 1 * u.m - ((([2 * u.m * (solution.coordinate[2]) - (1 * u.m * (solution.coordinate[1]))," + "1 * u.m * (solution.coordinate[0]) - (3 * u.m * (solution.coordinate[2]))," + "3 * u.m * (solution.coordinate[1]) - (2 * u.m * (solution.coordinate[0]))])[2]) * 2 * u.m)," + "(([2 * u.m * (solution.coordinate[2]) - (1 * u.m * (solution.coordinate[1]))," + "1 * u.m * (solution.coordinate[0]) - (3 * u.m * (solution.coordinate[2]))," + "3 * u.m * (solution.coordinate[1]) - (2 * u.m * (solution.coordinate[0]))])[2]) * 3 * u.m - ((([2 * u.m * (solution.coordinate[2]) - (1 * u.m * (solution.coordinate[1]))," + "1 * u.m * (solution.coordinate[0]) - (3 * u.m * (solution.coordinate[2]))," + "3 * u.m * (solution.coordinate[1]) - (2 * u.m * (solution.coordinate[0]))])[0]) * 1 * u.m)," + "(([2 * u.m * (solution.coordinate[2]) - (1 * u.m * (solution.coordinate[1]))," + "1 * u.m * (solution.coordinate[0]) - (3 * u.m * (solution.coordinate[2]))," + "3 * u.m * (solution.coordinate[1]) - (2 * u.m * (solution.coordinate[0]))])[0]) * 2 * u.m - ((([2 * u.m * (solution.coordinate[2]) - (1 * u.m * (solution.coordinate[1]))," + "1 * u.m * (solution.coordinate[0]) - (3 * u.m * (solution.coordinate[2]))," + "3 * u.m * (solution.coordinate[1]) - (2 * u.m * (solution.coordinate[0]))])[1]) * 3 * u.m)]") print("\n7 Using other variabels in Python mode\n") - b = UserVariable(name="bbb", value=math.cross([3, 2, 1] * u.m, solution.coordinate)) - a = UserVariable(name="aaa", value=math.cross(b, [3, 2, 1] * u.m)) + b = UserVariable(name="b", value=math.cross([3, 2, 1] * u.m, solution.coordinate)) + a.value = math.cross(b, [3, 2, 1] * u.m) res = a.value.evaluate(raise_error=False, force_evaluate=False) - assert ( - res[0].expression - == "((1 * u.m * (solution.coordinate[0]) - (3 * u.m * (solution.coordinate[2]))) * 1) * u.m - (((3 * u.m * (solution.coordinate[1]) - (2 * u.m * (solution.coordinate[0]))) * 2) * u.m)" - ) - assert ( - res[1].expression - == "((3 * u.m * (solution.coordinate[1]) - (2 * u.m * (solution.coordinate[0]))) * 3) * u.m - (((2 * u.m * (solution.coordinate[2]) - (1 * u.m * (solution.coordinate[1]))) * 1) * u.m)" - ) - assert ( - res[2].expression - == "((2 * u.m * (solution.coordinate[2]) - (1 * u.m * (solution.coordinate[1]))) * 2) * u.m - (((1 * u.m * (solution.coordinate[0]) - (3 * u.m * (solution.coordinate[2]))) * 3) * u.m)" + assert str(res) == ( + "[((1 * u.m * (solution.coordinate[0]) - (3 * u.m * (solution.coordinate[2]))) * 1) * u.m - (((3 * u.m * (solution.coordinate[1]) - (2 * u.m * (solution.coordinate[0]))) * 2) * u.m)," + "((3 * u.m * (solution.coordinate[1]) - (2 * u.m * (solution.coordinate[0]))) * 3) * u.m - (((2 * u.m * (solution.coordinate[2]) - (1 * u.m * (solution.coordinate[1]))) * 1) * u.m)," + "((2 * u.m * (solution.coordinate[2]) - (1 * u.m * (solution.coordinate[1]))) * 2) * u.m - (((1 * u.m * (solution.coordinate[0]) - (3 * u.m * (solution.coordinate[2]))) * 3) * u.m)]" ) print("\n8 Using other constant variabels in Python mode\n") - b = UserVariable(name="bbb", value=[3, 2, 1] * u.m) - a = UserVariable(name="aaa", value=math.cross(b, solution.coordinate)) + b.value = [3, 2, 1] * u.m + a.value = math.cross(b, solution.coordinate) res = a.value.evaluate(raise_error=False, force_evaluate=False) - assert ( - res[0].expression - == "2 * u.m * (solution.coordinate[2]) - (1 * u.m * (solution.coordinate[1]))" - ) - assert ( - res[1].expression - == "1 * u.m * (solution.coordinate[0]) - (3 * u.m * (solution.coordinate[2]))" - ) - assert ( - res[2].expression - == "3 * u.m * (solution.coordinate[1]) - (2 * u.m * (solution.coordinate[0]))" - ) + assert str(res) == ("[2 * u.m * (solution.coordinate[2]) - (1 * u.m * (solution.coordinate[1]))," + "1 * u.m * (solution.coordinate[0]) - (3 * u.m * (solution.coordinate[2]))," + "3 * u.m * (solution.coordinate[1]) - (2 * u.m * (solution.coordinate[0]))]") print("\n9 Using non-unyt_array\n") - b = UserVariable(name="bbb", value=[3 * u.m, 2 * u.m, 1 * u.m]) - a = UserVariable(name="aaa", value=math.cross(b, solution.coordinate)) + b.value = [3 * u.m, 2 * u.m, 1 * u.m] + a.value = math.cross(b, solution.coordinate) res = a.value.evaluate(raise_error=False, force_evaluate=False) - assert ( - res[0].expression - == "2 * u.m * (solution.coordinate[2]) - (1 * u.m * (solution.coordinate[1]))" - ) - assert ( - res[1].expression - == "1 * u.m * (solution.coordinate[0]) - (3 * u.m * (solution.coordinate[2]))" - ) - assert ( - res[2].expression - == "3 * u.m * (solution.coordinate[1]) - (2 * u.m * (solution.coordinate[0]))" - ) + assert str(res) == ("[2 * u.m * (solution.coordinate[2]) - (1 * u.m * (solution.coordinate[1]))," + "1 * u.m * (solution.coordinate[0]) - (3 * u.m * (solution.coordinate[2]))," + "3 * u.m * (solution.coordinate[1]) - (2 * u.m * (solution.coordinate[0]))]") From ee3c7f9a066530428bc25edbf3377b6ac15740b3 Mon Sep 17 00:00:00 2001 From: Andrzej Krupka Date: Wed, 4 Jun 2025 17:14:37 +0200 Subject: [PATCH 14/19] Fix invalid list initialization syntax in the C++ code generator --- .../component/simulation/blueprint/core/generator.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/flow360/component/simulation/blueprint/core/generator.py b/flow360/component/simulation/blueprint/core/generator.py index 314f958c1..77d407903 100644 --- a/flow360/component/simulation/blueprint/core/generator.py +++ b/flow360/component/simulation/blueprint/core/generator.py @@ -139,10 +139,8 @@ def _tuple(expr, syntax, name_translator): return f"({', '.join(elements)})" if syntax == TargetSyntax.CPP: if len(expr.elements) == 0: - return "{}" - if len(expr.elements) == 1: - return f"{{{elements[0]}}}" - return f"{{{', '.join(elements)}}}" + return "std::vector()" + return f"std::vector({{{', '.join(elements)}}})" raise ValueError( f"Unsupported syntax type, available {[syntax.name for syntax in TargetSyntax]}" @@ -159,8 +157,8 @@ def _list(expr, syntax, name_translator): return f"[{elements_str}]" if syntax == TargetSyntax.CPP: if len(expr.elements) == 0: - return "{}" - return f"{{{', '.join(elements)}}}" + return "std::vector()" + return f"std::vector({{{', '.join(elements)}}})" raise ValueError( f"Unsupported syntax type, available {[syntax.name for syntax in TargetSyntax]}" From b24e20dcd6fc29a8e1ccbf3d039341f8a3714ca9 Mon Sep 17 00:00:00 2001 From: benFlexcompute Date: Wed, 4 Jun 2025 14:41:24 -0400 Subject: [PATCH 15/19] Added back the as_vector() implementation --- flow360/component/simulation/unit_system.py | 2 +- .../simulation/user_code/core/context.py | 6 +- .../simulation/user_code/core/types.py | 11 ++- .../simulation/user_code/functions/math.py | 4 +- tests/simulation/test_expressions.py | 74 +++++++++++++------ 5 files changed, 68 insertions(+), 29 deletions(-) diff --git a/flow360/component/simulation/unit_system.py b/flow360/component/simulation/unit_system.py index 2a6fae9db..6356349d2 100644 --- a/flow360/component/simulation/unit_system.py +++ b/flow360/component/simulation/unit_system.py @@ -191,7 +191,7 @@ def _list_of_unyt_quantity_to_unyt_array(value): return value if not all(isinstance(item, unyt_quantity) for item in value): return value - units = set([item.units for item in value]) + units = {item.units for item in value} if not len(units) == 1: return value shared_unit = units.pop() diff --git a/flow360/component/simulation/user_code/core/context.py b/flow360/component/simulation/user_code/core/context.py index d12161770..35f95a478 100644 --- a/flow360/component/simulation/user_code/core/context.py +++ b/flow360/component/simulation/user_code/core/context.py @@ -30,7 +30,7 @@ def _import_units(_) -> Any: def _import_math(_) -> Any: """Import and return allowed function callables""" - # pylint:disable=import-outside-toplevel + # pylint:disable=import-outside-toplevel, cyclic-import from flow360.component.simulation.user_code.functions import math return math @@ -38,7 +38,7 @@ def _import_math(_) -> Any: def _import_control(_) -> Any: """Import and return allowed control variable callables""" - # pylint:disable=import-outside-toplevel + # pylint:disable=import-outside-toplevel, cyclic-import from flow360.component.simulation.user_code.variables import control return control @@ -46,7 +46,7 @@ def _import_control(_) -> Any: def _import_solution(_) -> Any: """Import and return allowed solution variable callables""" - # pylint:disable=import-outside-toplevel + # pylint:disable=import-outside-toplevel, cyclic-import from flow360.component.simulation.user_code.variables import solution return solution diff --git a/flow360/component/simulation/user_code/core/types.py b/flow360/component/simulation/user_code/core/types.py index 3c4b79502..2a9005961 100644 --- a/flow360/component/simulation/user_code/core/types.py +++ b/flow360/component/simulation/user_code/core/types.py @@ -2,6 +2,7 @@ from __future__ import annotations +import ast import re from numbers import Number from typing import Annotated, Any, Generic, Literal, Optional, TypeVar, Union @@ -495,8 +496,14 @@ def __rpow__(self, other): str_arg = arg if not parenthesize else f"({arg})" return Expression(expression=f"{str_arg} ** ({self.expression})") - def __getitem__(self, item): - (arg, _) = _convert_argument(item) + def __getitem__(self, index): + (arg, _) = _convert_argument(index) + + tree = ast.parse(self.expression, mode="eval") + if isinstance(tree.body, ast.List): + # Expression string with list syntax, like "[aa,bb,cc]" + result = [ast.unparse(elt) for elt in tree.body.elts] + return Expression.model_validate(result[int(arg)]) return Expression(expression=f"({self.expression})[{arg}]") def __str__(self): diff --git a/flow360/component/simulation/user_code/functions/math.py b/flow360/component/simulation/user_code/functions/math.py index 5d4ee9c41..18480e4ef 100644 --- a/flow360/component/simulation/user_code/functions/math.py +++ b/flow360/component/simulation/user_code/functions/math.py @@ -1,4 +1,6 @@ -"""""" +""" +Math.h for Flow360 Expression system +""" from typing import Any, Union diff --git a/tests/simulation/test_expressions.py b/tests/simulation/test_expressions.py index 7135e8e0a..b4877e1e3 100644 --- a/tests/simulation/test_expressions.py +++ b/tests/simulation/test_expressions.py @@ -665,6 +665,12 @@ class TestModel(Flow360BaseModel): def test_cross_function_use_case(): + + with SI_unit_system: + params = SimulationParams( + private_attribute_asset_cache=AssetCache(project_length_unit=10 * u.m) + ) + print("\n1 Python mode\n") a = UserVariable(name="a", value=math.cross([3, 2, 1] * u.m, solution.coordinate)) res = a.value.evaluate(raise_error=False, force_evaluate=False) @@ -673,6 +679,10 @@ def test_cross_function_use_case(): "1 * u.m * (solution.coordinate[0]) - (3 * u.m * (solution.coordinate[2]))," "3 * u.m * (solution.coordinate[1]) - (2 * u.m * (solution.coordinate[0]))]" ) + assert ( + a.value.to_solver_code(params) + == "std::vector({(((2 * 0.1) * solution.coordinate[2]) - ((1 * 0.1) * solution.coordinate[1])), (((1 * 0.1) * solution.coordinate[0]) - ((3 * 0.1) * solution.coordinate[2])), (((3 * 0.1) * solution.coordinate[1]) - ((2 * 0.1) * solution.coordinate[0]))})" + ) print("\n1.1 Python mode but arg swapped\n") a.value = math.cross(solution.coordinate, [3, 2, 1] * u.m) @@ -682,6 +692,10 @@ def test_cross_function_use_case(): "((solution.coordinate[2]) * 3) * u.m - (((solution.coordinate[0]) * 1) * u.m)," "((solution.coordinate[0]) * 2) * u.m - (((solution.coordinate[1]) * 3) * u.m)]" ) + assert ( + a.value.to_solver_code(params) + == "std::vector({(((solution.coordinate[1] * 1) * 0.1) - ((solution.coordinate[2] * 2) * 0.1)), (((solution.coordinate[2] * 3) * 0.1) - ((solution.coordinate[0] * 1) * 0.1)), (((solution.coordinate[0] * 2) * 0.1) - ((solution.coordinate[1] * 3) * 0.1))})" + ) print("\n2 Taking advantage of unyt as much as possible\n") a.value = math.cross([3, 2, 1] * u.m, [2, 2, 1] * u.m) @@ -699,6 +713,10 @@ def test_cross_function_use_case(): "1 * u.m * (solution.coordinate[0]) - (3 * u.m * (solution.coordinate[2]))," "3 * u.m * (solution.coordinate[1]) - (2 * u.m * (solution.coordinate[0]))]" ) + assert ( + a.value.to_solver_code(params) + == "std::vector({(((2 * 0.1) * solution.coordinate[2]) - ((1 * 0.1) * solution.coordinate[1])), (((1 * 0.1) * solution.coordinate[0]) - ((3 * 0.1) * solution.coordinate[2])), (((3 * 0.1) * solution.coordinate[1]) - ((2 * 0.1) * solution.coordinate[0]))})" + ) print("\n5 Recursive cross in Python mode\n") a.value = math.cross(math.cross([3, 2, 1] * u.m, solution.coordinate), [3, 2, 1] * u.m) @@ -708,26 +726,22 @@ def test_cross_function_use_case(): "((3 * u.m * (solution.coordinate[1]) - (2 * u.m * (solution.coordinate[0]))) * 3) * u.m - (((2 * u.m * (solution.coordinate[2]) - (1 * u.m * (solution.coordinate[1]))) * 1) * u.m)," "((2 * u.m * (solution.coordinate[2]) - (1 * u.m * (solution.coordinate[1]))) * 2) * u.m - (((1 * u.m * (solution.coordinate[0]) - (3 * u.m * (solution.coordinate[2]))) * 3) * u.m)]" ) + assert ( + a.value.to_solver_code(params) + == "std::vector({((((((1 * 0.1) * solution.coordinate[0]) - ((3 * 0.1) * solution.coordinate[2])) * 1) * 0.1) - (((((3 * 0.1) * solution.coordinate[1]) - ((2 * 0.1) * solution.coordinate[0])) * 2) * 0.1)), ((((((3 * 0.1) * solution.coordinate[1]) - ((2 * 0.1) * solution.coordinate[0])) * 3) * 0.1) - (((((2 * 0.1) * solution.coordinate[2]) - ((1 * 0.1) * solution.coordinate[1])) * 1) * 0.1)), ((((((2 * 0.1) * solution.coordinate[2]) - ((1 * 0.1) * solution.coordinate[1])) * 2) * 0.1) - (((((1 * 0.1) * solution.coordinate[0]) - ((3 * 0.1) * solution.coordinate[2])) * 3) * 0.1))})" + ) print("\n6 Recursive cross in String mode\n") a.value = "math.cross(math.cross([3, 2, 1] * u.m, solution.coordinate), [3, 2, 1] * u.m)" res = a.value.evaluate(raise_error=False, force_evaluate=False) - # This is extremely long because every use of the inner math.cross() is inlined... - assert str(res) == ("[(([2 * u.m * (solution.coordinate[2]) - (1 * u.m * (solution.coordinate[1]))," - "1 * u.m * (solution.coordinate[0]) - (3 * u.m * (solution.coordinate[2]))," - "3 * u.m * (solution.coordinate[1]) - (2 * u.m * (solution.coordinate[0]))])[1]) * 1 * u.m - ((([2 * u.m * (solution.coordinate[2]) - (1 * u.m * (solution.coordinate[1]))," - "1 * u.m * (solution.coordinate[0]) - (3 * u.m * (solution.coordinate[2]))," - "3 * u.m * (solution.coordinate[1]) - (2 * u.m * (solution.coordinate[0]))])[2]) * 2 * u.m)," - "(([2 * u.m * (solution.coordinate[2]) - (1 * u.m * (solution.coordinate[1]))," - "1 * u.m * (solution.coordinate[0]) - (3 * u.m * (solution.coordinate[2]))," - "3 * u.m * (solution.coordinate[1]) - (2 * u.m * (solution.coordinate[0]))])[2]) * 3 * u.m - ((([2 * u.m * (solution.coordinate[2]) - (1 * u.m * (solution.coordinate[1]))," - "1 * u.m * (solution.coordinate[0]) - (3 * u.m * (solution.coordinate[2]))," - "3 * u.m * (solution.coordinate[1]) - (2 * u.m * (solution.coordinate[0]))])[0]) * 1 * u.m)," - "(([2 * u.m * (solution.coordinate[2]) - (1 * u.m * (solution.coordinate[1]))," - "1 * u.m * (solution.coordinate[0]) - (3 * u.m * (solution.coordinate[2]))," - "3 * u.m * (solution.coordinate[1]) - (2 * u.m * (solution.coordinate[0]))])[0]) * 2 * u.m - ((([2 * u.m * (solution.coordinate[2]) - (1 * u.m * (solution.coordinate[1]))," - "1 * u.m * (solution.coordinate[0]) - (3 * u.m * (solution.coordinate[2]))," - "3 * u.m * (solution.coordinate[1]) - (2 * u.m * (solution.coordinate[0]))])[1]) * 3 * u.m)]") + assert ( + str(res) + == "[(1 * u.m * solution.coordinate[0] - 3 * u.m * solution.coordinate[2]) * 1 * u.m - ((3 * u.m * solution.coordinate[1] - 2 * u.m * solution.coordinate[0]) * 2 * u.m),(3 * u.m * solution.coordinate[1] - 2 * u.m * solution.coordinate[0]) * 3 * u.m - ((2 * u.m * solution.coordinate[2] - 1 * u.m * solution.coordinate[1]) * 1 * u.m),(2 * u.m * solution.coordinate[2] - 1 * u.m * solution.coordinate[1]) * 2 * u.m - ((1 * u.m * solution.coordinate[0] - 3 * u.m * solution.coordinate[2]) * 3 * u.m)]" + ) + assert ( + a.value.to_solver_code(params) + == "std::vector({((((((1 * 0.1) * solution.coordinate[0]) - ((3 * 0.1) * solution.coordinate[2])) * 1) * 0.1) - (((((3 * 0.1) * solution.coordinate[1]) - ((2 * 0.1) * solution.coordinate[0])) * 2) * 0.1)), ((((((3 * 0.1) * solution.coordinate[1]) - ((2 * 0.1) * solution.coordinate[0])) * 3) * 0.1) - (((((2 * 0.1) * solution.coordinate[2]) - ((1 * 0.1) * solution.coordinate[1])) * 1) * 0.1)), ((((((2 * 0.1) * solution.coordinate[2]) - ((1 * 0.1) * solution.coordinate[1])) * 2) * 0.1) - (((((1 * 0.1) * solution.coordinate[0]) - ((3 * 0.1) * solution.coordinate[2])) * 3) * 0.1))})" + ) print("\n7 Using other variabels in Python mode\n") b = UserVariable(name="b", value=math.cross([3, 2, 1] * u.m, solution.coordinate)) @@ -738,19 +752,35 @@ def test_cross_function_use_case(): "((3 * u.m * (solution.coordinate[1]) - (2 * u.m * (solution.coordinate[0]))) * 3) * u.m - (((2 * u.m * (solution.coordinate[2]) - (1 * u.m * (solution.coordinate[1]))) * 1) * u.m)," "((2 * u.m * (solution.coordinate[2]) - (1 * u.m * (solution.coordinate[1]))) * 2) * u.m - (((1 * u.m * (solution.coordinate[0]) - (3 * u.m * (solution.coordinate[2]))) * 3) * u.m)]" ) + assert ( + a.value.to_solver_code(params) + == "std::vector({((((((1 * 0.1) * solution.coordinate[0]) - ((3 * 0.1) * solution.coordinate[2])) * 1) * 0.1) - (((((3 * 0.1) * solution.coordinate[1]) - ((2 * 0.1) * solution.coordinate[0])) * 2) * 0.1)), ((((((3 * 0.1) * solution.coordinate[1]) - ((2 * 0.1) * solution.coordinate[0])) * 3) * 0.1) - (((((2 * 0.1) * solution.coordinate[2]) - ((1 * 0.1) * solution.coordinate[1])) * 1) * 0.1)), ((((((2 * 0.1) * solution.coordinate[2]) - ((1 * 0.1) * solution.coordinate[1])) * 2) * 0.1) - (((((1 * 0.1) * solution.coordinate[0]) - ((3 * 0.1) * solution.coordinate[2])) * 3) * 0.1))})" + ) print("\n8 Using other constant variabels in Python mode\n") b.value = [3, 2, 1] * u.m a.value = math.cross(b, solution.coordinate) res = a.value.evaluate(raise_error=False, force_evaluate=False) - assert str(res) == ("[2 * u.m * (solution.coordinate[2]) - (1 * u.m * (solution.coordinate[1]))," - "1 * u.m * (solution.coordinate[0]) - (3 * u.m * (solution.coordinate[2]))," - "3 * u.m * (solution.coordinate[1]) - (2 * u.m * (solution.coordinate[0]))]") + assert str(res) == ( + "[2 * u.m * (solution.coordinate[2]) - (1 * u.m * (solution.coordinate[1]))," + "1 * u.m * (solution.coordinate[0]) - (3 * u.m * (solution.coordinate[2]))," + "3 * u.m * (solution.coordinate[1]) - (2 * u.m * (solution.coordinate[0]))]" + ) + assert ( + a.value.to_solver_code(params) + == "std::vector({(((2 * 0.1) * solution.coordinate[2]) - ((1 * 0.1) * solution.coordinate[1])), (((1 * 0.1) * solution.coordinate[0]) - ((3 * 0.1) * solution.coordinate[2])), (((3 * 0.1) * solution.coordinate[1]) - ((2 * 0.1) * solution.coordinate[0]))})" + ) print("\n9 Using non-unyt_array\n") b.value = [3 * u.m, 2 * u.m, 1 * u.m] a.value = math.cross(b, solution.coordinate) res = a.value.evaluate(raise_error=False, force_evaluate=False) - assert str(res) == ("[2 * u.m * (solution.coordinate[2]) - (1 * u.m * (solution.coordinate[1]))," - "1 * u.m * (solution.coordinate[0]) - (3 * u.m * (solution.coordinate[2]))," - "3 * u.m * (solution.coordinate[1]) - (2 * u.m * (solution.coordinate[0]))]") + assert str(res) == ( + "[2 * u.m * (solution.coordinate[2]) - (1 * u.m * (solution.coordinate[1]))," + "1 * u.m * (solution.coordinate[0]) - (3 * u.m * (solution.coordinate[2]))," + "3 * u.m * (solution.coordinate[1]) - (2 * u.m * (solution.coordinate[0]))]" + ) + assert ( + a.value.to_solver_code(params) + == "std::vector({(((2 * 0.1) * solution.coordinate[2]) - ((1 * 0.1) * solution.coordinate[1])), (((1 * 0.1) * solution.coordinate[0]) - ((3 * 0.1) * solution.coordinate[2])), (((3 * 0.1) * solution.coordinate[1]) - ((2 * 0.1) * solution.coordinate[0]))})" + ) From eeb849ceb75bdef3e91ebc32a2513e3ebc32759d Mon Sep 17 00:00:00 2001 From: benFlexcompute Date: Wed, 4 Jun 2025 17:07:19 -0400 Subject: [PATCH 16/19] Renamed raise_error --- .../simulation/blueprint/core/__init__.py | 36 +++---- .../simulation/blueprint/core/expressions.py | 100 ++++++++++++------ .../simulation/blueprint/core/statements.py | 67 ++++++++---- .../simulation/blueprint/core/types.py | 7 +- flow360/component/simulation/services.py | 4 +- .../component/simulation/translator/utils.py | 4 +- .../simulation/user_code/core/types.py | 12 ++- .../simulation/user_code/functions/math.py | 4 +- tests/simulation/test_expressions.py | 16 +-- 9 files changed, 157 insertions(+), 93 deletions(-) diff --git a/flow360/component/simulation/blueprint/core/__init__.py b/flow360/component/simulation/blueprint/core/__init__.py index f521ad40d..4724c0f72 100644 --- a/flow360/component/simulation/blueprint/core/__init__.py +++ b/flow360/component/simulation/blueprint/core/__init__.py @@ -34,26 +34,26 @@ def _model_rebuild() -> None: """Update forward references in the correct order.""" namespace = { # Expression types - "Name": NameNode, - "Constant": ConstantNode, - "BinOp": BinOpNode, - "RangeCall": RangeCallNode, - "CallModel": CallModelNode, - "Tuple": TupleNode, - "List": ListNode, - "ListComp": ListCompNode, - "Subscript": SubscriptNode, - "ExpressionType": ExpressionNodeType, + "NameNode": NameNode, + "ConstantNode": ConstantNode, + "BinOpNode": BinOpNode, + "RangeCallNode": RangeCallNode, + "CallModelNode": CallModelNode, + "TupleNode": TupleNode, + "ListNode": ListNode, + "ListCompNode": ListCompNode, + "SubscriptNode": SubscriptNode, + "ExpressionNodeType": ExpressionNodeType, # Statement types - "Assign": AssignNode, - "AugAssign": AugAssignNode, - "IfElse": IfElseNode, - "ForLoop": ForLoopNode, - "Return": ReturnNode, - "TupleUnpack": TupleUnpackNode, - "StatementType": StatementNodeType, + "AssignNode": AssignNode, + "AugAssignNode": AugAssignNode, + "IfElseNode": IfElseNode, + "ForLoopNode": ForLoopNode, + "ReturnNode": ReturnNode, + "TupleUnpackNode": TupleUnpackNode, + "StatementNodeType": StatementNodeType, # Function type - "Function": FunctionNode, + "FunctionNode": FunctionNode, } # First update expression classes that only depend on ExpressionType diff --git a/flow360/component/simulation/blueprint/core/expressions.py b/flow360/component/simulation/blueprint/core/expressions.py index 3b9092ad1..ad6351f6c 100644 --- a/flow360/component/simulation/blueprint/core/expressions.py +++ b/flow360/component/simulation/blueprint/core/expressions.py @@ -12,15 +12,15 @@ ExpressionNodeType = Annotated[ # pylint: disable=duplicate-code Union[ - "Name", - "Constant", - "BinOp", - "RangeCall", - "CallModel", - "Tuple", - "List", - "ListComp", - "Subscript", + "NameNode", + "ConstantNode", + "BinOpNode", + "RangeCallNode", + "CallModelNode", + "TupleNode", + "ListNode", + "ListCompNode", + "SubscriptNode", ], pd.Field(discriminator="type"), ] @@ -55,10 +55,10 @@ class NameNode(ExpressionNode): def evaluate( self, context: EvaluationContext, - raise_error: bool = True, + raise_on_non_evaluable: bool = True, force_evaluate: bool = True, ) -> Any: - if raise_error and not context.can_evaluate(self.id): + if raise_on_non_evaluable and not context.can_evaluate(self.id): raise ValueError(f"Name '{self.id}' cannot be evaluated at client runtime") if not force_evaluate and not context.can_evaluate(self.id): data_model = context.get_data_model(self.id) @@ -68,7 +68,7 @@ def evaluate( value = context.get(self.id) # Recursively evaluate if the returned value is evaluable if isinstance(value, Evaluable): - value = value.evaluate(context, raise_error, force_evaluate) + value = value.evaluate(context, raise_on_non_evaluable, force_evaluate) return value def used_names(self) -> set[str]: @@ -84,7 +84,10 @@ class ConstantNode(ExpressionNode): value: Any def evaluate( - self, context: EvaluationContext, raise_error: bool = True, force_evaluate: bool = True + self, + context: EvaluationContext, + raise_on_non_evaluable: bool = True, + force_evaluate: bool = True, ) -> Any: # noqa: ARG002 return self.value @@ -102,9 +105,12 @@ class UnaryOpNode(ExpressionNode): operand: "ExpressionNodeType" def evaluate( - self, context: EvaluationContext, raise_error: bool = True, force_evaluate: bool = True + self, + context: EvaluationContext, + raise_on_non_evaluable: bool = True, + force_evaluate: bool = True, ) -> Any: - operand_val = self.operand.evaluate(context, raise_error, force_evaluate) + operand_val = self.operand.evaluate(context, raise_on_non_evaluable, force_evaluate) if self.op not in UNARY_OPERATORS: raise ValueError(f"Unsupported operator: {self.op}") @@ -126,10 +132,13 @@ class BinOpNode(ExpressionNode): right: "ExpressionNodeType" def evaluate( - self, context: EvaluationContext, raise_error: bool = True, force_evaluate: bool = True + self, + context: EvaluationContext, + raise_on_non_evaluable: bool = True, + force_evaluate: bool = True, ) -> Any: - left_val = self.left.evaluate(context, raise_error, force_evaluate) - right_val = self.right.evaluate(context, raise_error, force_evaluate) + left_val = self.left.evaluate(context, raise_on_non_evaluable, force_evaluate) + right_val = self.right.evaluate(context, raise_on_non_evaluable, force_evaluate) if self.op not in BINARY_OPERATORS: raise ValueError(f"Unsupported operator: {self.op}") @@ -153,10 +162,13 @@ class SubscriptNode(ExpressionNode): ctx: str # Only load context def evaluate( - self, context: EvaluationContext, raise_error: bool = True, force_evaluate: bool = True + self, + context: EvaluationContext, + raise_on_non_evaluable: bool = True, + force_evaluate: bool = True, ) -> Any: - value = self.value.evaluate(context, raise_error, force_evaluate) - item = self.slice.evaluate(context, raise_error, force_evaluate) + value = self.value.evaluate(context, raise_on_non_evaluable, force_evaluate) + item = self.slice.evaluate(context, raise_on_non_evaluable, force_evaluate) if self.ctx == "Load": return value[item] @@ -180,9 +192,12 @@ class RangeCallNode(ExpressionNode): arg: "ExpressionNodeType" def evaluate( - self, context: EvaluationContext, raise_error: bool = True, force_evaluate: bool = True + self, + context: EvaluationContext, + raise_on_non_evaluable: bool = True, + force_evaluate: bool = True, ) -> range: - return range(self.arg.evaluate(context, raise_error)) + return range(self.arg.evaluate(context, raise_on_non_evaluable)) def used_names(self) -> set[str]: return self.arg.used_names() @@ -204,7 +219,10 @@ class CallModelNode(ExpressionNode): kwargs: dict[str, "ExpressionNodeType"] = {} def evaluate( - self, context: EvaluationContext, raise_error: bool = True, force_evaluate: bool = True + self, + context: EvaluationContext, + raise_on_non_evaluable: bool = True, + force_evaluate: bool = True, ) -> Any: try: # Split into parts for attribute traversal @@ -225,9 +243,12 @@ def evaluate( func = getattr(base, parts[-1]) # Evaluate arguments - args = [arg.evaluate(context, raise_error, force_evaluate) for arg in self.args] + args = [ + arg.evaluate(context, raise_on_non_evaluable, force_evaluate) for arg in self.args + ] kwargs = { - k: v.evaluate(context, raise_error, force_evaluate) for k, v in self.kwargs.items() + k: v.evaluate(context, raise_on_non_evaluable, force_evaluate) + for k, v in self.kwargs.items() } return func(*args, **kwargs) @@ -258,9 +279,14 @@ class TupleNode(ExpressionNode): elements: list["ExpressionNodeType"] def evaluate( - self, context: EvaluationContext, raise_error: bool = True, force_evaluate: bool = True + self, + context: EvaluationContext, + raise_on_non_evaluable: bool = True, + force_evaluate: bool = True, ) -> tuple: - return tuple(elem.evaluate(context, raise_error, force_evaluate) for elem in self.elements) + return tuple( + elem.evaluate(context, raise_on_non_evaluable, force_evaluate) for elem in self.elements + ) def used_names(self) -> set[str]: return self.arg.used_names() @@ -273,9 +299,14 @@ class ListNode(ExpressionNode): elements: list["ExpressionNodeType"] def evaluate( - self, context: EvaluationContext, raise_error: bool = True, force_evaluate: bool = True + self, + context: EvaluationContext, + raise_on_non_evaluable: bool = True, + force_evaluate: bool = True, ) -> list: - return [elem.evaluate(context, raise_error, force_evaluate) for elem in self.elements] + return [ + elem.evaluate(context, raise_on_non_evaluable, force_evaluate) for elem in self.elements + ] def used_names(self) -> set[str]: names = set() @@ -295,15 +326,18 @@ class ListCompNode(ExpressionNode): iter: "ExpressionNodeType" # The iterable expression def evaluate( - self, context: EvaluationContext, raise_error: bool = True, force_evaluate: bool = True + self, + context: EvaluationContext, + raise_on_non_evaluable: bool = True, + force_evaluate: bool = True, ) -> list: result = [] - iterable = self.iter.evaluate(context, raise_error, force_evaluate) + iterable = self.iter.evaluate(context, raise_on_non_evaluable, force_evaluate) for item in iterable: # Create a new context for each iteration with the target variable iter_context = context.copy() iter_context.set(self.target, item) - result.append(self.element.evaluate(iter_context, raise_error)) + result.append(self.element.evaluate(iter_context, raise_on_non_evaluable)) return result def used_names(self) -> set[str]: diff --git a/flow360/component/simulation/blueprint/core/statements.py b/flow360/component/simulation/blueprint/core/statements.py index 85fbe75d5..ecef64c46 100644 --- a/flow360/component/simulation/blueprint/core/statements.py +++ b/flow360/component/simulation/blueprint/core/statements.py @@ -12,12 +12,12 @@ StatementNodeType = Annotated[ # pylint: disable=duplicate-code Union[ - "Assign", - "AugAssign", - "IfElse", - "ForLoop", - "Return", - "TupleUnpack", + "AssignNode", + "AugAssignNode", + "IfElseNode", + "ForLoopNode", + "ReturnNode", + "TupleUnpackNode", ], pd.Field(discriminator="type"), ] @@ -29,7 +29,10 @@ class StatementNode(pd.BaseModel, Evaluable): """ def evaluate( - self, context: EvaluationContext, raise_error: bool = True, force_evaluate: bool = True + self, + context: EvaluationContext, + raise_on_non_evaluable: bool = True, + force_evaluate: bool = True, ) -> None: raise NotImplementedError @@ -44,9 +47,14 @@ class AssignNode(StatementNode): value: ExpressionNodeType def evaluate( - self, context: EvaluationContext, raise_error: bool = True, force_evaluate: bool = True + self, + context: EvaluationContext, + raise_on_non_evaluable: bool = True, + force_evaluate: bool = True, ) -> None: - context.set(self.target, self.value.evaluate(context, raise_error, force_evaluate)) + context.set( + self.target, self.value.evaluate(context, raise_on_non_evaluable, force_evaluate) + ) class AugAssignNode(StatementNode): @@ -61,10 +69,13 @@ class AugAssignNode(StatementNode): value: ExpressionNodeType def evaluate( - self, context: EvaluationContext, raise_error: bool = True, force_evaluate: bool = True + self, + context: EvaluationContext, + raise_on_non_evaluable: bool = True, + force_evaluate: bool = True, ) -> None: old_val = context.get(self.target) - increment = self.value.evaluate(context, raise_error, force_evaluate) + increment = self.value.evaluate(context, raise_on_non_evaluable, force_evaluate) if self.op == "Add": context.set(self.target, old_val + increment) elif self.op == "Sub": @@ -92,14 +103,17 @@ class IfElseNode(StatementNode): orelse: list["StatementNodeType"] def evaluate( - self, context: EvaluationContext, raise_error: bool = True, force_evaluate: bool = True + self, + context: EvaluationContext, + raise_on_non_evaluable: bool = True, + force_evaluate: bool = True, ) -> None: - if self.condition.evaluate(context, raise_error, force_evaluate): + if self.condition.evaluate(context, raise_on_non_evaluable, force_evaluate): for stmt in self.body: - stmt.evaluate(context, raise_error, force_evaluate) + stmt.evaluate(context, raise_on_non_evaluable, force_evaluate) else: for stmt in self.orelse: - stmt.evaluate(context, raise_error) + stmt.evaluate(context, raise_on_non_evaluable) class ForLoopNode(StatementNode): @@ -115,13 +129,16 @@ class ForLoopNode(StatementNode): body: list["StatementNodeType"] def evaluate( - self, context: EvaluationContext, raise_error: bool = True, force_evaluate: bool = True + self, + context: EvaluationContext, + raise_on_non_evaluable: bool = True, + force_evaluate: bool = True, ) -> None: - iterable = self.iter.evaluate(context, raise_error, force_evaluate) + iterable = self.iter.evaluate(context, raise_on_non_evaluable, force_evaluate) for item in iterable: context.set(self.target, item) for stmt in self.body: - stmt.evaluate(context, raise_error, force_evaluate) + stmt.evaluate(context, raise_on_non_evaluable, force_evaluate) class ReturnNode(StatementNode): @@ -134,9 +151,12 @@ class ReturnNode(StatementNode): value: ExpressionNodeType def evaluate( - self, context: EvaluationContext, raise_error: bool = True, force_evaluate: bool = True + self, + context: EvaluationContext, + raise_on_non_evaluable: bool = True, + force_evaluate: bool = True, ) -> None: - val = self.value.evaluate(context, raise_error, force_evaluate) + val = self.value.evaluate(context, raise_on_non_evaluable, force_evaluate) raise ReturnValue(val) @@ -148,10 +168,13 @@ class TupleUnpackNode(StatementNode): values: list[ExpressionNodeType] def evaluate( - self, context: EvaluationContext, raise_error: bool = True, force_evaluate: bool = True + self, + context: EvaluationContext, + raise_on_non_evaluable: bool = True, + force_evaluate: bool = True, ) -> None: evaluated_values = [ - val.evaluate(context, raise_error, force_evaluate) for val in self.values + val.evaluate(context, raise_on_non_evaluable, force_evaluate) for val in self.values ] for target, value in zip(self.targets, evaluated_values): context.set(target, value) diff --git a/flow360/component/simulation/blueprint/core/types.py b/flow360/component/simulation/blueprint/core/types.py index a5d3a6830..87cbf2a8f 100644 --- a/flow360/component/simulation/blueprint/core/types.py +++ b/flow360/component/simulation/blueprint/core/types.py @@ -14,14 +14,17 @@ class Evaluable(metaclass=abc.ABCMeta): @abc.abstractmethod def evaluate( - self, context: EvaluationContext, raise_error: bool = True, force_evaluate: bool = True + self, + context: EvaluationContext, + raise_on_non_evaluable: bool = True, + force_evaluate: bool = True, ) -> Any: """ Evaluate the expression using the given context. Args: context (EvaluationContext): The context in which to evaluate the expression. - raise_error (bool): If True, raise an error on non-evaluable symbols; + raise_on_non_evaluable (bool): If True, raise an error on non-evaluable symbols; if False, allow graceful failure or fallback behavior. force_evaluate (bool): If True, evaluate evaluable objects marked as non-evaluable, instead of returning their identifier. diff --git a/flow360/component/simulation/services.py b/flow360/component/simulation/services.py index 7ce9e1615..92326832d 100644 --- a/flow360/component/simulation/services.py +++ b/flow360/component/simulation/services.py @@ -800,7 +800,7 @@ def validate_expression(variables: list[dict], expressions: list[str]): try: variable = UserVariable(name=variable["name"], value=variable["value"]) if variable and isinstance(variable.value, Expression): - _ = variable.value.evaluate(raise_error=False) + _ = variable.value.evaluate(raise_on_non_evaluable=False) except pd.ValidationError as err: errors.extend(err.errors()) except Exception as err: # pylint: disable=broad-exception-caught @@ -812,7 +812,7 @@ def validate_expression(variables: list[dict], expressions: list[str]): unit = None try: expression_object = Expression(expression=expression) - result = expression_object.evaluate(raise_error=False) + result = expression_object.evaluate(raise_on_non_evaluable=False) if np.isnan(result): pass elif isinstance(result, Number): diff --git a/flow360/component/simulation/translator/utils.py b/flow360/component/simulation/translator/utils.py index f525499aa..44fda088f 100644 --- a/flow360/component/simulation/translator/utils.py +++ b/flow360/component/simulation/translator/utils.py @@ -153,7 +153,7 @@ def inline_expressions_in_dict(input_dict, input_params): new_dict = {} if "expression" in input_dict.keys(): expression = Expression(expression=input_dict["expression"]) - evaluated = expression.evaluate(raise_error=False) + evaluated = expression.evaluate(raise_on_non_evaluable=False) converted = input_params.convert_unit(evaluated, "flow360").v new_dict = converted return new_dict @@ -162,7 +162,7 @@ def inline_expressions_in_dict(input_dict, input_params): # so remove_units_in_dict should handle them correctly... if isinstance(value, dict) and "expression" in value.keys(): expression = Expression(expression=value["expression"]) - evaluated = expression.evaluate(raise_error=False) + evaluated = expression.evaluate(raise_on_non_evaluable=False) converted = input_params.convert_unit(evaluated, "flow360").v if isinstance(converted, np.ndarray): if converted.ndim == 0: diff --git a/flow360/component/simulation/user_code/core/types.py b/flow360/component/simulation/user_code/core/types.py index 2a9005961..9fbec6844 100644 --- a/flow360/component/simulation/user_code/core/types.py +++ b/flow360/component/simulation/user_code/core/types.py @@ -339,14 +339,14 @@ def _validate_expression(cls, value) -> Self: def evaluate( self, context: EvaluationContext = None, - raise_error: bool = True, + raise_on_non_evaluable: bool = True, force_evaluate: bool = True, ) -> Union[float, list[float], unyt_array, Expression]: """Evaluate this expression against the given context.""" if context is None: context = default_context expr = expr_to_model(self.expression, context) - result = expr.evaluate(context, raise_error, force_evaluate) + result = expr.evaluate(context, raise_on_non_evaluable, force_evaluate) # Sometimes we may yield a list of expressions instead of # an expression containing a list, so we check this here @@ -399,7 +399,9 @@ def translate_symbol(name): return name - partial_result = self.evaluate(default_context, raise_error=False, force_evaluate=False) + partial_result = self.evaluate( + default_context, raise_on_non_evaluable=False, force_evaluate=False + ) if isinstance(partial_result, Expression): expr = expr_to_model(partial_result.expression, default_context) @@ -523,7 +525,7 @@ class ValueOrExpression(Expression, Generic[T]): def __class_getitem__(cls, typevar_values): # pylint:disable=too-many-statements def _internal_validator(value: Expression): try: - result = value.evaluate(raise_error=False) + result = value.evaluate(raise_on_non_evaluable=False) except Exception as err: raise ValueError(f"expression evaluation failed: {err}") from err pd.TypeAdapter(typevar_values).validate_python(result, context={"allow_inf_nan": True}) @@ -555,7 +557,7 @@ def _serializer(value, info) -> dict: serialized.expression = value.expression - evaluated = value.evaluate(raise_error=False) + evaluated = value.evaluate(raise_on_non_evaluable=False) if isinstance(evaluated, Number): serialized.evaluated_value = evaluated diff --git a/flow360/component/simulation/user_code/functions/math.py b/flow360/component/simulation/user_code/functions/math.py index 18480e4ef..cd64ffa54 100644 --- a/flow360/component/simulation/user_code/functions/math.py +++ b/flow360/component/simulation/user_code/functions/math.py @@ -14,7 +14,9 @@ def _convert_argument(value): # If the argument is a Variable, convert it to an expression if isinstance(value, Variable): - return Expression.model_validate(value).evaluate(raise_error=False, force_evaluate=False) + return Expression.model_validate(value).evaluate( + raise_on_non_evaluable=False, force_evaluate=False + ) return value diff --git a/tests/simulation/test_expressions.py b/tests/simulation/test_expressions.py index b4877e1e3..810ca6b22 100644 --- a/tests/simulation/test_expressions.py +++ b/tests/simulation/test_expressions.py @@ -673,7 +673,7 @@ def test_cross_function_use_case(): print("\n1 Python mode\n") a = UserVariable(name="a", value=math.cross([3, 2, 1] * u.m, solution.coordinate)) - res = a.value.evaluate(raise_error=False, force_evaluate=False) + res = a.value.evaluate(raise_on_non_evaluable=False, force_evaluate=False) assert str(res) == ( "[2 * u.m * (solution.coordinate[2]) - (1 * u.m * (solution.coordinate[1]))," "1 * u.m * (solution.coordinate[0]) - (3 * u.m * (solution.coordinate[2]))," @@ -686,7 +686,7 @@ def test_cross_function_use_case(): print("\n1.1 Python mode but arg swapped\n") a.value = math.cross(solution.coordinate, [3, 2, 1] * u.m) - res = a.value.evaluate(raise_error=False, force_evaluate=False) + res = a.value.evaluate(raise_on_non_evaluable=False, force_evaluate=False) assert str(res) == ( "[((solution.coordinate[1]) * 1) * u.m - (((solution.coordinate[2]) * 2) * u.m)," "((solution.coordinate[2]) * 3) * u.m - (((solution.coordinate[0]) * 1) * u.m)," @@ -707,7 +707,7 @@ def test_cross_function_use_case(): print("\n4 Serialized version\n") a.value = "math.cross([3, 2, 1] * u.m, solution.coordinate)" - res = a.value.evaluate(raise_error=False, force_evaluate=False) + res = a.value.evaluate(raise_on_non_evaluable=False, force_evaluate=False) assert str(res) == ( "[2 * u.m * (solution.coordinate[2]) - (1 * u.m * (solution.coordinate[1]))," "1 * u.m * (solution.coordinate[0]) - (3 * u.m * (solution.coordinate[2]))," @@ -720,7 +720,7 @@ def test_cross_function_use_case(): print("\n5 Recursive cross in Python mode\n") a.value = math.cross(math.cross([3, 2, 1] * u.m, solution.coordinate), [3, 2, 1] * u.m) - res = a.value.evaluate(raise_error=False, force_evaluate=False) + res = a.value.evaluate(raise_on_non_evaluable=False, force_evaluate=False) assert str(res) == ( "[((1 * u.m * (solution.coordinate[0]) - (3 * u.m * (solution.coordinate[2]))) * 1) * u.m - (((3 * u.m * (solution.coordinate[1]) - (2 * u.m * (solution.coordinate[0]))) * 2) * u.m)," "((3 * u.m * (solution.coordinate[1]) - (2 * u.m * (solution.coordinate[0]))) * 3) * u.m - (((2 * u.m * (solution.coordinate[2]) - (1 * u.m * (solution.coordinate[1]))) * 1) * u.m)," @@ -733,7 +733,7 @@ def test_cross_function_use_case(): print("\n6 Recursive cross in String mode\n") a.value = "math.cross(math.cross([3, 2, 1] * u.m, solution.coordinate), [3, 2, 1] * u.m)" - res = a.value.evaluate(raise_error=False, force_evaluate=False) + res = a.value.evaluate(raise_on_non_evaluable=False, force_evaluate=False) assert ( str(res) == "[(1 * u.m * solution.coordinate[0] - 3 * u.m * solution.coordinate[2]) * 1 * u.m - ((3 * u.m * solution.coordinate[1] - 2 * u.m * solution.coordinate[0]) * 2 * u.m),(3 * u.m * solution.coordinate[1] - 2 * u.m * solution.coordinate[0]) * 3 * u.m - ((2 * u.m * solution.coordinate[2] - 1 * u.m * solution.coordinate[1]) * 1 * u.m),(2 * u.m * solution.coordinate[2] - 1 * u.m * solution.coordinate[1]) * 2 * u.m - ((1 * u.m * solution.coordinate[0] - 3 * u.m * solution.coordinate[2]) * 3 * u.m)]" @@ -746,7 +746,7 @@ def test_cross_function_use_case(): print("\n7 Using other variabels in Python mode\n") b = UserVariable(name="b", value=math.cross([3, 2, 1] * u.m, solution.coordinate)) a.value = math.cross(b, [3, 2, 1] * u.m) - res = a.value.evaluate(raise_error=False, force_evaluate=False) + res = a.value.evaluate(raise_on_non_evaluable=False, force_evaluate=False) assert str(res) == ( "[((1 * u.m * (solution.coordinate[0]) - (3 * u.m * (solution.coordinate[2]))) * 1) * u.m - (((3 * u.m * (solution.coordinate[1]) - (2 * u.m * (solution.coordinate[0]))) * 2) * u.m)," "((3 * u.m * (solution.coordinate[1]) - (2 * u.m * (solution.coordinate[0]))) * 3) * u.m - (((2 * u.m * (solution.coordinate[2]) - (1 * u.m * (solution.coordinate[1]))) * 1) * u.m)," @@ -760,7 +760,7 @@ def test_cross_function_use_case(): print("\n8 Using other constant variabels in Python mode\n") b.value = [3, 2, 1] * u.m a.value = math.cross(b, solution.coordinate) - res = a.value.evaluate(raise_error=False, force_evaluate=False) + res = a.value.evaluate(raise_on_non_evaluable=False, force_evaluate=False) assert str(res) == ( "[2 * u.m * (solution.coordinate[2]) - (1 * u.m * (solution.coordinate[1]))," "1 * u.m * (solution.coordinate[0]) - (3 * u.m * (solution.coordinate[2]))," @@ -774,7 +774,7 @@ def test_cross_function_use_case(): print("\n9 Using non-unyt_array\n") b.value = [3 * u.m, 2 * u.m, 1 * u.m] a.value = math.cross(b, solution.coordinate) - res = a.value.evaluate(raise_error=False, force_evaluate=False) + res = a.value.evaluate(raise_on_non_evaluable=False, force_evaluate=False) assert str(res) == ( "[2 * u.m * (solution.coordinate[2]) - (1 * u.m * (solution.coordinate[1]))," "1 * u.m * (solution.coordinate[0]) - (3 * u.m * (solution.coordinate[2]))," From 1c677115f9f3d0b75c7ee858e9cf87b8d9feef28 Mon Sep 17 00:00:00 2001 From: Andrzej Krupka Date: Thu, 5 Jun 2025 16:32:09 +0200 Subject: [PATCH 17/19] Remove extra evaluation call in cross, reduce unnecessary parentheses --- .../simulation/blueprint/core/expressions.py | 3 +- .../simulation/blueprint/core/types.py | 1 - .../simulation/user_code/core/types.py | 14 ++- .../simulation/user_code/functions/math.py | 17 +-- tests/simulation/test_expressions.py | 102 +++++++++++------- 5 files changed, 79 insertions(+), 58 deletions(-) diff --git a/flow360/component/simulation/blueprint/core/expressions.py b/flow360/component/simulation/blueprint/core/expressions.py index ad6351f6c..10db413e0 100644 --- a/flow360/component/simulation/blueprint/core/expressions.py +++ b/flow360/component/simulation/blueprint/core/expressions.py @@ -169,8 +169,9 @@ def evaluate( ) -> Any: value = self.value.evaluate(context, raise_on_non_evaluable, force_evaluate) item = self.slice.evaluate(context, raise_on_non_evaluable, force_evaluate) - if self.ctx == "Load": + if isinstance(item, float): + item = int(item) return value[item] if self.ctx == "Store": raise NotImplementedError("Subscripted writes are not supported yet") diff --git a/flow360/component/simulation/blueprint/core/types.py b/flow360/component/simulation/blueprint/core/types.py index 87cbf2a8f..6e5572ced 100644 --- a/flow360/component/simulation/blueprint/core/types.py +++ b/flow360/component/simulation/blueprint/core/types.py @@ -28,7 +28,6 @@ def evaluate( if False, allow graceful failure or fallback behavior. force_evaluate (bool): If True, evaluate evaluable objects marked as non-evaluable, instead of returning their identifier. - inline (bool): If True, inline certain marked function calls. Returns: Any: The evaluated value. """ diff --git a/flow360/component/simulation/user_code/core/types.py b/flow360/component/simulation/user_code/core/types.py index 9fbec6844..bd30325a0 100644 --- a/flow360/component/simulation/user_code/core/types.py +++ b/flow360/component/simulation/user_code/core/types.py @@ -327,7 +327,10 @@ def _validate_expression(cls, value) -> Self: ) raise pd.ValidationError.from_exception_data("Expression type error", [details]) try: + # To ensure the expression is valid (also checks for expr_to_model(expression, default_context) + # To reduce unnecessary parentheses + expression = ast.unparse(ast.parse(expression)) except SyntaxError as s_err: handle_syntax_error(s_err, expression) except ValueError as v_err: @@ -500,12 +503,17 @@ def __rpow__(self, other): def __getitem__(self, index): (arg, _) = _convert_argument(index) - tree = ast.parse(self.expression, mode="eval") - if isinstance(tree.body, ast.List): + int_arg = None + try: + int_arg = int(arg) + except ValueError: + pass + if isinstance(tree.body, ast.List) and int_arg is not None: # Expression string with list syntax, like "[aa,bb,cc]" + # and since the index is static we can reduce it result = [ast.unparse(elt) for elt in tree.body.elts] - return Expression.model_validate(result[int(arg)]) + return Expression.model_validate(result[int_arg]) return Expression(expression=f"({self.expression})[{arg}]") def __str__(self): diff --git a/flow360/component/simulation/user_code/functions/math.py b/flow360/component/simulation/user_code/functions/math.py index cd64ffa54..719195593 100644 --- a/flow360/component/simulation/user_code/functions/math.py +++ b/flow360/component/simulation/user_code/functions/math.py @@ -6,19 +6,7 @@ from unyt import ucross, unyt_array, unyt_quantity -from flow360.component.simulation.user_code.core.types import Expression, Variable - - -def _convert_argument(value): - """Convert argument for use in builtin expression math functions""" - - # If the argument is a Variable, convert it to an expression - if isinstance(value, Variable): - return Expression.model_validate(value).evaluate( - raise_on_non_evaluable=False, force_evaluate=False - ) - - return value +from flow360.component.simulation.user_code.core.types import Expression def _handle_expression_list(value: list[Any]): @@ -40,9 +28,6 @@ def _handle_expression_list(value: list[Any]): def cross(left: VectorInputType, right: VectorInputType): """Customized Cross function to work with the `Expression` and Variables""" - left = _convert_argument(left) - right = _convert_argument(right) - # Taking advantage of unyt as much as possible: if isinstance(left, unyt_array) and isinstance(right, unyt_array): return ucross(left, right) diff --git a/tests/simulation/test_expressions.py b/tests/simulation/test_expressions.py index 810ca6b22..b3e24b5ff 100644 --- a/tests/simulation/test_expressions.py +++ b/tests/simulation/test_expressions.py @@ -189,7 +189,7 @@ class TestModel(Flow360BaseModel): model.field = ((x - 2 * x) + (x + y) / 2 - 2**x) % 4 assert isinstance(model.field, Expression) assert model.field.evaluate() == 3.5 - assert str(model.field) == "(x - (2 * x) + ((x + y) / 2) - (2 ** x)) % 4" + assert str(model.field) == "(x - 2 * x + (x + y) / 2 - 2 ** x) % 4" def test_dimensioned_expressions(): @@ -367,7 +367,7 @@ class TestModel(Flow360BaseModel): model = TestModel(field=x * u.m + solution.kOmega * u.cm) - assert str(model.field) == "x * u.m + (solution.kOmega * u.cm)" + assert str(model.field) == "x * u.m + solution.kOmega * u.cm" # Raises when trying to evaluate with a message about this variable being blacklisted with pytest.raises(ValueError): @@ -382,12 +382,12 @@ class TestModel(Flow360BaseModel): model = TestModel(field=x * u.m / u.s + 4 * x**2 * u.m / u.s) - assert str(model.field) == "(x * u.m) / u.s + (((4 * (x ** 2)) * u.m) / u.s)" + assert str(model.field) == "x * u.m / u.s + 4 * x ** 2 * u.m / u.s" serialized = model.model_dump(exclude_none=True) assert serialized["field"]["type_name"] == "expression" - assert serialized["field"]["expression"] == "(x * u.m) / u.s + (((4 * (x ** 2)) * u.m) / u.s)" + assert serialized["field"]["expression"] == "x * u.m / u.s + 4 * x ** 2 * u.m / u.s" model = TestModel(field=4 * u.m / u.s) @@ -406,14 +406,14 @@ class TestModel(Flow360BaseModel): model = { "type_name": "expression", - "expression": "(x * u.m) / u.s + (((4 * (x ** 2)) * u.m) / u.s)", + "expression": "x * u.m / u.s + 4 * x ** 2 * u.m / u.s", "evaluated_value": 68.0, "evaluated_units": "m/s", } deserialized = TestModel(field=model) - assert str(deserialized.field) == "(x * u.m) / u.s + (((4 * (x ** 2)) * u.m) / u.s)" + assert str(deserialized.field) == "x * u.m / u.s + 4 * x ** 2 * u.m / u.s" model = {"type_name": "number", "value": 4.0, "units": "m/s"} @@ -430,7 +430,7 @@ class ScalarModel(Flow360BaseModel): model = ScalarModel(scalar=x[0] + x[1] + x[2] + 1) - assert str(model.scalar) == "x[0] + (x[1]) + (x[2]) + 1" + assert str(model.scalar) == "x[0] + x[1] + x[2] + 1" assert model.scalar.evaluate() == 10 @@ -596,8 +596,8 @@ class TestModel(Flow360BaseModel): model_1 = TestModel(field=unaliased) model_2 = TestModel(field=aliased) - assert str(model_1.field) == "(x * u.m) / u.s + (((4 * (x ** 2)) * u.m) / u.s)" - assert str(model_2.field) == "(x * u.m) / u.s + (((4 * (x ** 2)) * u.m) / u.s)" + assert str(model_1.field) == "x * u.m / u.s + 4 * x ** 2 * u.m / u.s" + assert str(model_2.field) == "x * u.m / u.s + 4 * x ** 2 * u.m / u.s" def test_variable_space_init(): @@ -622,9 +622,12 @@ class TestModel(Flow360BaseModel): x = UserVariable(name="x", value=[1, 2, 3]) model = TestModel(field=math.cross(x, [3, 2, 1]) * u.m / u.s) - assert str(model.field) == "[-4 8 -4] m/s" + assert ( + str(model.field) + == "[x[1] * 1 - x[2] * 2, x[2] * 3 - x[0] * 1, x[0] * 2 - x[1] * 3] * u.m / u.s" + ) - assert (model.field == [-4, 8, -4] * u.m / u.s).all() + assert (model.field.evaluate() == [-4, 8, -4] * u.m / u.s).all() model = TestModel(field="math.cross(x, [3, 2, 1]) * u.m / u.s") assert str(model.field) == "math.cross(x, [3, 2, 1]) * u.m / u.s" @@ -645,8 +648,8 @@ class TestModel(Flow360BaseModel): field: ValueOrExpression[LengthType.Vector] = pd.Field() # From string - expr_1 = TestModel(field="math.cross([1, 2, 3], [1, 2, 3]*u.m)").field - assert str(expr_1) == "math.cross([1, 2, 3], [1, 2, 3]*u.m)" + expr_1 = TestModel(field="math.cross([1, 2, 3], [1, 2, 3] * u.m)").field + assert str(expr_1) == "math.cross([1, 2, 3], [1, 2, 3] * u.m)" # During solver translation both options are inlined the same way through partial evaluation solver_1 = expr_1.to_solver_code(params) @@ -655,8 +658,9 @@ class TestModel(Flow360BaseModel): # From python code expr_2 = TestModel(field=math.cross([1, 2, 3], solution.coordinate)).field assert ( - str(expr_2) - == "[2 * (solution.coordinate[2]) - (3 * (solution.coordinate[1])),3 * (solution.coordinate[0]) - (1 * (solution.coordinate[2])),1 * (solution.coordinate[1]) - (2 * (solution.coordinate[0]))]" + str(expr_2) == "[2 * solution.coordinate[2] - 3 * solution.coordinate[1], " + "3 * solution.coordinate[0] - 1 * solution.coordinate[2], " + "1 * solution.coordinate[1] - 2 * solution.coordinate[0]]" ) # During solver translation both options are inlined the same way through partial evaluation @@ -675,9 +679,9 @@ def test_cross_function_use_case(): a = UserVariable(name="a", value=math.cross([3, 2, 1] * u.m, solution.coordinate)) res = a.value.evaluate(raise_on_non_evaluable=False, force_evaluate=False) assert str(res) == ( - "[2 * u.m * (solution.coordinate[2]) - (1 * u.m * (solution.coordinate[1]))," - "1 * u.m * (solution.coordinate[0]) - (3 * u.m * (solution.coordinate[2]))," - "3 * u.m * (solution.coordinate[1]) - (2 * u.m * (solution.coordinate[0]))]" + "[2 * u.m * solution.coordinate[2] - 1 * u.m * solution.coordinate[1], " + "1 * u.m * solution.coordinate[0] - 3 * u.m * solution.coordinate[2], " + "3 * u.m * solution.coordinate[1] - 2 * u.m * solution.coordinate[0]]" ) assert ( a.value.to_solver_code(params) @@ -688,9 +692,9 @@ def test_cross_function_use_case(): a.value = math.cross(solution.coordinate, [3, 2, 1] * u.m) res = a.value.evaluate(raise_on_non_evaluable=False, force_evaluate=False) assert str(res) == ( - "[((solution.coordinate[1]) * 1) * u.m - (((solution.coordinate[2]) * 2) * u.m)," - "((solution.coordinate[2]) * 3) * u.m - (((solution.coordinate[0]) * 1) * u.m)," - "((solution.coordinate[0]) * 2) * u.m - (((solution.coordinate[1]) * 3) * u.m)]" + "[solution.coordinate[1] * 1 * u.m - solution.coordinate[2] * 2 * u.m, " + "solution.coordinate[2] * 3 * u.m - solution.coordinate[0] * 1 * u.m, " + "solution.coordinate[0] * 2 * u.m - solution.coordinate[1] * 3 * u.m]" ) assert ( a.value.to_solver_code(params) @@ -709,9 +713,9 @@ def test_cross_function_use_case(): a.value = "math.cross([3, 2, 1] * u.m, solution.coordinate)" res = a.value.evaluate(raise_on_non_evaluable=False, force_evaluate=False) assert str(res) == ( - "[2 * u.m * (solution.coordinate[2]) - (1 * u.m * (solution.coordinate[1]))," - "1 * u.m * (solution.coordinate[0]) - (3 * u.m * (solution.coordinate[2]))," - "3 * u.m * (solution.coordinate[1]) - (2 * u.m * (solution.coordinate[0]))]" + "[2 * u.m * solution.coordinate[2] - 1 * u.m * solution.coordinate[1], " + "1 * u.m * solution.coordinate[0] - 3 * u.m * solution.coordinate[2], " + "3 * u.m * solution.coordinate[1] - 2 * u.m * solution.coordinate[0]]" ) assert ( a.value.to_solver_code(params) @@ -722,9 +726,9 @@ def test_cross_function_use_case(): a.value = math.cross(math.cross([3, 2, 1] * u.m, solution.coordinate), [3, 2, 1] * u.m) res = a.value.evaluate(raise_on_non_evaluable=False, force_evaluate=False) assert str(res) == ( - "[((1 * u.m * (solution.coordinate[0]) - (3 * u.m * (solution.coordinate[2]))) * 1) * u.m - (((3 * u.m * (solution.coordinate[1]) - (2 * u.m * (solution.coordinate[0]))) * 2) * u.m)," - "((3 * u.m * (solution.coordinate[1]) - (2 * u.m * (solution.coordinate[0]))) * 3) * u.m - (((2 * u.m * (solution.coordinate[2]) - (1 * u.m * (solution.coordinate[1]))) * 1) * u.m)," - "((2 * u.m * (solution.coordinate[2]) - (1 * u.m * (solution.coordinate[1]))) * 2) * u.m - (((1 * u.m * (solution.coordinate[0]) - (3 * u.m * (solution.coordinate[2]))) * 3) * u.m)]" + "[(1 * u.m * solution.coordinate[0] - 3 * u.m * solution.coordinate[2]) * 1 * u.m - (3 * u.m * solution.coordinate[1] - 2 * u.m * solution.coordinate[0]) * 2 * u.m, " + "(3 * u.m * solution.coordinate[1] - 2 * u.m * solution.coordinate[0]) * 3 * u.m - (2 * u.m * solution.coordinate[2] - 1 * u.m * solution.coordinate[1]) * 1 * u.m, " + "(2 * u.m * solution.coordinate[2] - 1 * u.m * solution.coordinate[1]) * 2 * u.m - (1 * u.m * solution.coordinate[0] - 3 * u.m * solution.coordinate[2]) * 3 * u.m]" ) assert ( a.value.to_solver_code(params) @@ -736,7 +740,9 @@ def test_cross_function_use_case(): res = a.value.evaluate(raise_on_non_evaluable=False, force_evaluate=False) assert ( str(res) - == "[(1 * u.m * solution.coordinate[0] - 3 * u.m * solution.coordinate[2]) * 1 * u.m - ((3 * u.m * solution.coordinate[1] - 2 * u.m * solution.coordinate[0]) * 2 * u.m),(3 * u.m * solution.coordinate[1] - 2 * u.m * solution.coordinate[0]) * 3 * u.m - ((2 * u.m * solution.coordinate[2] - 1 * u.m * solution.coordinate[1]) * 1 * u.m),(2 * u.m * solution.coordinate[2] - 1 * u.m * solution.coordinate[1]) * 2 * u.m - ((1 * u.m * solution.coordinate[0] - 3 * u.m * solution.coordinate[2]) * 3 * u.m)]" + == "[(1 * u.m * solution.coordinate[0] - 3 * u.m * solution.coordinate[2]) * 1 * u.m - (3 * u.m * solution.coordinate[1] - 2 * u.m * solution.coordinate[0]) * 2 * u.m, " + "(3 * u.m * solution.coordinate[1] - 2 * u.m * solution.coordinate[0]) * 3 * u.m - (2 * u.m * solution.coordinate[2] - 1 * u.m * solution.coordinate[1]) * 1 * u.m, " + "(2 * u.m * solution.coordinate[2] - 1 * u.m * solution.coordinate[1]) * 2 * u.m - (1 * u.m * solution.coordinate[0] - 3 * u.m * solution.coordinate[2]) * 3 * u.m]" ) assert ( a.value.to_solver_code(params) @@ -748,9 +754,9 @@ def test_cross_function_use_case(): a.value = math.cross(b, [3, 2, 1] * u.m) res = a.value.evaluate(raise_on_non_evaluable=False, force_evaluate=False) assert str(res) == ( - "[((1 * u.m * (solution.coordinate[0]) - (3 * u.m * (solution.coordinate[2]))) * 1) * u.m - (((3 * u.m * (solution.coordinate[1]) - (2 * u.m * (solution.coordinate[0]))) * 2) * u.m)," - "((3 * u.m * (solution.coordinate[1]) - (2 * u.m * (solution.coordinate[0]))) * 3) * u.m - (((2 * u.m * (solution.coordinate[2]) - (1 * u.m * (solution.coordinate[1]))) * 1) * u.m)," - "((2 * u.m * (solution.coordinate[2]) - (1 * u.m * (solution.coordinate[1]))) * 2) * u.m - (((1 * u.m * (solution.coordinate[0]) - (3 * u.m * (solution.coordinate[2]))) * 3) * u.m)]" + "[(1 * u.m * solution.coordinate[0] - 3 * u.m * solution.coordinate[2]) * 1 * u.m - (3 * u.m * solution.coordinate[1] - 2 * u.m * solution.coordinate[0]) * 2 * u.m, " + "(3 * u.m * solution.coordinate[1] - 2 * u.m * solution.coordinate[0]) * 3 * u.m - (2 * u.m * solution.coordinate[2] - 1 * u.m * solution.coordinate[1]) * 1 * u.m, " + "(2 * u.m * solution.coordinate[2] - 1 * u.m * solution.coordinate[1]) * 2 * u.m - (1 * u.m * solution.coordinate[0] - 3 * u.m * solution.coordinate[2]) * 3 * u.m]" ) assert ( a.value.to_solver_code(params) @@ -762,9 +768,9 @@ def test_cross_function_use_case(): a.value = math.cross(b, solution.coordinate) res = a.value.evaluate(raise_on_non_evaluable=False, force_evaluate=False) assert str(res) == ( - "[2 * u.m * (solution.coordinate[2]) - (1 * u.m * (solution.coordinate[1]))," - "1 * u.m * (solution.coordinate[0]) - (3 * u.m * (solution.coordinate[2]))," - "3 * u.m * (solution.coordinate[1]) - (2 * u.m * (solution.coordinate[0]))]" + "[2 * u.m * solution.coordinate[2] - 1 * u.m * solution.coordinate[1], " + "1 * u.m * solution.coordinate[0] - 3 * u.m * solution.coordinate[2], " + "3 * u.m * solution.coordinate[1] - 2 * u.m * solution.coordinate[0]]" ) assert ( a.value.to_solver_code(params) @@ -776,11 +782,33 @@ def test_cross_function_use_case(): a.value = math.cross(b, solution.coordinate) res = a.value.evaluate(raise_on_non_evaluable=False, force_evaluate=False) assert str(res) == ( - "[2 * u.m * (solution.coordinate[2]) - (1 * u.m * (solution.coordinate[1]))," - "1 * u.m * (solution.coordinate[0]) - (3 * u.m * (solution.coordinate[2]))," - "3 * u.m * (solution.coordinate[1]) - (2 * u.m * (solution.coordinate[0]))]" + "[2 * u.m * solution.coordinate[2] - 1 * u.m * solution.coordinate[1], " + "1 * u.m * solution.coordinate[0] - 3 * u.m * solution.coordinate[2], " + "3 * u.m * solution.coordinate[1] - 2 * u.m * solution.coordinate[0]]" ) assert ( a.value.to_solver_code(params) == "std::vector({(((2 * 0.1) * solution.coordinate[2]) - ((1 * 0.1) * solution.coordinate[1])), (((1 * 0.1) * solution.coordinate[0]) - ((3 * 0.1) * solution.coordinate[2])), (((3 * 0.1) * solution.coordinate[1]) - ((2 * 0.1) * solution.coordinate[0]))})" ) + + +def test_expression_indexing(): + a = UserVariable(name="a", value=1) + b = UserVariable(name="b", value=[1, 2, 3]) + c = UserVariable(name="c", value=[3, 2, 1]) + + # Cannot simplify without non-statically evaluable index object (expression for example) + cross = math.cross(b, c) + expr = Expression.model_validate(cross[a]) + + assert ( + str(expr) + == "[b[1] * c[2] - b[2] * c[1], b[2] * c[0] - b[0] * c[2], b[0] * c[1] - b[1] * c[0]][a]" + ) + assert expr.evaluate() == 8 + + # Cannot simplify without non-statically evaluable index object (expression for example) + expr = Expression.model_validate(cross[1]) + + assert str(expr) == "b[2] * c[0] - b[0] * c[2]" + assert expr.evaluate() == 8 From 139647efcb7dbf4e64a75e0eee4e57393f2ab731 Mon Sep 17 00:00:00 2001 From: BenYuan Date: Thu, 5 Jun 2025 15:10:58 +0000 Subject: [PATCH 18/19] some more small changes --- flow360/component/simulation/blueprint/core/expressions.py | 4 ++-- flow360/component/simulation/blueprint/core/generator.py | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/flow360/component/simulation/blueprint/core/expressions.py b/flow360/component/simulation/blueprint/core/expressions.py index 10db413e0..88ab3b11d 100644 --- a/flow360/component/simulation/blueprint/core/expressions.py +++ b/flow360/component/simulation/blueprint/core/expressions.py @@ -198,7 +198,7 @@ def evaluate( raise_on_non_evaluable: bool = True, force_evaluate: bool = True, ) -> range: - return range(self.arg.evaluate(context, raise_on_non_evaluable)) + return range(self.arg.evaluate(context, raise_on_non_evaluable, force_evaluate)) def used_names(self) -> set[str]: return self.arg.used_names() @@ -338,7 +338,7 @@ def evaluate( # Create a new context for each iteration with the target variable iter_context = context.copy() iter_context.set(self.target, item) - result.append(self.element.evaluate(iter_context, raise_on_non_evaluable)) + result.append(self.element.evaluate(iter_context, raise_on_non_evaluable, force_evaluate)) return result def used_names(self) -> set[str]: diff --git a/flow360/component/simulation/blueprint/core/generator.py b/flow360/component/simulation/blueprint/core/generator.py index 77d407903..d986be49d 100644 --- a/flow360/component/simulation/blueprint/core/generator.py +++ b/flow360/component/simulation/blueprint/core/generator.py @@ -139,7 +139,7 @@ def _tuple(expr, syntax, name_translator): return f"({', '.join(elements)})" if syntax == TargetSyntax.CPP: if len(expr.elements) == 0: - return "std::vector()" + raise TypeError("Zero-length tuple is found in expression.") return f"std::vector({{{', '.join(elements)}}})" raise ValueError( @@ -157,7 +157,8 @@ def _list(expr, syntax, name_translator): return f"[{elements_str}]" if syntax == TargetSyntax.CPP: if len(expr.elements) == 0: - return "std::vector()" + raise TypeError("Zero-length list is found in expression.") + return f"std::vector({{{', '.join(elements)}}})" raise ValueError( From 4aeb42f445e00393f59e2c31dade45dd1d6bceed Mon Sep 17 00:00:00 2001 From: BenYuan Date: Thu, 5 Jun 2025 15:15:08 +0000 Subject: [PATCH 19/19] Format --- flow360/component/simulation/blueprint/core/expressions.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/flow360/component/simulation/blueprint/core/expressions.py b/flow360/component/simulation/blueprint/core/expressions.py index 88ab3b11d..453ba79ef 100644 --- a/flow360/component/simulation/blueprint/core/expressions.py +++ b/flow360/component/simulation/blueprint/core/expressions.py @@ -338,7 +338,9 @@ def evaluate( # Create a new context for each iteration with the target variable iter_context = context.copy() iter_context.set(self.target, item) - result.append(self.element.evaluate(iter_context, raise_on_non_evaluable, force_evaluate)) + result.append( + self.element.evaluate(iter_context, raise_on_non_evaluable, force_evaluate) + ) return result def used_names(self) -> set[str]: