diff --git a/flow360/component/simulation/blueprint/__init__.py b/flow360/component/simulation/blueprint/__init__.py index 94cb27d9d..65fecdda6 100644 --- a/flow360/component/simulation/blueprint/__init__.py +++ b/flow360/component/simulation/blueprint/__init__.py @@ -1,8 +1,12 @@ """Blueprint: Safe function serialization and visual programming integration.""" -from .codegen.generator import model_to_function -from .codegen.parser import expr_to_model, function_to_model +from flow360.component.simulation.blueprint.core.generator import model_to_function +from flow360.component.simulation.blueprint.core.parser import ( + expr_to_model, + function_to_model, +) + from .core.function import Function from .core.types import Evaluable -__all__ = ["Function", "function_to_model", "model_to_function", "expr_to_model"] +__all__ = ["Function", "Evaluable", "function_to_model", "model_to_function", "expr_to_model"] diff --git a/flow360/component/simulation/blueprint/codegen/__init__.py b/flow360/component/simulation/blueprint/codegen/__init__.py deleted file mode 100644 index 319b883fb..000000000 --- a/flow360/component/simulation/blueprint/codegen/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -from .generator import expr_to_code, model_to_function, stmt_to_code -from .parser import function_to_model - -__all__ = ["expr_to_code", "stmt_to_code", "model_to_function", "function_to_model"] diff --git a/flow360/component/simulation/blueprint/core/__init__.py b/flow360/component/simulation/blueprint/core/__init__.py index d81c36bed..ec4e79641 100644 --- a/flow360/component/simulation/blueprint/core/__init__.py +++ b/flow360/component/simulation/blueprint/core/__init__.py @@ -15,6 +15,8 @@ Tuple, ) from .function import Function +from .generator import expr_to_code, model_to_function, stmt_to_code +from .parser import function_to_model from .statements import ( Assign, AugAssign, @@ -25,7 +27,7 @@ StatementType, TupleUnpack, ) -from .types import Evaluable +from .types import Evaluable, TargetSyntax def _model_rebuild() -> None: @@ -101,4 +103,9 @@ def _model_rebuild() -> None: "Function", "EvaluationContext", "ReturnValue", + "Evaluable", + "expr_to_code", + "stmt_to_code", + "model_to_function", + "function_to_model", ] diff --git a/flow360/component/simulation/blueprint/core/context.py b/flow360/component/simulation/blueprint/core/context.py index 0a9499c9f..9d45f069b 100644 --- a/flow360/component/simulation/blueprint/core/context.py +++ b/flow360/component/simulation/blueprint/core/context.py @@ -1,6 +1,8 @@ +"""Evaluation context that contains references to known symbols""" + from typing import Any, Optional -from .resolver import CallableResolver +from flow360.component.simulation.blueprint.core.resolver import CallableResolver class ReturnValue(Exception): @@ -17,15 +19,44 @@ def __init__(self, value: Any): class EvaluationContext: """ Manages variable scope and access during function evaluation. + + This class stores named values and optionally resolves names through a + `CallableResolver` when not already defined in the context. """ def __init__( self, resolver: CallableResolver, initial_values: Optional[dict[str, Any]] = None ) -> None: + """ + Initialize the evaluation context. + + Args: + resolver (CallableResolver): A resolver used to look up callable names + and constants if not explicitly defined. + initial_values (Optional[dict[str, Any]]): Initial variable values to populate + the context with. + """ self._values = initial_values or {} self._resolver = resolver def get(self, name: str, resolve: bool = True) -> Any: + """ + Retrieve a value by name from the context. + + If the name is not explicitly defined and `resolve` is True, + attempt to resolve it using the resolver. + + Args: + name (str): The variable or callable name to retrieve. + resolve (bool): Whether to attempt to resolve the name if it's undefined. + + Returns: + Any: The corresponding value. + + Raises: + NameError: If the name is not found and cannot be resolved. + ValueError: If resolution is disabled and the name is undefined. + """ if name not in self._values: # Try loading from builtin callables/constants if possible try: @@ -39,14 +70,48 @@ def get(self, name: str, resolve: bool = True) -> Any: return self._values[name] def set(self, name: str, value: Any) -> None: + """ + Assign a value to a name in the context. + + Args: + name (str): The variable name to set. + value (Any): The value to assign. + """ self._values[name] = value def resolve(self, name): + """ + Resolve a name using the provided resolver. + + Args: + name (str): The name to resolve. + + Returns: + Any: The resolved callable or constant. + + Raises: + ValueError: If the name cannot be resolved by the resolver. + """ return self._resolver.get_allowed_callable(name) def can_evaluate(self, name) -> bool: + """ + Check if the name can be evaluated via the resolver. + + Args: + name (str): The name to check. + + Returns: + bool: True if the name is allowed and resolvable, False otherwise. + """ return self._resolver.can_evaluate(name) def copy(self) -> "EvaluationContext": - """Create a copy of the current context.""" + """ + Create a copy of the current context. + + Returns: + EvaluationContext: A new context instance with the same resolver and a copy + of the current variable values. + """ return EvaluationContext(self._resolver, dict(self._values)) diff --git a/flow360/component/simulation/blueprint/core/expressions.py b/flow360/component/simulation/blueprint/core/expressions.py index 26b299697..e8091e24a 100644 --- a/flow360/component/simulation/blueprint/core/expressions.py +++ b/flow360/component/simulation/blueprint/core/expressions.py @@ -1,11 +1,16 @@ +"""Data models and evaluator functions for rvalue expression elements""" + +import abc from typing import Annotated, Any, Literal, Union import pydantic as pd +from ..utils.operators import BINARY_OPERATORS, UNARY_OPERATORS from .context import EvaluationContext from .types import Evaluable ExpressionType = Annotated[ + # pylint: disable=duplicate-code Union[ "Name", "Constant", @@ -21,19 +26,29 @@ ] -class Expression(pd.BaseModel): - """ - Base class for expressions (like x > 3, range(n), etc.). +class Expression(pd.BaseModel, Evaluable, metaclass=abc.ABCMeta): """ + Base class for expressions (like `x > 3`, `range(n)`, etc.). - def evaluate(self, context: EvaluationContext, strict: bool) -> Any: - raise NotImplementedError + Subclasses must implement the `evaluate` and `used_names` methods + to support context-based evaluation and variable usage introspection. + """ def used_names(self) -> set[str]: + """ + Return a set of variable names used by the expression. + + Returns: + set[str]: A set of strings representing variable names used in the expression. + """ raise NotImplementedError -class Name(Expression, Evaluable): +class Name(Expression): + """ + Expression representing a name qualifier + """ + type: Literal["Name"] = "Name" id: str @@ -53,6 +68,10 @@ def used_names(self) -> set[str]: class Constant(Expression): + """ + Expression representing a constant numeric value + """ + type: Literal["Constant"] = "Constant" value: Any @@ -64,13 +83,15 @@ def used_names(self) -> set[str]: class UnaryOp(Expression): + """ + Expression representing a unary operation + """ + type: Literal["UnaryOp"] = "UnaryOp" op: str operand: "ExpressionType" def evaluate(self, context: EvaluationContext, strict: bool) -> Any: - from ..utils.operators import UNARY_OPERATORS - operand_val = self.operand.evaluate(context, strict) if self.op not in UNARY_OPERATORS: @@ -84,8 +105,7 @@ def used_names(self) -> set[str]: class BinOp(Expression): """ - For simplicity, we use the operator's class name as a string - (e.g. 'Add', 'Sub', 'Gt', etc.). + Expression representing a binary operation """ type: Literal["BinOp"] = "BinOp" @@ -94,8 +114,6 @@ class BinOp(Expression): right: "ExpressionType" def evaluate(self, context: EvaluationContext, strict: bool) -> Any: - from ..utils.operators import BINARY_OPERATORS - left_val = self.left.evaluate(context, strict) right_val = self.right.evaluate(context, strict) @@ -111,6 +129,9 @@ def used_names(self) -> set[str]: class Subscript(Expression): + """ + Expression representing an iterable object subscript + """ type: Literal["Subscript"] = "Subscript" value: "ExpressionType" @@ -123,9 +144,11 @@ def evaluate(self, context: EvaluationContext, strict: bool) -> Any: if self.ctx == "Load": return value[item] - elif self.ctx == "Store": + if self.ctx == "Store": raise NotImplementedError("Subscripted writes are not supported yet") + raise ValueError(f"Invalid subscript context {self.ctx}") + def used_names(self) -> set[str]: value = self.value.used_names() item = self.slice.used_names() @@ -163,21 +186,6 @@ class CallModel(Expression): kwargs: dict[str, "ExpressionType"] = {} def evaluate(self, context: EvaluationContext, strict: bool) -> Any: - """Evaluate the function call in the given context. - - Handles both direct function calls and method calls by properly resolving - the function qualname through the context and whitelisting system. - - Args: - context: The execution context containing variable bindings - - Returns: - The result of the function call - - Raises: - ValueError: If the function is not allowed or evaluation fails - AttributeError: If an intermediate attribute access fails - """ try: # Split into parts for attribute traversal parts = self.func_qualname.split(".") @@ -215,7 +223,7 @@ def used_names(self) -> set[str]: for arg in self.args: names = names.union(arg.used_names()) - for keyword, arg in self.kwargs.items(): + for _, arg in self.kwargs.items(): names = names.union(arg.used_names()) return names diff --git a/flow360/component/simulation/blueprint/core/function.py b/flow360/component/simulation/blueprint/core/function.py index 88feb55a9..f80b94d83 100644 --- a/flow360/component/simulation/blueprint/core/function.py +++ b/flow360/component/simulation/blueprint/core/function.py @@ -1,3 +1,5 @@ +"""Data models and evaluator functions for full Python function definitions""" + from typing import Any import pydantic as pd @@ -18,21 +20,18 @@ def name(arg1, arg2, ...): defaults: dict[str, Any] body: list[StatementType] - def __call__(self, *call_args: Any) -> Any: - # Create empty context first - context = EvaluationContext() - + def __call__(self, context: EvaluationContext, *call_args: Any) -> Any: # Add default values for arg_name, default_val in self.defaults.items(): - context.set(arg_name, default_val) + self.context.set(arg_name, default_val) # Add call arguments for arg_name, arg_val in zip(self.args, call_args, strict=False): - context.set(arg_name, arg_val) + self.context.set(arg_name, arg_val) try: for stmt in self.body: - stmt.evaluate(context) + stmt.evaluate(self.context) except ReturnValue as rv: return rv.value diff --git a/flow360/component/simulation/blueprint/codegen/generator.py b/flow360/component/simulation/blueprint/core/generator.py similarity index 77% rename from flow360/component/simulation/blueprint/codegen/generator.py rename to flow360/component/simulation/blueprint/core/generator.py index 3f82a4c3d..c10b3a2eb 100644 --- a/flow360/component/simulation/blueprint/codegen/generator.py +++ b/flow360/component/simulation/blueprint/core/generator.py @@ -1,7 +1,10 @@ -import functools +"""Code generator for the blueprint module, supports python and C++ syntax for now""" + +# pylint: disable=too-many-return-statements + from typing import Any, Callable -from ..core.expressions import ( +from flow360.component.simulation.blueprint.core.expressions import ( BinOp, CallModel, Constant, @@ -12,10 +15,20 @@ Tuple, UnaryOp, ) -from ..core.function import Function -from ..core.statements import Assign, AugAssign, ForLoop, IfElse, Return, TupleUnpack -from ..utils.operators import BINARY_OPERATORS, UNARY_OPERATORS -from ..utils.types import TargetSyntax +from flow360.component.simulation.blueprint.core.function import Function +from flow360.component.simulation.blueprint.core.statements import ( + Assign, + AugAssign, + ForLoop, + IfElse, + Return, + TupleUnpack, +) +from flow360.component.simulation.blueprint.core.types import TargetSyntax +from flow360.component.simulation.blueprint.utils.operators import ( + BINARY_OPERATORS, + UNARY_OPERATORS, +) def _indent(code: str, level: int = 1) -> str: @@ -24,42 +37,29 @@ def _indent(code: str, level: int = 1) -> str: return "\n".join(spaces + line if line else line for line in code.split("\n")) -def check_syntax_type(func): - @functools.wraps(func) - def wrapper(*args, **kwargs): - result = func(*args, **kwargs) - if result is None: - raise ValueError( - f"Unsupported syntax type, available {[syntax.name for syntax in TargetSyntax]}" - ) - return result - - return wrapper - - -@check_syntax_type def _empty(syntax): if syntax == TargetSyntax.PYTHON: return "None" - elif syntax == TargetSyntax.CPP: + if syntax == TargetSyntax.CPP: return "nullptr" + raise ValueError( + f"Unsupported syntax type, available {[syntax.name for syntax in TargetSyntax]}" + ) + -@check_syntax_type def _name(expr, name_translator): if name_translator: return name_translator(expr.id) return expr.id -@check_syntax_type def _constant(expr): if isinstance(expr.value, str): return f"'{expr.value}'" return str(expr.value) -@check_syntax_type def _unary_op(expr, syntax, name_translator): op_info = UNARY_OPERATORS[expr.op] @@ -68,7 +68,6 @@ def _unary_op(expr, syntax, name_translator): return f"{op_info.symbol}{arg}" -@check_syntax_type def _binary_op(expr, syntax, name_translator): left = expr_to_code(expr.left, syntax, name_translator) right = expr_to_code(expr.right, syntax, name_translator) @@ -86,7 +85,6 @@ def _binary_op(expr, syntax, name_translator): return f"({left} {op_info.symbol} {right})" -@check_syntax_type def _range_call(expr, syntax, name_translator): if syntax == TargetSyntax.PYTHON: arg = expr_to_code(expr.arg, syntax, name_translator) @@ -95,7 +93,6 @@ def _range_call(expr, syntax, name_translator): raise ValueError("Range calls are only supported for Python target syntax") -@check_syntax_type def _call_model(expr, syntax, name_translator): if syntax == TargetSyntax.PYTHON: args = [] @@ -115,7 +112,7 @@ def _call_model(expr, syntax, name_translator): kwargs_str = ", ".join(kwargs_parts) all_args = ", ".join(x for x in [args_str, kwargs_str] if x) return f"{expr.func_qualname}({all_args})" - elif syntax == TargetSyntax.CPP: + if syntax == TargetSyntax.CPP: args = [] for arg in expr.args: val_str = expr_to_code(arg, syntax, name_translator) @@ -125,26 +122,32 @@ def _call_model(expr, syntax, name_translator): raise ValueError("Named arguments are not supported in C++ syntax") return f"{expr.func_qualname}({args_str})" + raise ValueError( + f"Unsupported syntax type, available {[syntax.name for syntax in TargetSyntax]}" + ) + -@check_syntax_type def _tuple(expr, syntax, name_translator): elements = [expr_to_code(e, syntax, name_translator) for e in expr.elements] if syntax == TargetSyntax.PYTHON: if len(expr.elements) == 0: return "()" - elif len(expr.elements) == 1: + if len(expr.elements) == 1: return f"({elements[0]},)" return f"({', '.join(elements)})" - elif syntax == TargetSyntax.CPP: + if syntax == TargetSyntax.CPP: if len(expr.elements) == 0: return "{}" - elif len(expr.elements) == 1: + if len(expr.elements) == 1: return f"{{{elements[0]}}}" return f"{{{', '.join(elements)}}}" + raise ValueError( + f"Unsupported syntax type, available {[syntax.name for syntax in TargetSyntax]}" + ) + -@check_syntax_type def _list(expr, syntax, name_translator): elements = [expr_to_code(e, syntax, name_translator) for e in expr.elements] @@ -153,11 +156,15 @@ def _list(expr, syntax, name_translator): return "[]" elements_str = ", ".join(elements) return f"[{elements_str}]" - elif syntax == TargetSyntax.CPP: + if syntax == TargetSyntax.CPP: if len(expr.elements) == 0: return "{}" return f"{{{', '.join(elements)}}}" + raise ValueError( + f"Unsupported syntax type, available {[syntax.name for syntax in TargetSyntax]}" + ) + def _list_comp(expr, syntax, name_translator): if syntax == TargetSyntax.PYTHON: @@ -183,45 +190,44 @@ def expr_to_code( if isinstance(expr, Name): return _name(expr, name_translator) - elif isinstance(expr, Constant): + if isinstance(expr, Constant): return _constant(expr) - elif isinstance(expr, UnaryOp): + if isinstance(expr, UnaryOp): return _unary_op(expr, syntax, name_translator) - elif isinstance(expr, BinOp): + if isinstance(expr, BinOp): return _binary_op(expr, syntax, name_translator) - elif isinstance(expr, RangeCall): + if isinstance(expr, RangeCall): return _range_call(expr, syntax, name_translator) - elif isinstance(expr, CallModel): + if isinstance(expr, CallModel): return _call_model(expr, syntax, name_translator) - elif isinstance(expr, Tuple): + if isinstance(expr, Tuple): return _tuple(expr, syntax, name_translator) - elif isinstance(expr, List): + if isinstance(expr, List): return _list(expr, syntax, name_translator) - elif isinstance(expr, ListComp): + if isinstance(expr, ListComp): return _list_comp(expr, syntax, name_translator) - else: - raise ValueError(f"Unsupported expression type: {type(expr)}") + raise ValueError(f"Unsupported expression type: {type(expr)}") def stmt_to_code( stmt: Any, syntax: TargetSyntax = TargetSyntax.PYTHON, remap: dict[str, str] = None ) -> str: + """Convert a statement model back to source code.""" if syntax == TargetSyntax.PYTHON: - """Convert a statement model back to source code.""" if isinstance(stmt, Assign): if stmt.target == "_": # Expression statement return expr_to_code(stmt.value) return f"{stmt.target} = {expr_to_code(stmt.value, syntax, remap)}" - elif isinstance(stmt, AugAssign): + if isinstance(stmt, AugAssign): op_map = { "Add": "+=", "Sub": "-=", @@ -231,7 +237,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)}" - elif isinstance(stmt, IfElse): + if isinstance(stmt, IfElse): 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: @@ -239,25 +245,24 @@ def stmt_to_code( code.append(_indent("\n".join(stmt_to_code(s, syntax, remap) for s in stmt.orelse))) return "\n".join(code) - elif isinstance(stmt, ForLoop): + if isinstance(stmt, ForLoop): 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) - elif isinstance(stmt, Return): + if isinstance(stmt, Return): return f"return {expr_to_code(stmt.value, syntax, remap)}" - elif isinstance(stmt, TupleUnpack): + if isinstance(stmt, TupleUnpack): targets = ", ".join(stmt.targets) if len(stmt.values) == 1: # Single expression that evaluates to a tuple return f"{targets} = {expr_to_code(stmt.values[0], syntax, remap)}" - else: - # Multiple expressions - values = ", ".join(expr_to_code(v, syntax, remap) for v in stmt.values) - return f"{targets} = {values}" - else: - raise ValueError(f"Unsupported statement type: {type(stmt)}") + # Multiple expressions + values = ", ".join(expr_to_code(v, syntax, remap) for v in stmt.values) + return f"{targets} = {values}" + + raise ValueError(f"Unsupported statement type: {type(stmt)}") raise NotImplementedError("Statement translation is not available for other syntax types yet") @@ -265,9 +270,8 @@ def stmt_to_code( def model_to_function( func: Function, syntax: TargetSyntax = TargetSyntax.PYTHON, remap: dict[str, str] = None ) -> str: + """Convert a Function model back to source code.""" if syntax == TargetSyntax.PYTHON: - """Convert a Function model back to source code.""" - # Build the function signature args_with_defaults = [] for arg in func.args: if arg in func.defaults: diff --git a/flow360/component/simulation/blueprint/codegen/parser.py b/flow360/component/simulation/blueprint/core/parser.py similarity index 80% rename from flow360/component/simulation/blueprint/codegen/parser.py rename to flow360/component/simulation/blueprint/core/parser.py index 82900dbbb..8281becf1 100644 --- a/flow360/component/simulation/blueprint/codegen/parser.py +++ b/flow360/component/simulation/blueprint/core/parser.py @@ -1,14 +1,37 @@ +"""Python code parser using the AST module""" + +# pylint: disable=too-many-return-statements, too-many-branches + import ast import inspect from collections.abc import Callable -from typing import Any, Optional, Union - -from ..core.context import EvaluationContext -from ..core.expressions import BinOp, CallModel, Constant, Expression -from ..core.expressions import List as ListExpr -from ..core.expressions import ListComp, Name, RangeCall, Subscript, Tuple, UnaryOp -from ..core.function import Function -from ..core.statements import Assign, AugAssign, ForLoop, IfElse, Return, TupleUnpack +from typing import Any, Union + +from flow360.component.simulation.blueprint.core.context import EvaluationContext +from flow360.component.simulation.blueprint.core.expressions import ( + BinOp, + CallModel, + Constant, + Expression, +) +from flow360.component.simulation.blueprint.core.expressions import List as ListExpr +from flow360.component.simulation.blueprint.core.expressions import ( + ListComp, + Name, + RangeCall, + Subscript, + Tuple, + UnaryOp, +) +from flow360.component.simulation.blueprint.core.function import Function +from flow360.component.simulation.blueprint.core.statements import ( + Assign, + AugAssign, + ForLoop, + IfElse, + Return, + TupleUnpack, +) def parse_expr(node: ast.AST, ctx: EvaluationContext) -> Any: @@ -16,13 +39,12 @@ def parse_expr(node: ast.AST, ctx: EvaluationContext) -> Any: if isinstance(node, ast.Name): return Name(id=node.id) - elif isinstance(node, ast.Constant): + if isinstance(node, ast.Constant): if hasattr(node, "value"): return Constant(value=node.value) - else: - return Constant(value=node.s) + return Constant(value=node.s) - elif isinstance(node, ast.Attribute): + if isinstance(node, ast.Attribute): # Handle attribute access (e.g., td.inf) parts = [] current = node @@ -33,20 +55,19 @@ def parse_expr(node: ast.AST, ctx: EvaluationContext) -> Any: parts.append(current.id) # Create a Name node with the full qualified name return Name(id=".".join(reversed(parts))) - else: - raise ValueError(f"Unsupported attribute access: {ast.dump(node)}") + raise ValueError(f"Unsupported attribute access: {ast.dump(node)}") - elif isinstance(node, ast.UnaryOp): + if isinstance(node, ast.UnaryOp): return UnaryOp(op=type(node.op).__name__, operand=parse_expr(node.operand, ctx)) - elif isinstance(node, ast.BinOp): + if isinstance(node, ast.BinOp): return BinOp( op=type(node.op).__name__, left=parse_expr(node.left, ctx), right=parse_expr(node.right, ctx), ) - elif isinstance(node, ast.Compare): + if isinstance(node, ast.Compare): if len(node.ops) > 1 or len(node.comparators) > 1: raise ValueError("Only single comparisons are supported") return BinOp( @@ -55,14 +76,14 @@ def parse_expr(node: ast.AST, ctx: EvaluationContext) -> Any: right=parse_expr(node.comparators[0], ctx), ) - elif isinstance(node, ast.Subscript): + if isinstance(node, ast.Subscript): return Subscript( value=parse_expr(node.value, ctx), slice=parse_expr(node.slice, ctx), ctx=type(node.ctx).__name__, ) - elif isinstance(node, ast.Call): + 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)) @@ -98,13 +119,13 @@ def parse_expr(node: ast.AST, ctx: EvaluationContext) -> Any: kwargs=kwargs, ) - elif isinstance(node, ast.Tuple): + if isinstance(node, ast.Tuple): return Tuple(elements=[parse_expr(elt, ctx) for elt in node.elts]) - elif isinstance(node, ast.List): + if isinstance(node, ast.List): return ListExpr(elements=[parse_expr(elt, ctx) for elt in node.elts]) - elif isinstance(node, ast.ListComp): + if isinstance(node, ast.ListComp): if len(node.generators) != 1: raise ValueError("Only single-generator list comprehensions are supported") gen = node.generators[0] @@ -118,8 +139,7 @@ def parse_expr(node: ast.AST, ctx: EvaluationContext) -> Any: iter=parse_expr(gen.iter, ctx), ) - else: - raise ValueError(f"Unsupported expression type: {type(node)}") + raise ValueError(f"Unsupported expression type: {type(node)}") def parse_stmt(node: ast.AST, ctx: EvaluationContext) -> Any: @@ -131,19 +151,18 @@ def parse_stmt(node: ast.AST, ctx: EvaluationContext) -> Any: if isinstance(target, ast.Name): return Assign(target=target.id, value=parse_expr(node.value, ctx)) - elif isinstance(target, ast.Tuple): + 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) - else: - return TupleUnpack(targets=targets, values=[parse_expr(node.value, ctx)]) - else: - raise ValueError(f"Unsupported assignment target: {type(target)}") + return TupleUnpack(targets=targets, values=[parse_expr(node.value, ctx)]) + + raise ValueError(f"Unsupported assignment target: {type(target)}") - elif isinstance(node, ast.AugAssign): + if isinstance(node, ast.AugAssign): if not isinstance(node.target, ast.Name): raise ValueError("Only simple names supported in augmented assignment") return AugAssign( @@ -152,18 +171,18 @@ def parse_stmt(node: ast.AST, ctx: EvaluationContext) -> Any: value=parse_expr(node.value, ctx), ) - elif isinstance(node, ast.Expr): + if isinstance(node, ast.Expr): # For expression statements, we use "_" as a dummy target return Assign(target="_", value=parse_expr(node.value, ctx)) - elif isinstance(node, ast.If): + if isinstance(node, ast.If): return IfElse( 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 [], ) - elif isinstance(node, ast.For): + if isinstance(node, ast.For): if not isinstance(node.target, ast.Name): raise ValueError("Only simple names supported as loop targets") return ForLoop( @@ -172,27 +191,24 @@ def parse_stmt(node: ast.AST, ctx: EvaluationContext) -> Any: body=[parse_stmt(stmt, ctx) for stmt in node.body], ) - elif isinstance(node, ast.Return): + 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)) - else: - raise ValueError(f"Unsupported statement type: {type(node)}") + raise ValueError(f"Unsupported statement type: {type(node)}") def function_to_model( source: Union[str, Callable[..., Any]], - ctx: Optional[EvaluationContext] = None, + ctx: EvaluationContext, ) -> Function: """Parse a Python function definition into our intermediate representation. Args: source: Either a function object or a string containing the function definition - ctx: Optional evaluation context + ctx: Evaluation context """ - if ctx is None: - ctx = EvaluationContext() # Convert function object to source string if needed if callable(source) and not isinstance(source, str): diff --git a/flow360/component/simulation/blueprint/core/resolver.py b/flow360/component/simulation/blueprint/core/resolver.py index 7ab1467e1..f163683e9 100644 --- a/flow360/component/simulation/blueprint/core/resolver.py +++ b/flow360/component/simulation/blueprint/core/resolver.py @@ -43,6 +43,7 @@ def register_module(self, name: str, module: Any) -> None: self._allowed_modules[name] = module def can_evaluate(self, qualname: str) -> bool: + """Check if the name is not blacklisted for evaluation by the resolver""" return qualname not in self._evaluation_blacklist def get_callable(self, qualname: str) -> Callable[..., Any]: diff --git a/flow360/component/simulation/blueprint/core/statements.py b/flow360/component/simulation/blueprint/core/statements.py index bf7cc3a85..181aec059 100644 --- a/flow360/component/simulation/blueprint/core/statements.py +++ b/flow360/component/simulation/blueprint/core/statements.py @@ -1,12 +1,16 @@ +"""Data models and evaluator functions for single-line Python statements""" + from typing import Annotated, Literal, Union import pydantic as pd from .context import EvaluationContext, ReturnValue from .expressions import ExpressionType +from .types import Evaluable # Forward declaration of type StatementType = Annotated[ + # pylint: disable=duplicate-code Union[ "Assign", "AugAssign", @@ -19,12 +23,12 @@ ] -class Statement(pd.BaseModel): +class Statement(pd.BaseModel, Evaluable): """ Base class for statements (like 'if', 'for', assignments, etc.). """ - def evaluate(self, context: EvaluationContext) -> None: + def evaluate(self, context: EvaluationContext, strict: bool) -> None: raise NotImplementedError @@ -37,8 +41,8 @@ class Assign(Statement): target: str value: ExpressionType - def evaluate(self, context: EvaluationContext) -> None: - context.set(self.target, self.value.evaluate(context)) + def evaluate(self, context: EvaluationContext, strict: bool) -> None: + context.set(self.target, self.value.evaluate(context, strict)) class AugAssign(Statement): @@ -52,9 +56,9 @@ class AugAssign(Statement): op: str value: ExpressionType - def evaluate(self, context: EvaluationContext) -> None: + def evaluate(self, context: EvaluationContext, strict: bool) -> None: old_val = context.get(self.target) - increment = self.value.evaluate(context) + increment = self.value.evaluate(context, strict) if self.op == "Add": context.set(self.target, old_val + increment) elif self.op == "Sub": @@ -81,13 +85,13 @@ class IfElse(Statement): body: list["StatementType"] orelse: list["StatementType"] - def evaluate(self, context: EvaluationContext) -> None: - if self.condition.evaluate(context): + def evaluate(self, context: EvaluationContext, strict: bool) -> None: + if self.condition.evaluate(context, strict): for stmt in self.body: - stmt.evaluate(context) + stmt.evaluate(context, strict) else: for stmt in self.orelse: - stmt.evaluate(context) + stmt.evaluate(context, strict) class ForLoop(Statement): @@ -102,12 +106,12 @@ class ForLoop(Statement): iter: ExpressionType body: list["StatementType"] - def evaluate(self, context: EvaluationContext) -> None: - iterable = self.iter.evaluate(context) + def evaluate(self, context: EvaluationContext, strict: bool) -> None: + iterable = self.iter.evaluate(context, strict) for item in iterable: context.set(self.target, item) for stmt in self.body: - stmt.evaluate(context) + stmt.evaluate(context, strict) class Return(Statement): @@ -119,8 +123,8 @@ class Return(Statement): type: Literal["Return"] = "Return" value: ExpressionType - def evaluate(self, context: EvaluationContext) -> None: - val = self.value.evaluate(context) + def evaluate(self, context: EvaluationContext, strict: bool) -> None: + val = self.value.evaluate(context, strict) raise ReturnValue(val) @@ -131,7 +135,7 @@ class TupleUnpack(Statement): targets: list[str] values: list[ExpressionType] - def evaluate(self, context: EvaluationContext) -> None: - evaluated_values = [val.evaluate(context) for val in self.values] - for target, value in zip(self.targets, evaluated_values, strict=False): + def evaluate(self, context: EvaluationContext, strict: bool) -> None: + evaluated_values = [val.evaluate(context, strict) 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 4a808d719..bd1d30b8a 100644 --- a/flow360/component/simulation/blueprint/core/types.py +++ b/flow360/component/simulation/blueprint/core/types.py @@ -1,10 +1,36 @@ +"""Shared type definitions for blueprint core submodules""" + +# pylint: disable=too-few-public-methods + import abc +from enum import Enum from typing import Any from .context import EvaluationContext 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: - pass + """ + 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; + if False, allow graceful failure or fallback behavior. + + Returns: + Any: The evaluated value. + """ + raise NotImplementedError + + +class TargetSyntax(Enum): + """Target syntax enum, Python and""" + + PYTHON = ("python",) + CPP = ("cpp",) + # Possibly other languages in the future if needed... diff --git a/flow360/component/simulation/blueprint/flow360/__init__.py b/flow360/component/simulation/blueprint/flow360/__init__.py index e2639ab6f..4f83c1037 100644 --- a/flow360/component/simulation/blueprint/flow360/__init__.py +++ b/flow360/component/simulation/blueprint/flow360/__init__.py @@ -1 +1,3 @@ +"""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 index 6d986aaa2..c34b0ec05 100644 --- a/flow360/component/simulation/blueprint/flow360/symbols.py +++ b/flow360/component/simulation/blueprint/flow360/symbols.py @@ -1,53 +1,36 @@ +"""Resolver and symbols data for Flow360 python client""" + from __future__ import annotations from typing import Any -from ..core.resolver import CallableResolver +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(): - import unyt +def _unit_list(): unit_symbols = set() - for key, value in unyt.unit_symbols.__dict__.items(): + 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_flow360(name: str) -> Any: - import flow360 as fl - - """Import and return a flow360 callable""" - if name == "fl": - return fl - - if name == "u": - from flow360 import u - - return u - - if name == "control": - from flow360 import control +def _import_units(_: str) -> Any: + """Import and return allowed flow360 callables""" + return u - return control - if name == "solution": - from flow360 import solution +def _import_numpy(_: str) -> Any: + """Import and return allowed numpy callables""" + return np - return solution - -def _import_numpy(name: str) -> Any: - import numpy as np - - if name == "np": - return np - - -# TODO: Rename the variables to conform to the snake case convention WHITELISTED_CALLABLES = { "flow360.units": {"prefix": "u.", "callables": _unit_list(), "evaluate": True}, "flow360.control": { @@ -148,13 +131,27 @@ def _import_numpy(name: str) -> Any: ], "evaluate": False, }, + "numpy": { + "prefix": "np.", + "callables": [ + "array", + "sin", + "tan", + "arcsin", + "arccos", + "arctan", + "dot", + "cross", + "sqrt", + ], + "evaluate": True, + }, } # Define allowed modules -ALLOWED_MODULES = {"flow360", "fl", "np"} +ALLOWED_MODULES = {"u", "np", "control", "solution"} ALLOWED_CALLABLES = { - "fl": None, **{ f"{group['prefix']}{name}": None for group in WHITELISTED_CALLABLES.values() @@ -172,7 +169,7 @@ def _import_numpy(name: str) -> Any: } IMPORT_FUNCTIONS = { - ("fl", "u"): _import_flow360, + "u": _import_units, "np": _import_numpy, } diff --git a/flow360/component/simulation/blueprint/tidy3d/__init__.py b/flow360/component/simulation/blueprint/tidy3d/__init__.py deleted file mode 100644 index e2639ab6f..000000000 --- a/flow360/component/simulation/blueprint/tidy3d/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .symbols import resolver diff --git a/flow360/component/simulation/blueprint/tidy3d/symbols.py b/flow360/component/simulation/blueprint/tidy3d/symbols.py deleted file mode 100644 index e7e64839c..000000000 --- a/flow360/component/simulation/blueprint/tidy3d/symbols.py +++ /dev/null @@ -1,98 +0,0 @@ -from __future__ import annotations - -from typing import Any, Callable - -from ..core.resolver import CallableResolver - - -def _import_utilities(name: str) -> Callable[..., Any]: - """Import and return a utility callable.""" - from rich import print - - callables = { - "print": print, - } - return callables[name] - - -def _import_tidy3d(name: str) -> Any: - """Import and return a tidy3d callable.""" - import tidy3d as td - - if name == "td": - return td - - if name.startswith("td."): - # Generate callables dict dynamically from WHITELISTED_CALLABLES - td_core = WHITELISTED_CALLABLES["tidy3d.core"] - callables = { - f"{td_core['prefix']}{name}": getattr(td, name) for name in td_core["callables"] - } - return callables[name] - elif name == "ModeSolver": - from tidy3d.plugins.mode import ModeSolver - - return ModeSolver - elif name == "C_0": - from tidy3d.constants import C_0 - - return C_0 - raise ValueError(f"Unknown tidy3d callable: {name}") - - -# Single source of truth for whitelisted callables -WHITELISTED_CALLABLES = { - "tidy3d.core": { - "prefix": "td.", - "callables": [ - "Medium", - "GridSpec", - "Box", - "Structure", - "Simulation", - "BoundarySpec", - "Periodic", - "ModeSpec", - "inf", - ], - "evaluate": True, - }, - "tidy3d.plugins": {"prefix": "", "callables": ["ModeSolver"], "evaluate": True}, - "tidy3d.constants": {"prefix": "", "callables": ["C_0"], "evaluate": True}, - "utilities": {"prefix": "", "callables": ["print"], "evaluate": True}, -} - -# Define allowed modules -ALLOWED_MODULES = { - "ModeSolver", # For the ModeSolver class - "tidy3d", # For the tidy3d module - "td", # For the tidy3d alias -} - -ALLOWED_CALLABLES = { - "td": None, - **{ - 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"] - }, -} - -# Generate import category mapping -IMPORT_FUNCTIONS = { - ("td", "td.", "ModeSolver", "C_0"): _import_tidy3d, - ("print",): _import_utilities, -} - -resolver = CallableResolver( - ALLOWED_CALLABLES, ALLOWED_MODULES, IMPORT_FUNCTIONS, EVALUATION_BLACKLIST -) diff --git a/flow360/component/simulation/blueprint/utils/operators.py b/flow360/component/simulation/blueprint/utils/operators.py index b72a7317f..89bc9dcdf 100644 --- a/flow360/component/simulation/blueprint/utils/operators.py +++ b/flow360/component/simulation/blueprint/utils/operators.py @@ -1,9 +1,11 @@ +"""Operator info for the parser module""" + +# pylint: disable=too-few-public-methods + import operator from collections.abc import Callable from typing import Any, Union -from .types import TargetSyntax - class OpInfo: """Class to hold operator information.""" diff --git a/flow360/component/simulation/blueprint/utils/types.py b/flow360/component/simulation/blueprint/utils/types.py deleted file mode 100644 index bbeaf35e7..000000000 --- a/flow360/component/simulation/blueprint/utils/types.py +++ /dev/null @@ -1,7 +0,0 @@ -from enum import Enum - - -class TargetSyntax(Enum): - PYTHON = ("python",) - CPP = ("cpp",) - # Possibly other languages in the future if needed... diff --git a/flow360/component/simulation/framework/param_utils.py b/flow360/component/simulation/framework/param_utils.py index 4ae031209..d470cb192 100644 --- a/flow360/component/simulation/framework/param_utils.py +++ b/flow360/component/simulation/framework/param_utils.py @@ -52,6 +52,7 @@ def boundaries(self): def find_instances(obj, target_type): + """Recursively find items of target_type within a python object""" stack = [obj] seen_ids = set() results = set() @@ -80,7 +81,7 @@ def find_instances(obj, target_type): elif hasattr(current, "__iter__") and not isinstance(current, (str, bytes)): try: stack.extend(iter(current)) - except Exception: + except Exception: # pylint: disable=broad-exception-caught pass # skip problematic iterables return list(results) diff --git a/flow360/component/simulation/primitives.py b/flow360/component/simulation/primitives.py index 032b8e56c..e2158887d 100644 --- a/flow360/component/simulation/primitives.py +++ b/flow360/component/simulation/primitives.py @@ -88,7 +88,7 @@ class ReferenceGeometry(Flow360BaseModel): moment_length: Optional[Union[LengthType.Positive, LengthType.PositiveVector]] = pd.Field( None, description="The x, y, z component-wise moment reference lengths." ) - area: Optional[ValueOrExpression[AreaType]] = pd.Field( + area: Optional[ValueOrExpression[AreaType.Positive]] = pd.Field( None, description="The reference area of the geometry." ) diff --git a/flow360/component/simulation/services.py b/flow360/component/simulation/services.py index 4ca5d7550..f5f40787a 100644 --- a/flow360/component/simulation/services.py +++ b/flow360/component/simulation/services.py @@ -362,6 +362,8 @@ def validate_model( updated_param_as_dict = parse_model_dict(updated_param_as_dict, globals()) + SimulationParams.initialize_variable_space(updated_param_as_dict) + additional_info = ParamsValidationInfo(param_as_dict=updated_param_as_dict) with ValidationContext(levels=validation_levels_to_use, info=additional_info): validated_param = SimulationParams(file_content=updated_param_as_dict) @@ -782,6 +784,7 @@ def update_simulation_json(*, params_as_dict: dict, target_python_api_version: s return updated_params_as_dict, errors +# pylint: disable=too-many-branches def validate_expression(variables: list[dict], expressions: list[str]): """ Validate all given expressions using the specified variable space (which is also validated) @@ -791,8 +794,7 @@ def validate_expression(variables: list[dict], expressions: list[str]): units = [] # Populate variable scope - for i in range(len(variables)): - variable = variables[i] + for i, variable in enumerate(variables): loc_hint = ["variables", str(i)] try: variable = UserVariable(name=variable["name"], value=variable["value"]) @@ -803,8 +805,7 @@ def validate_expression(variables: list[dict], expressions: list[str]): except Exception as err: # pylint: disable=broad-exception-caught handle_generic_exception(err, errors, loc_hint) - for i in range(len(expressions)): - expression = expressions[i] + for i, expression in enumerate(expressions): loc_hint = ["expressions", str(i)] value = None unit = None diff --git a/flow360/component/simulation/simulation_params.py b/flow360/component/simulation/simulation_params.py index ae8b39074..dc43f3367 100644 --- a/flow360/component/simulation/simulation_params.py +++ b/flow360/component/simulation/simulation_params.py @@ -195,12 +195,6 @@ def _init_no_unit_context(self, filename, file_content, **kwargs): unit_system = model_dict.get("unit_system") - # This is ugly but needed beacuse overloading __init__ nullfies all before validators of SimulationParams... - # TODO: Move this to a before field_validator when __init__ is not overloaded - cache_key = "private_attribute_asset_cache" - if cache_key in model_dict: - SimulationParams.initialize_variable_space(model_dict[cache_key]) - with UnitSystem.from_dict(**unit_system): # pylint: disable=not-context-manager super().__init__(**model_dict) @@ -211,12 +205,6 @@ def _init_with_unit_context(self, **kwargs): # When treating dicts the updater is skipped. kwargs = _ParamModelBase._init_check_unit_system(**kwargs) - # This is ugly but needed beacuse overloading __init__ nullfies all before validators of SimulationParams... - # TODO: Move this to a before field_validator when __init__ is not overloaded - cache_key = "private_attribute_asset_cache" - if cache_key in kwargs: - SimulationParams.initialize_variable_space(kwargs[cache_key]) - super().__init__(unit_system=unit_system_manager.current, **kwargs) # pylint: disable=super-init-not-called @@ -381,11 +369,17 @@ def convert_unit( converted = value.in_base(unit_system=target_system) return converted - # A bit ugly but we have no way of forcing validator call order so this is a workaround + # We have no way forcing validator call order so this is a workaround @classmethod - def initialize_variable_space(cls, value): - if "project_variables" in value and isinstance(value["project_variables"], Iterable): - for variable_dict in value["project_variables"]: + def initialize_variable_space(cls, value: dict): + """Load all user variables from private attributes when a simulation params object is initialized""" + if "private_attribute_asset_cache" not in value.keys(): + return value + asset_cache: dict = value["private_attribute_asset_cache"] + if "project_variables" not in asset_cache.keys(): + return value + if isinstance(asset_cache["project_variables"], Iterable): + for variable_dict in asset_cache["project_variables"]: UserVariable(name=variable_dict["name"], value=variable_dict["value"]) return value @@ -393,7 +387,7 @@ def initialize_variable_space(cls, value): @pd.field_validator("models", mode="after") @classmethod def apply_default_fluid_settings(cls, v): - """apply default Fluid() settings if not found in models""" + """Apply default Fluid() settings if not found in models""" if v is None: v = [] assert isinstance(v, list) diff --git a/flow360/component/simulation/translator/utils.py b/flow360/component/simulation/translator/utils.py index 0f16496b8..1b47697ac 100644 --- a/flow360/component/simulation/translator/utils.py +++ b/flow360/component/simulation/translator/utils.py @@ -119,10 +119,13 @@ def convert_tuples_to_lists(input_dict): def remove_units_in_dict(input_dict): """Remove units from a dimensioned value.""" - unit_keys = {"value", "units"} + + def _is_unyt_or_unyt_like_obj(value): + return "value" in value.keys() and "units" in value.keys() + if isinstance(input_dict, dict): new_dict = {} - if input_dict.keys() == unit_keys: + if _is_unyt_or_unyt_like_obj(input_dict): new_dict = input_dict["value"] if input_dict["units"].startswith("flow360_") is False: raise ValueError( @@ -130,7 +133,7 @@ def remove_units_in_dict(input_dict): ) return new_dict for key, value in input_dict.items(): - if isinstance(value, dict) and value.keys() == unit_keys: + if isinstance(value, dict) and _is_unyt_or_unyt_like_obj(input_dict): if value["units"].startswith("flow360_") is False: raise ValueError( f"[Internal Error] Unit {value['units']} is not non-dimensionalized." @@ -145,6 +148,7 @@ def remove_units_in_dict(input_dict): def inline_expressions_in_dict(input_dict, input_params): + """Inline all expressions in the provided dict to their evaluated values""" if isinstance(input_dict, dict): new_dict = {} if "expression" in input_dict.keys(): diff --git a/flow360/component/simulation/user_code.py b/flow360/component/simulation/user_code.py index 2d0a4242a..9cc8419b8 100644 --- a/flow360/component/simulation/user_code.py +++ b/flow360/component/simulation/user_code.py @@ -1,23 +1,27 @@ +"""This module allows users to write serializable, evaluable symbolic code for use in simulation params""" + from __future__ import annotations import re -from typing import ClassVar, Generic, Iterable, Optional, TypeVar +from numbers import Number +from typing import Annotated, Any, Generic, Iterable, Literal, Optional, TypeVar, Union -from pydantic import BeforeValidator +import numpy as np +import pydantic as pd +from pydantic import BeforeValidator, Discriminator, PlainSerializer, Tag +from pydantic_core import InitErrorDetails, core_schema from typing_extensions import Self from unyt import Unit, unyt_array from flow360.component.simulation.blueprint import Evaluable, expr_to_model -from flow360.component.simulation.blueprint.codegen import expr_to_code -from flow360.component.simulation.blueprint.core import EvaluationContext -from flow360.component.simulation.blueprint.flow360 import resolver -from flow360.component.simulation.blueprint.utils.types import TargetSyntax +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.unit_system import * _global_ctx: EvaluationContext = EvaluationContext(resolver) _user_variables: set[str] = set() -_solver_variables: dict[str, str] = dict() +_solver_variables: dict[str, str] = {} def _is_number_string(s: str) -> bool: @@ -28,10 +32,10 @@ def _is_number_string(s: str) -> bool: return False -def _split_keep_delimiters(input: str, delimiters: list) -> list: +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, input) + result = re.split(pattern, value) return [part for part in result if part != ""] @@ -83,6 +87,8 @@ def _convert_argument(value): 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) units: Optional[str] = pd.Field(None) @@ -93,15 +99,19 @@ class SerializedValueOrExpression(Flow360BaseModel): # This is a wrapper to allow using ndarrays with pydantic models class NdArray(np.ndarray): + """NdArray wrapper to enable pydantic compatibility""" + def __repr__(self): return f"NdArray(shape={self.shape}, dtype={self.dtype})" + # pylint: disable=unused-argument @classmethod def __get_pydantic_core_schema__(cls, source_type, handler): return core_schema.no_info_plain_validator_function(cls.validate) @classmethod def validate(cls, value: Any): + """Minimal validator for pydantic compatibility""" if isinstance(value, np.ndarray): return value raise ValueError(f"Cannot convert {type(value)} to NdArray") @@ -109,15 +119,19 @@ def validate(cls, value: Any): # This is a wrapper to allow using unyt arrays with pydantic models class UnytArray(unyt_array): + """UnytArray wrapper to enable pydantic compatibility""" + def __repr__(self): return f"UnytArray({str(self)})" + # pylint: disable=unused-argument @classmethod def __get_pydantic_core_schema__(cls, source_type, handler): return core_schema.no_info_plain_validator_function(cls.validate) @classmethod def validate(cls, value: Any): + """Minimal validator for pydantic compatibility""" if isinstance(value, unyt_array): return value raise ValueError(f"Cannot convert {type(value)} to UnytArray") @@ -127,6 +141,8 @@ def validate(cls, value: Any): class Variable(Flow360BaseModel): + """Base class representing a symbolic variable""" + name: str = pd.Field() value: ValueOrExpression[AnyNumericType] = pd.Field() @@ -222,6 +238,7 @@ def __getitem__(self, item): return Expression(expression=f"{self.name}[{arg}]") def __str__(self): + # pylint:disable=invalid-str-returned return self.name def __repr__(self): @@ -231,31 +248,41 @@ def __hash__(self): return hash(self.name) def sqrt(self): + """Square root, required for numpy interop""" return Expression(expression=f"np.sqrt({self.expression})") def sin(self): + """Sine, required for numpy interop""" return Expression(expression=f"np.sin({self.expression})") def cos(self): + """Cosine, required for numpy interop""" return Expression(expression=f"np.cos({self.expression})") def tan(self): + """Tangent, required for numpy interop""" return Expression(expression=f"np.tan({self.expression})") def arcsin(self): + """Arcsine, required for numpy interop""" return Expression(expression=f"np.arcsin({self.expression})") def arccos(self): + """Arccosine, required for numpy interop""" return Expression(expression=f"np.arccos({self.expression})") def arctan(self): + """Arctangent, required for numpy interop""" return Expression(expression=f"np.arctan({self.expression})") class UserVariable(Variable): + """Class representing a user-defined symbolic variable""" + @pd.model_validator(mode="after") @classmethod def update_context(cls, value): + """Auto updating context when new variable is declared""" _global_ctx.set(value.name, value.value) _user_variables.add(value.name) return value @@ -263,6 +290,7 @@ def update_context(cls, value): @pd.model_validator(mode="after") @classmethod def check_dependencies(cls, value): + """Validator for ensuring no cyclic dependency.""" visited = set() stack = [(value.name, [value.name])] while stack: @@ -284,11 +312,14 @@ def check_dependencies(cls, value): class SolverVariable(Variable): + """Class representing a pre-defined symbolic variable that cannot be evaluated at client runtime""" + solver_name: Optional[str] = pd.Field(None) @pd.model_validator(mode="after") @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 @@ -320,6 +351,16 @@ def _handle_syntax_error(se: SyntaxError, source: str): class Expression(Flow360BaseModel, Evaluable): + """ + A symbolic, validated representation of a mathematical expression. + + This model wraps a string-based expression, ensures its syntax and semantics + against the global evaluation context, and provides methods to: + - evaluate its numeric/unyt result (`evaluate`) + - list user-defined variables it references (`user_variables` / `user_variable_names`) + - emit C++ solver code (`to_solver_code`) + """ + expression: str = pd.Field("") model_config = pd.ConfigDict(validate_assignment=True) @@ -350,7 +391,7 @@ def _validate_expression(cls, value) -> Self: try: expr_to_model(expression, _global_ctx) except SyntaxError as s_err: - raise _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]) @@ -360,6 +401,7 @@ def _validate_expression(cls, value) -> Self: def evaluate( self, context: EvaluationContext = None, strict: bool = True ) -> Union[float, np.ndarray, unyt_array]: + """Evaluate this expression against the given context.""" if context is None: context = _global_ctx expr = expr_to_model(self.expression, context) @@ -367,6 +409,7 @@ def evaluate( return result def user_variables(self): + """Get list of user variables used in expression.""" expr = expr_to_model(self.expression, _global_ctx) names = expr.used_names() names = [name for name in names if name in _user_variables] @@ -374,6 +417,7 @@ def user_variables(self): return [UserVariable(name=name, value=_global_ctx.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) names = expr.used_names() names = [name for name in names if name in _user_variables] @@ -381,6 +425,8 @@ def user_variable_names(self): return names def to_solver_code(self, params): + """Convert to solver readable code.""" + def translate_symbol(name): if name in _solver_variables: return _solver_variables[name] @@ -389,8 +435,7 @@ def translate_symbol(name): value = _global_ctx.get(name) if isinstance(value, Expression): return f"{value.to_solver_code(params)}" - else: - return _convert_numeric(value) + return _convert_numeric(value) match = re.fullmatch("u\\.(.+)", name) @@ -499,30 +544,38 @@ def __getitem__(self, item): return Expression(expression=f"({self.expression})[{arg}]") def __str__(self): + # pylint:disable=invalid-str-returned return self.expression def __repr__(self): return f"Expression({self.expression})" def sqrt(self): + """Element-wise square root of this expression.""" return Expression(expression=f"np.sqrt({self.expression})") def sin(self): + """Element-wise sine of this expression (in radians).""" return Expression(expression=f"np.sin({self.expression})") def cos(self): + """Element-wise cosine of this expression (in radians).""" return Expression(expression=f"np.cos({self.expression})") def tan(self): + """Element-wise tangent of this expression (in radians).""" return Expression(expression=f"np.tan({self.expression})") def arcsin(self): + """Element-wise inverse sine (arcsin) of this expression.""" return Expression(expression=f"np.arcsin({self.expression})") def arccos(self): + """Element-wise inverse cosine (arccos) of this expression.""" return Expression(expression=f"np.arccos({self.expression})") def arctan(self): + """Element-wise inverse tangent (arctan) of this expression.""" return Expression(expression=f"np.arctan({self.expression})") @@ -530,36 +583,48 @@ def arctan(self): class ValueOrExpression(Expression, Generic[T]): - def __class_getitem__(cls, internal_type): + """Model accepting both value and expressions""" + + def __class_getitem__(cls, typevar_values): # pylint:disable=too-many-statements def _internal_validator(value: Expression): try: result = value.evaluate(strict=False) except Exception as err: raise ValueError(f"expression evaluation failed: {err}") from err - pd.TypeAdapter(internal_type).validate_python(result) + pd.TypeAdapter(typevar_values).validate_python(result) return value expr_type = Annotated[Expression, pd.AfterValidator(_internal_validator)] def _deserialize(value) -> Self: - is_serialized = False - - try: - value = SerializedValueOrExpression.model_validate(value) - is_serialized = True - except Exception as err: - pass - - if is_serialized: - if value.type_name == "number": - if value.units is not None: - return unyt_array(value.value, value.units) - else: - return value.value - elif value.type_name == "expression": - return expr_type(expression=value.expression) + 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: - return value + 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) + + return deserialized def _serializer(value, info) -> dict: if isinstance(value, Expression): @@ -594,9 +659,24 @@ def _serializer(value, info) -> dict: return serialized.model_dump(**info.__dict__) - union_type = Union[expr_type, internal_type] - - union_type = Annotated[union_type, PlainSerializer(_serializer)] - union_type = Annotated[union_type, BeforeValidator(_deserialize)] - + def _get_discriminator_value(v: Any) -> str: + # Note: This is ran after deserializer + if isinstance(v, SerializedValueOrExpression): + return v.type_name + if isinstance(v, dict): + return v.get("typeName") if v.get("typeName") else v.get("type_name") + if isinstance(v, (Expression, Variable, str)): + return "expression" + if isinstance(v, (Number, unyt_array, np.ndarray)): + return "number" + raise KeyError("Unknown expression input type: ", v, v.__class__.__name__) + + union_type = Annotated[ + Union[ + Annotated[expr_type, Tag("expression")], Annotated[typevar_values, Tag("number")] + ], + Discriminator(_get_discriminator_value), + BeforeValidator(_deserialize), + PlainSerializer(_serializer), + ] return union_type diff --git a/flow360/component/simulation/utils.py b/flow360/component/simulation/utils.py index e2bf9ceb2..9f687c347 100644 --- a/flow360/component/simulation/utils.py +++ b/flow360/component/simulation/utils.py @@ -57,34 +57,3 @@ def is_instance_of_type_in_union(obj, typ) -> bool: # Otherwise, do a normal isinstance check. return isinstance(obj, typ) - - -class UnknownFloat(float): - def __new__(cls): - return super().__new__(cls, float("nan")) - - def __repr__(self): - return "UnknownFloat()" - - def __str__(self): - return "unknown" - - def _return_unknown(self, *args): - return UnknownFloat() - - __add__ = __radd__ = __sub__ = __rsub__ = _return_unknown - __mul__ = __rmul__ = __truediv__ = __rtruediv__ = _return_unknown - __floordiv__ = __rfloordiv__ = __mod__ = __rmod__ = _return_unknown - __pow__ = __rpow__ = _return_unknown - __neg__ = __pos__ = __abs__ = _return_unknown - - def __eq__(self, other): - return False - - def __ne__(self, other): - return True - - __lt__ = __le__ = __gt__ = __ge__ = _return_unknown - - def __bool__(self): - return False diff --git a/flow360/component/simulation/variables/control_variables.py b/flow360/component/simulation/variables/control_variables.py index 847db09b8..d3c1b1929 100644 --- a/flow360/component/simulation/variables/control_variables.py +++ b/flow360/component/simulation/variables/control_variables.py @@ -1,7 +1,9 @@ -from flow360 import u +"""Control variables of Flow360""" + +from flow360.component.simulation import units as u from flow360.component.simulation.user_code import SolverVariable -# TODO: This is an example to illustrate translator features, switch for correct values later... +# pylint:disable=no-member MachRef = SolverVariable( name="control.MachRef", value=float("NaN") * u.m / u.s, solver_name="machRef" ) # Reference mach specified by the user diff --git a/flow360/component/simulation/variables/solution_variables.py b/flow360/component/simulation/variables/solution_variables.py index 07237b7c8..e97f28691 100644 --- a/flow360/component/simulation/variables/solution_variables.py +++ b/flow360/component/simulation/variables/solution_variables.py @@ -1,3 +1,5 @@ +"""Solution variables of Flow360""" + from flow360.component.simulation.user_code import SolverVariable mut = SolverVariable(name="solution.mut", value=float("NaN")) # Turbulent viscosity diff --git a/tests/ref/simulation/service_init_geometry.json b/tests/ref/simulation/service_init_geometry.json index dbe8cbe2f..8d4882bdd 100644 --- a/tests/ref/simulation/service_init_geometry.json +++ b/tests/ref/simulation/service_init_geometry.json @@ -42,6 +42,7 @@ "units": "m" }, "area": { + "type_name":"number", "value": 1.0, "units": "m**2" } @@ -299,6 +300,7 @@ "units": "m" }, "use_inhouse_mesher": false, - "use_geometry_AI": false + "use_geometry_AI": false, + "project_variables":[] } } diff --git a/tests/ref/simulation/service_init_surface_mesh.json b/tests/ref/simulation/service_init_surface_mesh.json index b17bdbbb4..112f57825 100644 --- a/tests/ref/simulation/service_init_surface_mesh.json +++ b/tests/ref/simulation/service_init_surface_mesh.json @@ -42,6 +42,7 @@ "units": "cm" }, "area": { + "type_name":"number", "value": 1.0, "units": "cm**2" } @@ -299,6 +300,7 @@ "units": "cm" }, "use_inhouse_mesher": false, - "use_geometry_AI": false + "use_geometry_AI": false, + "project_variables":[] } } \ No newline at end of file diff --git a/tests/ref/simulation/service_init_volume_mesh.json b/tests/ref/simulation/service_init_volume_mesh.json index f53d3f504..c4fa4437a 100644 --- a/tests/ref/simulation/service_init_volume_mesh.json +++ b/tests/ref/simulation/service_init_volume_mesh.json @@ -21,6 +21,7 @@ "units": "m" }, "area": { + "type_name":"number", "value": 1.0, "units": "m**2" } @@ -264,6 +265,7 @@ "units": "m" }, "use_inhouse_mesher": false, - "use_geometry_AI": false + "use_geometry_AI": false, + "project_variables":[] } } diff --git a/tests/simulation/converter/ref/ref_monitor.json b/tests/simulation/converter/ref/ref_monitor.json index 2ff8e1263..fed32cef0 100644 --- a/tests/simulation/converter/ref/ref_monitor.json +++ b/tests/simulation/converter/ref/ref_monitor.json @@ -1 +1 @@ -{"version":"25.5.1","unit_system":{"name":"SI"},"meshing":null,"reference_geometry":null,"operating_condition":null,"models":[{"material":{"type":"air","name":"air","dynamic_viscosity":{"reference_viscosity":{"value":0.00001716,"units":"Pa*s"},"reference_temperature":{"value":273.15,"units":"K"},"effective_temperature":{"value":110.4,"units":"K"}}},"initial_condition":{"type_name":"NavierStokesInitialCondition","constants":null,"rho":"rho","u":"u","v":"v","w":"w","p":"p"},"type":"Fluid","navier_stokes_solver":{"absolute_tolerance":1e-10,"relative_tolerance":0.0,"order_of_accuracy":2,"equation_evaluation_frequency":1,"linear_solver":{"max_iterations":30,"absolute_tolerance":null,"relative_tolerance":null},"private_attribute_dict":null,"CFL_multiplier":1.0,"kappa_MUSCL":-1.0,"numerical_dissipation_factor":1.0,"limit_velocity":false,"limit_pressure_density":false,"type_name":"Compressible","low_mach_preconditioner":false,"low_mach_preconditioner_threshold":null,"update_jacobian_frequency":4,"max_force_jac_update_physical_steps":0},"turbulence_model_solver":{"absolute_tolerance":1e-8,"relative_tolerance":0.0,"order_of_accuracy":2,"equation_evaluation_frequency":4,"linear_solver":{"max_iterations":20,"absolute_tolerance":null,"relative_tolerance":null},"private_attribute_dict":null,"CFL_multiplier":2.0,"type_name":"SpalartAllmaras","reconstruction_gradient_limiter":0.5,"quadratic_constitutive_relation":false,"modeling_constants":{"type_name":"SpalartAllmarasConsts","C_DES":0.72,"C_d":8.0,"C_cb1":0.1355,"C_cb2":0.622,"C_sigma":0.6666666666666666,"C_v1":7.1,"C_vonKarman":0.41,"C_w2":0.3,"C_t3":1.2,"C_t4":0.5,"C_min_rd":10.0},"update_jacobian_frequency":4,"max_force_jac_update_physical_steps":0,"hybrid_model":null,"rotation_correction":false},"transition_model_solver":{"type_name":"None"}}],"time_stepping":{"type_name":"Steady","max_steps":2000,"CFL":{"type":"adaptive","min":0.1,"max":10000.0,"max_relative_change":1.0,"convergence_limiting_factor":0.25}},"user_defined_dynamics":null,"user_defined_fields":[],"outputs":[{"name":"R1","entities":{"stored_entities":[{"private_attribute_registry_bucket_name":"PointEntityType","private_attribute_entity_type_name":"Point","private_attribute_id":"b9de2bce-36c1-4bbf-af0a-2c6a2ab713a4","name":"Point-0","location":{"value":[2.694298,0.0,1.0195910000000001],"units":"m"}}]},"output_fields":{"items":["primitiveVars"]},"output_type":"ProbeOutput"},{"name":"V3","entities":{"stored_entities":[{"private_attribute_registry_bucket_name":"PointEntityType","private_attribute_entity_type_name":"Point","private_attribute_id":"a79cffc0-31d0-499d-906c-f271c2320166","name":"Point-1","location":{"value":[4.007,0.0,-0.31760000000000005],"units":"m"}},{"private_attribute_registry_bucket_name":"PointEntityType","private_attribute_entity_type_name":"Point","private_attribute_id":"8947eb10-fc59-4102-b9c7-168a91ca22b9","name":"Point-2","location":{"value":[4.007,0.0,-0.29760000000000003],"units":"m"}},{"private_attribute_registry_bucket_name":"PointEntityType","private_attribute_entity_type_name":"Point","private_attribute_id":"27ac4e03-592b-4dba-8fa1-8f6678087a96","name":"Point-3","location":{"value":[4.007,0.0,-0.2776],"units":"m"}}]},"output_fields":{"items":["mut"]},"output_type":"ProbeOutput"}],"private_attribute_asset_cache":{"project_length_unit":null,"project_entity_info":null, "use_inhouse_mesher": false, "use_geometry_AI": false}} +{"version":"25.5.1","unit_system":{"name":"SI"},"meshing":null,"reference_geometry":null,"operating_condition":null,"models":[{"material":{"type":"air","name":"air","dynamic_viscosity":{"reference_viscosity":{"value":0.00001716,"units":"Pa*s"},"reference_temperature":{"value":273.15,"units":"K"},"effective_temperature":{"value":110.4,"units":"K"}}},"initial_condition":{"type_name":"NavierStokesInitialCondition","constants":null,"rho":"rho","u":"u","v":"v","w":"w","p":"p"},"type":"Fluid","navier_stokes_solver":{"absolute_tolerance":1e-10,"relative_tolerance":0.0,"order_of_accuracy":2,"equation_evaluation_frequency":1,"linear_solver":{"max_iterations":30,"absolute_tolerance":null,"relative_tolerance":null},"private_attribute_dict":null,"CFL_multiplier":1.0,"kappa_MUSCL":-1.0,"numerical_dissipation_factor":1.0,"limit_velocity":false,"limit_pressure_density":false,"type_name":"Compressible","low_mach_preconditioner":false,"low_mach_preconditioner_threshold":null,"update_jacobian_frequency":4,"max_force_jac_update_physical_steps":0},"turbulence_model_solver":{"absolute_tolerance":1e-8,"relative_tolerance":0.0,"order_of_accuracy":2,"equation_evaluation_frequency":4,"linear_solver":{"max_iterations":20,"absolute_tolerance":null,"relative_tolerance":null},"private_attribute_dict":null,"CFL_multiplier":2.0,"type_name":"SpalartAllmaras","reconstruction_gradient_limiter":0.5,"quadratic_constitutive_relation":false,"modeling_constants":{"type_name":"SpalartAllmarasConsts","C_DES":0.72,"C_d":8.0,"C_cb1":0.1355,"C_cb2":0.622,"C_sigma":0.6666666666666666,"C_v1":7.1,"C_vonKarman":0.41,"C_w2":0.3,"C_t3":1.2,"C_t4":0.5,"C_min_rd":10.0},"update_jacobian_frequency":4,"max_force_jac_update_physical_steps":0,"hybrid_model":null,"rotation_correction":false},"transition_model_solver":{"type_name":"None"}}],"time_stepping":{"type_name":"Steady","max_steps":2000,"CFL":{"type":"adaptive","min":0.1,"max":10000.0,"max_relative_change":1.0,"convergence_limiting_factor":0.25}},"user_defined_dynamics":null,"user_defined_fields":[],"outputs":[{"name":"R1","entities":{"stored_entities":[{"private_attribute_registry_bucket_name":"PointEntityType","private_attribute_entity_type_name":"Point","private_attribute_id":"b9de2bce-36c1-4bbf-af0a-2c6a2ab713a4","name":"Point-0","location":{"value":[2.694298,0.0,1.0195910000000001],"units":"m"}}]},"output_fields":{"items":["primitiveVars"]},"output_type":"ProbeOutput"},{"name":"V3","entities":{"stored_entities":[{"private_attribute_registry_bucket_name":"PointEntityType","private_attribute_entity_type_name":"Point","private_attribute_id":"a79cffc0-31d0-499d-906c-f271c2320166","name":"Point-1","location":{"value":[4.007,0.0,-0.31760000000000005],"units":"m"}},{"private_attribute_registry_bucket_name":"PointEntityType","private_attribute_entity_type_name":"Point","private_attribute_id":"8947eb10-fc59-4102-b9c7-168a91ca22b9","name":"Point-2","location":{"value":[4.007,0.0,-0.29760000000000003],"units":"m"}},{"private_attribute_registry_bucket_name":"PointEntityType","private_attribute_entity_type_name":"Point","private_attribute_id":"27ac4e03-592b-4dba-8fa1-8f6678087a96","name":"Point-3","location":{"value":[4.007,0.0,-0.2776],"units":"m"}}]},"output_fields":{"items":["mut"]},"output_type":"ProbeOutput"}],"private_attribute_asset_cache":{"project_length_unit":null,"project_entity_info":null, "use_inhouse_mesher": false, "project_variables":[], "use_geometry_AI": false}} diff --git a/tests/simulation/test_expressions.py b/tests/simulation/test_expressions.py index b8beaa4b4..ed72bc01d 100644 --- a/tests/simulation/test_expressions.py +++ b/tests/simulation/test_expressions.py @@ -1,4 +1,5 @@ import json +import tempfile from typing import List import numpy as np @@ -18,8 +19,13 @@ 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, Surface +from flow360.component.simulation.primitives import ( + GenericVolume, + ReferenceGeometry, + Surface, +) from flow360.component.simulation.unit_system import ( AbsoluteTemperatureType, AngleType, @@ -590,14 +596,14 @@ class TestModel(Flow360BaseModel): except pd.ValidationError as err: validation_errors = err.errors() - assert len(validation_errors) == 2 - assert validation_errors[0]["type"] == "value_error" - assert validation_errors[1]["type"] == "value_error" - assert "'(' was never closed at" in validation_errors[0]["msg"] - assert "TokenError('EOF in multi-line statement', (2, 0))" in validation_errors[1]["msg"] - assert "line" in validation_errors[0]["ctx"] - assert "column" in validation_errors[0]["ctx"] - assert validation_errors[0]["ctx"]["column"] == 9 + assert len(validation_errors) == 1 + assert validation_errors[0]["type"] == "value_error" + assert "line" in validation_errors[0]["ctx"] + assert "column" in validation_errors[0]["ctx"] + assert validation_errors[0]["ctx"]["column"] in ( + 9, + 11, + ) # Python 3.9 report error on col 11, error message is also different def test_solver_translation(): @@ -649,7 +655,7 @@ def test_solver_translation(): # 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) / 125.0) + machRef)" + assert expression.to_solver_code(params) == "((((4.0 + 1) * 0.5) / 500.0) + machRef)" def test_cyclic_dependencies(): @@ -698,10 +704,14 @@ 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: data = json.load(fh) + from flow360.component.simulation.services import ValidationCalledBy, validate_model - with SI_unit_system: - params = SimulationParams.model_validate(data) + 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