diff --git a/flow360/__init__.py b/flow360/__init__.py index 8fc37725b..68d4d4b81 100644 --- a/flow360/__init__.py +++ b/flow360/__init__.py @@ -145,12 +145,12 @@ SI_unit_system, imperial_unit_system, ) -from flow360.component.simulation.user_code 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.variables import control_variables as control -from flow360.component.simulation.variables import solution_variables as solution from flow360.component.surface_mesh_v2 import SurfaceMeshV2 as SurfaceMesh from flow360.component.volume_mesh import VolumeMeshV2 as VolumeMesh from flow360.environment import Env @@ -277,4 +277,7 @@ "Transformation", "WallRotation", "UserVariable", + "math", + "control", + "solution", ] diff --git a/flow360/component/simulation/blueprint/__init__.py b/flow360/component/simulation/blueprint/__init__.py index 65fecdda6..30b3f33c0 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 FunctionNode from .core.types import Evaluable -__all__ = ["Function", "Evaluable", "function_to_model", "model_to_function", "expr_to_model"] +__all__ = [ + "FunctionNode", + "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..4724c0f72 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, - Expression, - ExpressionType, - List, - ListComp, - Name, - RangeCall, - Subscript, - Tuple, + BinOpNode, + CallModelNode, + ConstantNode, + ExpressionNode, + ExpressionNodeType, + ListCompNode, + ListNode, + NameNode, + RangeCallNode, + SubscriptNode, + TupleNode, ) -from .function import Function +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, - Statement, - 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, + "NameNode": NameNode, + "ConstantNode": ConstantNode, + "BinOpNode": BinOpNode, + "RangeCallNode": RangeCallNode, + "CallModelNode": CallModelNode, + "TupleNode": TupleNode, + "ListNode": ListNode, + "ListCompNode": ListCompNode, + "SubscriptNode": SubscriptNode, + "ExpressionNodeType": ExpressionNodeType, # Statement types - "Assign": Assign, - "AugAssign": AugAssign, - "IfElse": IfElse, - "ForLoop": ForLoop, - "Return": Return, - "TupleUnpack": TupleUnpack, - "StatementType": StatementType, + "AssignNode": AssignNode, + "AugAssignNode": AugAssignNode, + "IfElseNode": IfElseNode, + "ForLoopNode": ForLoopNode, + "ReturnNode": ReturnNode, + "TupleUnpackNode": TupleUnpackNode, + "StatementNodeType": StatementNodeType, # Function type - "Function": Function, + "FunctionNode": 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 - Function.model_rebuild(_types_namespace=namespace) + FunctionNode.model_rebuild(_types_namespace=namespace) # Update forward references @@ -82,25 +82,25 @@ def _model_rebuild() -> None: __all__ = [ - "Expression", - "Name", - "Constant", - "BinOp", - "RangeCall", - "CallModel", - "Tuple", - "List", - "ListComp", - "ExpressionType", - "Statement", - "Assign", - "AugAssign", - "IfElse", - "ForLoop", - "Return", - "TupleUnpack", - "StatementType", - "Function", + "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 9d45f069b..655e28e9f 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,7 +39,9 @@ def __init__( the context with. """ 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: """ @@ -69,16 +73,34 @@ 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]: + """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: """ 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. diff --git a/flow360/component/simulation/blueprint/core/expressions.py b/flow360/component/simulation/blueprint/core/expressions.py index e8091e24a..453ba79ef 100644 --- a/flow360/component/simulation/blueprint/core/expressions.py +++ b/flow360/component/simulation/blueprint/core/expressions.py @@ -9,24 +9,24 @@ from .context import EvaluationContext from .types import Evaluable -ExpressionType = Annotated[ +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"), ] -class Expression(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(Expression): +class NameNode(ExpressionNode): """ Expression representing a name qualifier """ @@ -52,22 +52,30 @@ class Name(Expression): type: Literal["Name"] = "Name" id: str - def evaluate(self, context: EvaluationContext, strict: bool) -> Any: - if strict and not context.can_evaluate(self.id): + def evaluate( + self, + context: EvaluationContext, + raise_on_non_evaluable: bool = True, + force_evaluate: bool = True, + ) -> Any: + 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) + if data_model: + return data_model.model_validate({"name": self.id, "value": context.get(self.id)}) + 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): - value = value.evaluate(context, strict) - + value = value.evaluate(context, raise_on_non_evaluable, force_evaluate) return value def used_names(self) -> set[str]: return {self.id} -class Constant(Expression): +class ConstantNode(ExpressionNode): """ Expression representing a constant numeric value """ @@ -75,24 +83,34 @@ 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_on_non_evaluable: bool = True, + force_evaluate: bool = True, + ) -> Any: # noqa: ARG002 return self.value def used_names(self) -> set[str]: return set() -class UnaryOp(Expression): +class UnaryOpNode(ExpressionNode): """ Expression representing a unary operation """ type: Literal["UnaryOp"] = "UnaryOp" op: str - operand: "ExpressionType" + operand: "ExpressionNodeType" - def evaluate(self, context: EvaluationContext, strict: bool) -> Any: - operand_val = self.operand.evaluate(context, strict) + def evaluate( + self, + context: EvaluationContext, + raise_on_non_evaluable: bool = True, + force_evaluate: bool = True, + ) -> Any: + 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}") @@ -103,19 +121,24 @@ def used_names(self) -> set[str]: return self.operand.used_names() -class BinOp(Expression): +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, strict: bool) -> Any: - left_val = self.left.evaluate(context, strict) - right_val = self.right.evaluate(context, strict) + def evaluate( + self, + context: EvaluationContext, + raise_on_non_evaluable: bool = True, + force_evaluate: bool = True, + ) -> Any: + 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}") @@ -128,21 +151,27 @@ def used_names(self) -> set[str]: return left.union(right) -class Subscript(Expression): +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, strict: bool) -> Any: - value = self.value.evaluate(context, strict) - item = self.slice.evaluate(context, strict) - + def evaluate( + self, + context: EvaluationContext, + raise_on_non_evaluable: bool = True, + force_evaluate: bool = True, + ) -> 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") @@ -155,22 +184,27 @@ def used_names(self) -> set[str]: return value.union(item) -class RangeCall(Expression): +class RangeCallNode(ExpressionNode): """ Model for something like range(). """ type: Literal["RangeCall"] = "RangeCall" - arg: "ExpressionType" + arg: "ExpressionNodeType" - def evaluate(self, context: EvaluationContext, strict: bool) -> range: - return range(self.arg.evaluate(context, strict)) + def evaluate( + self, + context: EvaluationContext, + raise_on_non_evaluable: bool = True, + force_evaluate: bool = True, + ) -> range: + return range(self.arg.evaluate(context, raise_on_non_evaluable, force_evaluate)) def used_names(self) -> set[str]: return self.arg.used_names() -class CallModel(Expression): +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. @@ -182,10 +216,15 @@ class CallModel(Expression): type: Literal["CallModel"] = "CallModel" func_qualname: str - args: list["ExpressionType"] = [] - kwargs: dict[str, "ExpressionType"] = {} - - def evaluate(self, context: EvaluationContext, strict: bool) -> Any: + args: list["ExpressionNodeType"] = [] + kwargs: dict[str, "ExpressionNodeType"] = {} + + def evaluate( + self, + context: EvaluationContext, + raise_on_non_evaluable: bool = True, + force_evaluate: bool = True, + ) -> Any: try: # Split into parts for attribute traversal parts = self.func_qualname.split(".") @@ -205,8 +244,13 @@ 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_on_non_evaluable, force_evaluate) for arg in self.args + ] + kwargs = { + k: v.evaluate(context, raise_on_non_evaluable, force_evaluate) + for k, v in self.kwargs.items() + } return func(*args, **kwargs) @@ -229,27 +273,41 @@ def used_names(self) -> set[str]: return names -class Tuple(Expression): +class TupleNode(ExpressionNode): """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) + elements: list["ExpressionNodeType"] + + def evaluate( + self, + context: EvaluationContext, + raise_on_non_evaluable: bool = True, + force_evaluate: bool = True, + ) -> tuple: + 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() -class List(Expression): +class ListNode(ExpressionNode): """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] + elements: list["ExpressionNodeType"] + + def evaluate( + self, + context: EvaluationContext, + raise_on_non_evaluable: bool = True, + force_evaluate: bool = True, + ) -> list: + return [ + elem.evaluate(context, raise_on_non_evaluable, force_evaluate) for elem in self.elements + ] def used_names(self) -> set[str]: names = set() @@ -260,22 +318,29 @@ def used_names(self) -> set[str]: return names -class ListComp(Expression): +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 - - def evaluate(self, context: EvaluationContext, strict: bool) -> list: + iter: "ExpressionNodeType" # The iterable expression + + def evaluate( + self, + context: EvaluationContext, + raise_on_non_evaluable: bool = True, + force_evaluate: bool = True, + ) -> list: result = [] - iterable = self.iter.evaluate(context, strict) + 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, strict)) + 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/function.py b/flow360/component/simulation/blueprint/core/function.py index f80b94d83..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 Function(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 c10b3a2eb..d986be49d 100644 --- a/flow360/component/simulation/blueprint/core/generator.py +++ b/flow360/component/simulation/blueprint/core/generator.py @@ -5,24 +5,25 @@ from typing import Any, Callable from flow360.component.simulation.blueprint.core.expressions import ( - BinOp, - CallModel, - Constant, - List, - ListComp, - Name, - RangeCall, - Tuple, - UnaryOp, + BinOpNode, + CallModelNode, + ConstantNode, + ListCompNode, + ListNode, + NameNode, + RangeCallNode, + SubscriptNode, + TupleNode, + UnaryOpNode, ) -from flow360.component.simulation.blueprint.core.function import Function +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 ( @@ -138,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)}}}" + raise TypeError("Zero-length tuple is found in expression.") + return f"std::vector({{{', '.join(elements)}}})" raise ValueError( f"Unsupported syntax type, available {[syntax.name for syntax in TargetSyntax]}" @@ -158,8 +157,9 @@ def _list(expr, syntax, name_translator): return f"[{elements_str}]" if syntax == TargetSyntax.CPP: if len(expr.elements) == 0: - return "{}" - return f"{{{', '.join(elements)}}}" + raise TypeError("Zero-length list is found in expression.") + + return f"std::vector({{{', '.join(elements)}}})" raise ValueError( f"Unsupported syntax type, available {[syntax.name for syntax in TargetSyntax]}" @@ -177,6 +177,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): # pylint:disable=unused-argument + return f"{expr.value.id}[{expr.slice.value}]" + + def expr_to_code( expr: Any, syntax: TargetSyntax = TargetSyntax.PYTHON, @@ -187,33 +191,36 @@ 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, SubscriptNode): + return _subscript(expr, syntax, name_translator) + raise ValueError(f"Unsupported expression type: {type(expr)}") @@ -222,12 +229,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": "-=", @@ -237,7 +244,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: @@ -245,15 +252,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 @@ -268,7 +275,9 @@ def stmt_to_code( def model_to_function( - func: Function, syntax: TargetSyntax = TargetSyntax.PYTHON, remap: dict[str, str] = None + func: FunctionNode, + 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..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, - Expression, + 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 Function +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, -) -> Function: +) -> 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 Function(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, -) -> Expression: +) -> ExpressionNode: """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..ecef64c46 100644 --- a/flow360/component/simulation/blueprint/core/statements.py +++ b/flow360/component/simulation/blueprint/core/statements.py @@ -5,47 +5,59 @@ 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", - "AugAssign", - "IfElse", - "ForLoop", - "Return", - "TupleUnpack", + "AssignNode", + "AugAssignNode", + "IfElseNode", + "ForLoopNode", + "ReturnNode", + "TupleUnpackNode", ], pd.Field(discriminator="type"), ] -class Statement(pd.BaseModel, Evaluable): +class StatementNode(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_on_non_evaluable: bool = True, + force_evaluate: bool = True, + ) -> None: raise NotImplementedError -class Assign(Statement): +class AssignNode(StatementNode): """ Represents something like 'result = '. """ type: Literal["Assign"] = "Assign" target: str - value: ExpressionType + value: ExpressionNodeType - def evaluate(self, context: EvaluationContext, strict: bool) -> None: - context.set(self.target, self.value.evaluate(context, strict)) + def evaluate( + self, + context: EvaluationContext, + raise_on_non_evaluable: bool = True, + force_evaluate: bool = True, + ) -> None: + context.set( + self.target, self.value.evaluate(context, raise_on_non_evaluable, force_evaluate) + ) -class AugAssign(Statement): +class AugAssignNode(StatementNode): """ Represents something like 'result += '. The 'op' is again the operator class name (e.g. 'Add', 'Mult', etc.). @@ -54,11 +66,16 @@ class AugAssign(Statement): type: Literal["AugAssign"] = "AugAssign" target: str op: str - value: ExpressionType - - def evaluate(self, context: EvaluationContext, strict: bool) -> None: + value: ExpressionNodeType + + def evaluate( + 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, strict) + 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": @@ -71,7 +88,7 @@ def evaluate(self, context: EvaluationContext, strict: bool) -> None: raise ValueError(f"Unsupported augmented assignment operator: {self.op}") -class IfElse(Statement): +class IfElseNode(StatementNode): """ Represents an if/else block: if condition: @@ -81,20 +98,25 @@ class IfElse(Statement): """ type: Literal["IfElse"] = "IfElse" - condition: ExpressionType - body: list["StatementType"] - orelse: list["StatementType"] - - def evaluate(self, context: EvaluationContext, strict: bool) -> None: - if self.condition.evaluate(context, strict): + condition: ExpressionNodeType + body: list["StatementNodeType"] + orelse: list["StatementNodeType"] + + def evaluate( + self, + context: EvaluationContext, + raise_on_non_evaluable: bool = True, + force_evaluate: bool = True, + ) -> None: + if self.condition.evaluate(context, raise_on_non_evaluable, force_evaluate): for stmt in self.body: - stmt.evaluate(context, strict) + stmt.evaluate(context, raise_on_non_evaluable, force_evaluate) else: for stmt in self.orelse: - stmt.evaluate(context, strict) + stmt.evaluate(context, raise_on_non_evaluable) -class ForLoop(Statement): +class ForLoopNode(StatementNode): """ Represents a for loop: for in : @@ -103,39 +125,56 @@ class ForLoop(Statement): type: Literal["ForLoop"] = "ForLoop" target: str - iter: ExpressionType - body: list["StatementType"] - - def evaluate(self, context: EvaluationContext, strict: bool) -> None: - iterable = self.iter.evaluate(context, strict) + iter: ExpressionNodeType + body: list["StatementNodeType"] + + def evaluate( + self, + context: EvaluationContext, + raise_on_non_evaluable: bool = True, + force_evaluate: bool = True, + ) -> None: + 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, strict) + stmt.evaluate(context, raise_on_non_evaluable, force_evaluate) -class Return(Statement): +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 - - def evaluate(self, context: EvaluationContext, strict: bool) -> None: - val = self.value.evaluate(context, strict) + value: ExpressionNodeType + + def evaluate( + self, + context: EvaluationContext, + raise_on_non_evaluable: bool = True, + force_evaluate: bool = True, + ) -> None: + val = self.value.evaluate(context, raise_on_non_evaluable, force_evaluate) raise ReturnValue(val) -class TupleUnpack(Statement): +class TupleUnpackNode(StatementNode): """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] + values: list[ExpressionNodeType] + + def evaluate( + self, + context: EvaluationContext, + raise_on_non_evaluable: bool = True, + force_evaluate: bool = True, + ) -> None: + evaluated_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 bd1d30b8a..6e5572ced 100644 --- a/flow360/component/simulation/blueprint/core/types.py +++ b/flow360/component/simulation/blueprint/core/types.py @@ -13,15 +13,21 @@ 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_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. - strict (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. Returns: Any: The evaluated value. """ diff --git a/flow360/component/simulation/blueprint/flow360/__init__.py b/flow360/component/simulation/blueprint/flow360/__init__.py deleted file mode 100644 index 4f83c1037..000000000 --- a/flow360/component/simulation/blueprint/flow360/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -"""Flow360 implementation of the blueprint module""" - -from .symbols import resolver diff --git a/flow360/component/simulation/blueprint/flow360/symbols.py b/flow360/component/simulation/blueprint/flow360/symbols.py deleted file mode 100644 index c34b0ec05..000000000 --- a/flow360/component/simulation/blueprint/flow360/symbols.py +++ /dev/null @@ -1,178 +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 - - -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_numpy(_: str) -> Any: - """Import and return allowed numpy callables""" - return np - - -WHITELISTED_CALLABLES = { - "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, - }, - "numpy": { - "prefix": "np.", - "callables": [ - "array", - "sin", - "tan", - "arcsin", - "arccos", - "arctan", - "dot", - "cross", - "sqrt", - ], - "evaluate": True, - }, -} - -# Define allowed modules -ALLOWED_MODULES = {"u", "np", "control", "solution"} - -ALLOWED_CALLABLES = { - **{ - f"{group['prefix']}{name}": None - for group in WHITELISTED_CALLABLES.values() - for name in group["callables"] - }, -} - -EVALUATION_BLACKLIST = { - **{ - f"{group['prefix']}{name}": None - for group in WHITELISTED_CALLABLES.values() - for name in group["callables"] - if not group["evaluate"] - }, -} - -IMPORT_FUNCTIONS = { - "u": _import_units, - "np": _import_numpy, -} - -resolver = CallableResolver( - ALLOWED_CALLABLES, ALLOWED_MODULES, IMPORT_FUNCTIONS, EVALUATION_BLACKLIST -) diff --git a/flow360/component/simulation/framework/param_utils.py b/flow360/component/simulation/framework/param_utils.py index d470cb192..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.user_code 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/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/primitives.py b/flow360/component/simulation/primitives.py index e2158887d..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.user_code 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 7a439f249..92326832d 100644 --- a/flow360/component/simulation/services.py +++ b/flow360/component/simulation/services.py @@ -56,7 +56,7 @@ u, unit_system_manager, ) -from flow360.component.simulation.user_code 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, @@ -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(strict=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(strict=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/simulation_params.py b/flow360/component/simulation/simulation_params.py index 6f9bb14fe..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.user_code 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 1b47697ac..44fda088f 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.user_code.core.types 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_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(strict=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/unit_system.py b/flow360/component/simulation/unit_system.py index f7c223504..6356349d2 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 = {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): """ @@ -491,10 +509,11 @@ 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]) + 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) @@ -524,7 +543,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 +563,10 @@ 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/variables/__init__.py b/flow360/component/simulation/user_code/__init__.py similarity index 100% rename from flow360/component/simulation/variables/__init__.py rename to flow360/component/simulation/user_code/__init__.py 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..35f95a478 --- /dev/null +++ b/flow360/component/simulation/user_code/core/context.py @@ -0,0 +1,152 @@ +"""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""" + + 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""" + # pylint:disable=import-outside-toplevel + from flow360.component.simulation import units as u + + return u + + +def _import_math(_) -> Any: + """Import and return allowed function callables""" + # pylint:disable=import-outside-toplevel, cyclic-import + from flow360.component.simulation.user_code.functions import math + + return math + + +def _import_control(_) -> Any: + """Import and return allowed control variable callables""" + # pylint:disable=import-outside-toplevel, cyclic-import + from flow360.component.simulation.user_code.variables import control + + return control + + +def _import_solution(_) -> Any: + """Import and return allowed solution variable callables""" + # pylint:disable=import-outside-toplevel, cyclic-import + 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/user_code.py b/flow360/component/simulation/user_code/core/types.py similarity index 77% rename from flow360/component/simulation/user_code.py rename to flow360/component/simulation/user_code/core/types.py index 09b29b211..bd30325a0 100644 --- a/flow360/component/simulation/user_code.py +++ b/flow360/component/simulation/user_code/core/types.py @@ -2,10 +2,12 @@ from __future__ import annotations +import ast 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 from pydantic import BeforeValidator, Discriminator, PlainSerializer, Tag from pydantic_core import InitErrorDetails, core_schema @@ -15,27 +17,45 @@ 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.types import TargetSyntax -from flow360.component.simulation.blueprint.flow360.symbols import resolver from flow360.component.simulation.framework.base_model import Flow360BaseModel +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_variables: 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) + 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 != ""] +def __soft_fail_sub__(self, other): + if not isinstance(other, Expression) and not isinstance(other, Variable): + return np.ndarray.__sub__(self, other) + return NotImplemented + + +def __soft_fail_mul__(self, other): + if not isinstance(other, Expression) and not isinstance(other, Variable): + return np.ndarray.__mul__(self, other) + return NotImplemented + + +def __soft_fail_truediv__(self, other): + if not isinstance(other, Expression) and not isinstance(other, Variable): + return np.ndarray.__truediv__(self, other) + return NotImplemented + + +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): @@ -45,20 +65,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: @@ -85,10 +105,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) @@ -230,7 +250,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 @@ -242,7 +262,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]: @@ -267,36 +287,12 @@ class SolverVariable(Variable): @classmethod def update_context(cls, value): """Auto updating context when new variable is declared""" - _global_ctx.set(value.name, value.value) - _solver_variables[value.name] = ( - value.solver_name if value.solver_name is not None else value.name - ) + default_context.set(value.name, value.value, SolverVariable) + if value.solver_name: + default_context.set_alias(value.name, value.solver_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. @@ -331,9 +327,12 @@ def _validate_expression(cls, value) -> Self: ) raise pd.ValidationError.from_exception_data("Expression type error", [details]) try: - expr_to_model(expression, _global_ctx) + # 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) + 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]) @@ -341,26 +340,44 @@ def _validate_expression(cls, value) -> Self: return {"expression": expression} def evaluate( - self, context: EvaluationContext = None, strict: bool = True - ) -> Union[float, list, unyt_array]: + self, + context: EvaluationContext = None, + 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 = _global_ctx + context = default_context expr = expr_to_model(self.expression, context) - result = expr.evaluate(context, strict) + 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 + # 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): """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] @@ -370,14 +387,10 @@ def to_solver_code(self, params): """Convert to solver readable code.""" def translate_symbol(name): - if name in _solver_variables: - return _solver_variables[name] + alias = default_context.get_alias(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) + if alias: + return alias match = re.fullmatch("u\\.(.+)", name) @@ -389,9 +402,16 @@ 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_on_non_evaluable=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) @@ -481,8 +501,19 @@ 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") + 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(expression=f"({self.expression})[{arg}]") def __str__(self): @@ -502,43 +533,31 @@ 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_on_non_evaluable=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)] 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: # 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) + return value.value + if value.type_name == "expression": + return expr_type(expression=value.expression) + return value def _serializer(value, info) -> dict: if isinstance(value, Expression): @@ -546,7 +565,7 @@ def _serializer(value, info) -> dict: serialized.expression = value.expression - evaluated = value.evaluate(strict=False) + evaluated = value.evaluate(raise_on_non_evaluable=False) if isinstance(evaluated, Number): serialized.evaluated_value = evaluated @@ -573,7 +592,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 @@ -589,7 +608,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/user_code/core/utils.py b/flow360/component/simulation/user_code/core/utils.py new file mode 100644 index 000000000..a287afae6 --- /dev/null +++ b/flow360/component/simulation/user_code/core/utils.py @@ -0,0 +1,47 @@ +"""Utility functions for the user code module""" + +import re + +import pydantic as pd +from pydantic_core import InitErrorDetails + + +def is_number_string(s: str) -> bool: + """Check if the string represents a single scalar number""" + try: + float(s) + return True + except ValueError: + return False + + +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) + return [part for part in result if part != ""] + + +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: + 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..719195593 --- /dev/null +++ b/flow360/component/simulation/user_code/functions/math.py @@ -0,0 +1,42 @@ +""" +Math.h for Flow360 Expression system +""" + +from typing import Any, Union + +from unyt import ucross, unyt_array, unyt_quantity + +from flow360.component.simulation.user_code.core.types import Expression + + +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""" + # Taking advantage of unyt as much as possible: + 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], + ] + + return _handle_expression_list(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/variables/control_variables.py b/flow360/component/simulation/user_code/variables/control.py similarity index 96% rename from flow360/component/simulation/variables/control_variables.py rename to flow360/component/simulation/user_code/variables/control.py index d3c1b1929..4639a8972 100644 --- a/flow360/component/simulation/variables/control_variables.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.user_code import SolverVariable +from flow360.component.simulation.user_code.core.types import SolverVariable # pylint:disable=no-member MachRef = SolverVariable( diff --git a/flow360/component/simulation/variables/solution_variables.py b/flow360/component/simulation/user_code/variables/solution.py similarity index 88% rename from flow360/component/simulation/variables/solution_variables.py rename to flow360/component/simulation/user_code/variables/solution.py index e97f28691..289dc7649 100644 --- a/flow360/component/simulation/variables/solution_variables.py +++ b/flow360/component/simulation/user_code/variables/solution.py @@ -1,7 +1,10 @@ """Solution variables of Flow360""" -from flow360.component.simulation.user_code import SolverVariable +import unyt as u +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( @@ -34,7 +37,16 @@ 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 + +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") diff --git a/flow360/component/simulation/validation/validation_simulation_params.py b/flow360/component/simulation/validation/validation_simulation_params.py index 7f7556d9c..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.user_code 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/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/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", diff --git a/tests/simulation/test_expressions.py b/tests/simulation/test_expressions.py index f7dd60662..b3e24b5ff 100644 --- a/tests/simulation/test_expressions.py +++ b/tests/simulation/test_expressions.py @@ -10,20 +10,15 @@ 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.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.primitives import GenericVolume, Surface +from flow360.component.simulation.services import ValidationCalledBy, validate_model from flow360.component.simulation.unit_system import ( AbsoluteTemperatureType, AngleType, @@ -50,11 +45,12 @@ VelocityType, ViscosityType, ) -from flow360.component.simulation.user_code 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) @@ -189,17 +185,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(): @@ -377,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): @@ -392,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) @@ -416,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"} @@ -432,94 +422,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() @@ -528,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 @@ -649,11 +551,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(): @@ -694,22 +596,219 @@ 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(): # 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" ) - from flow360.component.simulation.user_code import _global_ctx, _user_variables assert errors is None evaluated = params.reference_geometry.area.evaluate() assert evaluated == 1.0 * u.m**2 + + +def test_cross_product(): + class TestModel(Flow360BaseModel): + field: ValueOrExpression[VelocityType.Vector] = 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" + ) + + 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" + + 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], [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) + 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]]" + ) + + # During solver translation both options are inlined the same way through partial evaluation + solver_2 = expr_2.to_solver_code(params) + print(solver_2) + + +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_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]]" + ) + 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) + 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]" + ) + 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) + assert all(a.value == [0, -1, 2] * u.m * 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") + 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]]" + ) + 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) + 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]" + ) + 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_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]" + ) + 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)) + 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]" + ) + 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_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]]" + ) + 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_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]]" + ) + 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