From ec01e74d6c5777f34587fdc6f3301e1690fd5ebf Mon Sep 17 00:00:00 2001 From: Andrzej Krupka <156919532+andrzej-krupka@users.noreply.github.com> Date: Wed, 19 Mar 2025 16:34:42 +0100 Subject: [PATCH 01/34] User expression support [POC] (#789) * Added examples of a pydantic model using expressions * Example units support * Migrate to Pydantic V2 * Added variable support * Added units support in variable expressions * Added support for constrained types * Added tests, add support for unary operations --- .../simulation/blueprint/__init__.py | 7 + .../simulation/blueprint/codegen/__init__.py | 4 + .../simulation/blueprint/codegen/generator.py | 157 ++++++++ .../simulation/blueprint/codegen/parser.py | 269 ++++++++++++++ .../simulation/blueprint/core/__init__.py | 100 +++++ .../simulation/blueprint/core/context.py | 41 +++ .../simulation/blueprint/core/expressions.py | 198 ++++++++++ .../simulation/blueprint/core/function.py | 39 ++ .../simulation/blueprint/core/statements.py | 137 +++++++ .../simulation/blueprint/utils/__init__.py | 0 .../simulation/blueprint/utils/operators.py | 47 +++ .../simulation/blueprint/utils/whitelisted.py | 240 ++++++++++++ flow360/component/simulation/expressions.py | 341 ++++++++++++++++++ tests/simulation/test_expressions.py | 325 +++++++++++++++++ 14 files changed, 1905 insertions(+) create mode 100644 flow360/component/simulation/blueprint/__init__.py create mode 100644 flow360/component/simulation/blueprint/codegen/__init__.py create mode 100644 flow360/component/simulation/blueprint/codegen/generator.py create mode 100644 flow360/component/simulation/blueprint/codegen/parser.py create mode 100644 flow360/component/simulation/blueprint/core/__init__.py create mode 100644 flow360/component/simulation/blueprint/core/context.py create mode 100644 flow360/component/simulation/blueprint/core/expressions.py create mode 100644 flow360/component/simulation/blueprint/core/function.py create mode 100644 flow360/component/simulation/blueprint/core/statements.py create mode 100644 flow360/component/simulation/blueprint/utils/__init__.py create mode 100644 flow360/component/simulation/blueprint/utils/operators.py create mode 100644 flow360/component/simulation/blueprint/utils/whitelisted.py create mode 100644 flow360/component/simulation/expressions.py create mode 100644 tests/simulation/test_expressions.py diff --git a/flow360/component/simulation/blueprint/__init__.py b/flow360/component/simulation/blueprint/__init__.py new file mode 100644 index 000000000..7b9bb46a1 --- /dev/null +++ b/flow360/component/simulation/blueprint/__init__.py @@ -0,0 +1,7 @@ +"""Blueprint: Safe function serialization and visual programming integration.""" + +from .codegen.generator import model_to_function +from .codegen.parser import function_to_model, expression_to_model +from .core.function import Function + +__all__ = ["Function", "function_to_model", "model_to_function", "expression_to_model"] diff --git a/flow360/component/simulation/blueprint/codegen/__init__.py b/flow360/component/simulation/blueprint/codegen/__init__.py new file mode 100644 index 000000000..319b883fb --- /dev/null +++ b/flow360/component/simulation/blueprint/codegen/__init__.py @@ -0,0 +1,4 @@ +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/codegen/generator.py b/flow360/component/simulation/blueprint/codegen/generator.py new file mode 100644 index 000000000..a324a694b --- /dev/null +++ b/flow360/component/simulation/blueprint/codegen/generator.py @@ -0,0 +1,157 @@ +from typing import Any + +from ..core.expressions import ( + BinOp, + CallModel, + Constant, + List, + ListComp, + Name, + RangeCall, + Tuple, +) +from ..core.function import Function +from ..core.statements import ( + Assign, + AugAssign, + ForLoop, + IfElse, + Return, + TupleUnpack, +) +from ..utils.operators import BINARY_OPERATORS + + +def _indent(code: str, level: int = 1) -> str: + """Add indentation to each line of code.""" + spaces = " " * level + return "\n".join(spaces + line if line else line for line in code.split("\n")) + + +def expr_to_code(expr: Any) -> str: + """Convert an expression model back to Python code.""" + if expr is None: + return "None" + + if isinstance(expr, Name): + return expr.id + + elif isinstance(expr, Constant): + if isinstance(expr.value, str): + return f"'{expr.value}'" + return str(expr.value) + + elif isinstance(expr, BinOp): + op_info = BINARY_OPERATORS[expr.op] + return f"({expr_to_code(expr.left)} {op_info.symbol} {expr_to_code(expr.right)})" + + elif isinstance(expr, RangeCall): + return f"range({expr_to_code(expr.arg)})" + + elif isinstance(expr, CallModel): + args_str = ", ".join(expr_to_code(arg) for arg in expr.args) + kwargs_parts = [] + for k, v in expr.kwargs.items(): + if v is None: + continue + val_str = expr_to_code(v) + if not val_str or val_str.isspace(): + continue + kwargs_parts.append(f"{k}={val_str}") + + 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 isinstance(expr, Tuple): + if len(expr.elements) == 0: + return "()" + elif len(expr.elements) == 1: + return f"({expr_to_code(expr.elements[0])},)" + return f"({', '.join(expr_to_code(e) for e in expr.elements)})" + + elif isinstance(expr, List): + if not expr.elements: + return "[]" + elements = [expr_to_code(e) for e in expr.elements] + elements_str = ", ".join(elements) + return f"[{elements_str}]" + + elif isinstance(expr, ListComp): + return f"[{expr_to_code(expr.element)} for {expr.target} in {expr_to_code(expr.iter)}]" + + else: + raise ValueError(f"Unsupported expression type: {type(expr)}") + + +def stmt_to_code(stmt: Any) -> str: + """Convert a statement model back to Python 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)}" + + elif isinstance(stmt, AugAssign): + op_map = { + "Add": "+=", + "Sub": "-=", + "Mult": "*=", + "Div": "/=", + } + op_str = op_map.get(stmt.op, f"{stmt.op}=") + return f"{stmt.target} {op_str} {expr_to_code(stmt.value)}" + + elif isinstance(stmt, IfElse): + code = [f"if {expr_to_code(stmt.condition)}:"] + code.append(_indent("\n".join(stmt_to_code(s) for s in stmt.body))) + if stmt.orelse: + code.append("else:") + code.append(_indent("\n".join(stmt_to_code(s) for s in stmt.orelse))) + return "\n".join(code) + + elif isinstance(stmt, ForLoop): + code = [f"for {stmt.target} in {expr_to_code(stmt.iter)}:"] + code.append(_indent("\n".join(stmt_to_code(s) for s in stmt.body))) + return "\n".join(code) + + elif isinstance(stmt, Return): + return f"return {expr_to_code(stmt.value)}" + + elif 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])}" + else: + # Multiple expressions + values = ", ".join(expr_to_code(v) for v in stmt.values) + return f"{targets} = {values}" + + else: + raise ValueError(f"Unsupported statement type: {type(stmt)}") + + +def model_to_function(func: Function) -> str: + """Convert a Function model back to Python code.""" + # Build the function signature + args_with_defaults = [] + for arg in func.args: + if arg in func.defaults: + default_val = func.defaults[arg] + if isinstance(default_val, int | float | bool | str): + args_with_defaults.append(f"{arg}={default_val}") + else: + args_with_defaults.append(f"{arg}={expr_to_code(default_val)}") + else: + args_with_defaults.append(arg) + + signature = f"def {func.name}({', '.join(args_with_defaults)}):" + + # Convert the function body + body_lines = [] + for stmt in func.body: + line = stmt_to_code(stmt) + body_lines.append(line) + + body = "\n".join(body_lines) if body_lines else "pass" + return f"{signature}\n{_indent(body)}" diff --git a/flow360/component/simulation/blueprint/codegen/parser.py b/flow360/component/simulation/blueprint/codegen/parser.py new file mode 100644 index 000000000..c20757c68 --- /dev/null +++ b/flow360/component/simulation/blueprint/codegen/parser.py @@ -0,0 +1,269 @@ +import ast +import inspect +from collections.abc import Callable +from typing import Any + +from ..core.context import EvaluationContext +from ..core.expressions import ( + BinOp, + UnaryOp, + CallModel, + Constant, + ListComp, + Name, + RangeCall, + Tuple, + Expression, +) +from ..core.expressions import ( + List as ListExpr, +) +from ..core.function import Function +from ..core.statements import ( + Assign, + AugAssign, + ForLoop, + IfElse, + Return, + TupleUnpack, +) + + +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) + + elif isinstance(node, ast.Constant): + if hasattr(node, "value"): + return Constant(value=node.value) + else: + return Constant(value=node.s) + + elif isinstance(node, ast.Attribute): + # Handle attribute access (e.g., td.inf) + parts = [] + current = node + while isinstance(current, ast.Attribute): + parts.append(current.attr) + current = current.value + if isinstance(current, ast.Name): + 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)}") + + elif isinstance(node, ast.UnaryOp): + return UnaryOp(op=type(node.op).__name__, operand=parse_expr(node.operand, ctx)) + + elif 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 len(node.ops) > 1 or len(node.comparators) > 1: + raise ValueError("Only single comparisons are supported") + return BinOp( + op=type(node.ops[0]).__name__, + left=parse_expr(node.left, ctx), + right=parse_expr(node.comparators[0], ctx), + ) + + elif 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)) + + # Build the full qualified name for the function + if isinstance(node.func, ast.Name): + func_name = node.func.id + elif isinstance(node.func, ast.Attribute): + # Handle nested attributes (e.g., td.GridSpec.auto) + parts = [] + current = node.func + while isinstance(current, ast.Attribute): + parts.append(current.attr) + current = current.value + if isinstance(current, ast.Name): + parts.append(current.id) + func_name = ".".join(reversed(parts)) + else: + raise ValueError(f"Unsupported function call: {ast.dump(node)}") + else: + raise ValueError(f"Unsupported function call: {ast.dump(node)}") + + # Parse arguments + args = [parse_expr(arg, ctx) for arg in node.args] + kwargs = { + kw.arg: parse_expr(kw.value, ctx) + for kw in node.keywords + if kw.arg is not None and kw.value is not None # Ensure value is not None + } + + return CallModel( + func_qualname=func_name, + args=args, + kwargs=kwargs, + ) + + elif isinstance(node, ast.Tuple): + return Tuple(elements=[parse_expr(elt, ctx) for elt in node.elts]) + + elif isinstance(node, ast.List): + return ListExpr(elements=[parse_expr(elt, ctx) for elt in node.elts]) + + elif isinstance(node, ast.ListComp): + if len(node.generators) != 1: + raise ValueError("Only single-generator list comprehensions are supported") + gen = node.generators[0] + if not isinstance(gen.target, ast.Name): + 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( + element=parse_expr(node.elt, ctx), + target=gen.target.id, + iter=parse_expr(gen.iter, ctx), + ) + + else: + raise ValueError(f"Unsupported expression type: {type(node)}") + + +def parse_stmt(node: ast.AST, ctx: EvaluationContext) -> Any: + """Parse a Python AST statement into our intermediate representation.""" + if isinstance(node, ast.Assign): + if len(node.targets) > 1: + raise ValueError("Multiple assignment targets not supported") + target = node.targets[0] + + if isinstance(target, ast.Name): + return Assign(target=target.id, value=parse_expr(node.value, ctx)) + elif 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)}") + + elif isinstance(node, ast.AugAssign): + if not isinstance(node.target, ast.Name): + raise ValueError("Only simple names supported in augmented assignment") + return AugAssign( + target=node.target.id, + op=type(node.op).__name__, + value=parse_expr(node.value, ctx), + ) + + elif 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): + 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 not isinstance(node.target, ast.Name): + raise ValueError("Only simple names supported as loop targets") + return ForLoop( + target=node.target.id, + iter=parse_expr(node.iter, ctx), + body=[parse_stmt(stmt, ctx) for stmt in node.body], + ) + + elif 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)}") + + +def function_to_model( + source: str | Callable[..., Any], + ctx: EvaluationContext | None = None, +) -> 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 + """ + if ctx is None: + ctx = EvaluationContext() + + # Convert function object to source string if needed + if callable(source) and not isinstance(source, str): + source = inspect.getsource(source) + + # Parse the source code into an AST + tree = ast.parse(source) + + # We expect a single function definition + if ( + not isinstance(tree, ast.Module) + or len(tree.body) != 1 + or not isinstance(tree.body[0], ast.FunctionDef) + ): + raise ValueError("Expected a single function definition") + + func_def = tree.body[0] + + # Extract function name and arguments + name = func_def.name + args = [arg.arg for arg in func_def.args.args] + defaults: dict[str, Any] = {} + + # Handle default values for arguments + default_offset = len(func_def.args.args) - len(func_def.args.defaults) + for i, default in enumerate(func_def.args.defaults): + arg_name = func_def.args.args[i + default_offset].arg + if isinstance(default, ast.Constant): + defaults[arg_name] = default.value + else: + defaults[arg_name] = parse_expr(default, ctx) + + # 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) + + +def expression_to_model( + source: str, + ctx: EvaluationContext | None = None, +) -> Expression: + """Parse a Python rvalue expression + + Args: + source: a string containing the source + ctx: Optional evaluation context + """ + if ctx is None: + ctx = EvaluationContext() + + # Parse the source code into an AST + tree = ast.parse(source) + + body = tree.body[0] + + # We expect a single line expression + if not isinstance(tree, ast.Module) or len(tree.body) != 1 or not isinstance(body, ast.Expr): + raise ValueError("Expected a single-line rvalue expression") + + expression = parse_expr(body.value, ctx) + + return expression diff --git a/flow360/component/simulation/blueprint/core/__init__.py b/flow360/component/simulation/blueprint/core/__init__.py new file mode 100644 index 000000000..7e37f4bb6 --- /dev/null +++ b/flow360/component/simulation/blueprint/core/__init__.py @@ -0,0 +1,100 @@ +"""Core blueprint functionality.""" + +from .context import EvaluationContext, ReturnValue +from .expressions import ( + BinOp, + CallModel, + Constant, + Expression, + ExpressionType, + List, + ListComp, + Name, + RangeCall, + Tuple, +) +from .function import Function +from .statements import ( + Assign, + AugAssign, + ForLoop, + IfElse, + Return, + Statement, + StatementType, + TupleUnpack, +) + + +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, + "ExpressionType": ExpressionType, + # Statement types + "Assign": Assign, + "AugAssign": AugAssign, + "IfElse": IfElse, + "ForLoop": ForLoop, + "Return": Return, + "TupleUnpack": TupleUnpack, + "StatementType": StatementType, + # Function type + "Function": Function, + } + + # 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) + + # 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) + + # Finally update Function class + Function.model_rebuild(_types_namespace=namespace) + + +# Update forward references +_model_rebuild() + + +__all__ = [ + "Expression", + "Name", + "Constant", + "BinOp", + "RangeCall", + "CallModel", + "Tuple", + "List", + "ListComp", + "ExpressionType", + "Statement", + "Assign", + "AugAssign", + "IfElse", + "ForLoop", + "Return", + "TupleUnpack", + "StatementType", + "Function", + "EvaluationContext", + "ReturnValue", +] diff --git a/flow360/component/simulation/blueprint/core/context.py b/flow360/component/simulation/blueprint/core/context.py new file mode 100644 index 000000000..387464547 --- /dev/null +++ b/flow360/component/simulation/blueprint/core/context.py @@ -0,0 +1,41 @@ +from typing import Any + +from ..utils.whitelisted import get_allowed_callable + + +class ReturnValue(Exception): + """ + Custom exception to signal a 'return' during the evaluation + of a function model. + """ + + def __init__(self, value: Any): + super().__init__("Function returned.") + self.value = value + + +class EvaluationContext: + """ + Manages variable scope and access during function evaluation. + """ + + def __init__(self, initial_values: dict[str, Any] | None = None) -> None: + self._values = initial_values or {} + + def get(self, name: str) -> Any: + if name not in self._values: + # Try loading from whitelisted callables/constants if possible + try: + val = get_allowed_callable(name) + # If successful, store it so we don't need to import again + self._values[name] = val + except ValueError as err: + raise NameError(f"Name '{name}' is not defined") from err + return self._values[name] + + def set(self, name: str, value: Any) -> None: + self._values[name] = value + + def copy(self) -> "EvaluationContext": + """Create a copy of the current context.""" + return EvaluationContext(dict(self._values)) diff --git a/flow360/component/simulation/blueprint/core/expressions.py b/flow360/component/simulation/blueprint/core/expressions.py new file mode 100644 index 000000000..94f6503a3 --- /dev/null +++ b/flow360/component/simulation/blueprint/core/expressions.py @@ -0,0 +1,198 @@ +from typing import Annotated, Any, Literal, Union + +import pydantic as pd + +from ..utils.whitelisted import get_allowed_callable +from .context import EvaluationContext + +ExpressionType = Annotated[ + Union[ + "Name", + "Constant", + "BinOp", + "RangeCall", + "CallModel", + "Tuple", + "List", + "ListComp", + ], + pd.Field(discriminator="type"), +] + + +class Expression(pd.BaseModel): + """ + Base class for expressions (like x > 3, range(n), etc.). + """ + + def evaluate(self, context: EvaluationContext) -> Any: + raise NotImplementedError + + +class Name(Expression): + type: Literal["Name"] = "Name" + id: str + + def evaluate(self, context: EvaluationContext) -> Any: + return context.get(self.id) + + +class Constant(Expression): + type: Literal["Constant"] = "Constant" + value: Any + + def evaluate(self, context: EvaluationContext) -> Any: # noqa: ARG002 + return self.value + + +class UnaryOp(Expression): + type: Literal["UnaryOp"] = "UnaryOp" + op: str + operand: "ExpressionType" + + def evaluate(self, context: EvaluationContext) -> Any: + from ..utils.operators import UNARY_OPERATORS + + operand_val = self.operand.evaluate(context) + + if self.op not in UNARY_OPERATORS: + raise ValueError(f"Unsupported operator: {self.op}") + + return UNARY_OPERATORS[self.op](operand_val) + + +class BinOp(Expression): + """ + For simplicity, we use the operator's class name as a string + (e.g. 'Add', 'Sub', 'Gt', etc.). + """ + + type: Literal["BinOp"] = "BinOp" + left: "ExpressionType" + op: str + right: "ExpressionType" + + def evaluate(self, context: EvaluationContext) -> Any: + from ..utils.operators import BINARY_OPERATORS + + left_val = self.left.evaluate(context) + right_val = self.right.evaluate(context) + + if self.op not in BINARY_OPERATORS: + raise ValueError(f"Unsupported operator: {self.op}") + + return BINARY_OPERATORS[self.op](left_val, right_val) + + +class RangeCall(Expression): + """ + Model for something like range(). + """ + + type: Literal["RangeCall"] = "RangeCall" + arg: "ExpressionType" + + def evaluate(self, context: EvaluationContext) -> range: + return range(self.arg.evaluate(context)) + + +class CallModel(Expression): + """Represents a function or method call expression. + + This class handles both direct function calls and method calls through a fully qualified name. + For example: + - Simple function: "sum" + - Method call: "np.array" + - Nested attribute: "td.GridSpec.auto" + """ + + type: Literal["CallModel"] = "CallModel" + func_qualname: str + args: list["ExpressionType"] = [] + kwargs: dict[str, "ExpressionType"] = {} + + def evaluate(self, context: EvaluationContext) -> 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(".") + + if len(parts) == 1: + # Direct function call + func = get_allowed_callable(parts[0], context) + else: + # Method or nested attribute call + base = get_allowed_callable(parts[0], context) + + # Traverse the attribute chain + for part in parts[1:-1]: + base = getattr(base, part) + + # Get the final callable + func = getattr(base, parts[-1]) + + # Evaluate arguments + args = [arg.evaluate(context) for arg in self.args] + kwargs = {k: v.evaluate(context) for k, v in self.kwargs.items()} + + return func(*args, **kwargs) + + except AttributeError as e: + raise ValueError( + f"Invalid attribute in call chain '{self.func_qualname}': {str(e)}" + ) from e + except Exception as e: + raise ValueError(f"Error evaluating call to '{self.func_qualname}': {str(e)}") from e + + +class Tuple(Expression): + """Model for tuple expressions.""" + + type: Literal["Tuple"] = "Tuple" + elements: list["ExpressionType"] + + def evaluate(self, context: EvaluationContext) -> tuple: + return tuple(elem.evaluate(context) for elem in self.elements) + + +class List(Expression): + """Model for list expressions.""" + + type: Literal["List"] = "List" + elements: list["ExpressionType"] + + def evaluate(self, context: EvaluationContext) -> list: + return [elem.evaluate(context) for elem in self.elements] + + +class ListComp(Expression): + """Model for list comprehension expressions.""" + + type: Literal["ListComp"] = "ListComp" + element: "ExpressionType" # The expression to evaluate for each item + target: str # The loop variable name + iter: "ExpressionType" # The iterable expression + + def evaluate(self, context: EvaluationContext) -> list: + result = [] + iterable = self.iter.evaluate(context) + 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)) + return result diff --git a/flow360/component/simulation/blueprint/core/function.py b/flow360/component/simulation/blueprint/core/function.py new file mode 100644 index 000000000..88feb55a9 --- /dev/null +++ b/flow360/component/simulation/blueprint/core/function.py @@ -0,0 +1,39 @@ +from typing import Any + +import pydantic as pd + +from .context import EvaluationContext, ReturnValue +from .statements import StatementType + + +class Function(pd.BaseModel): + """ + Represents an entire function: + def name(arg1, arg2, ...): + + """ + + name: str + args: list[str] + defaults: dict[str, Any] + body: list[StatementType] + + def __call__(self, *call_args: Any) -> Any: + # Create empty context first + context = EvaluationContext() + + # Add default values + for arg_name, default_val in self.defaults.items(): + 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) + + try: + for stmt in self.body: + stmt.evaluate(context) + except ReturnValue as rv: + return rv.value + + return None diff --git a/flow360/component/simulation/blueprint/core/statements.py b/flow360/component/simulation/blueprint/core/statements.py new file mode 100644 index 000000000..bf7cc3a85 --- /dev/null +++ b/flow360/component/simulation/blueprint/core/statements.py @@ -0,0 +1,137 @@ +from typing import Annotated, Literal, Union + +import pydantic as pd + +from .context import EvaluationContext, ReturnValue +from .expressions import ExpressionType + +# Forward declaration of type +StatementType = Annotated[ + Union[ + "Assign", + "AugAssign", + "IfElse", + "ForLoop", + "Return", + "TupleUnpack", + ], + pd.Field(discriminator="type"), +] + + +class Statement(pd.BaseModel): + """ + Base class for statements (like 'if', 'for', assignments, etc.). + """ + + def evaluate(self, context: EvaluationContext) -> None: + raise NotImplementedError + + +class Assign(Statement): + """ + Represents something like 'result = '. + """ + + type: Literal["Assign"] = "Assign" + target: str + value: ExpressionType + + def evaluate(self, context: EvaluationContext) -> None: + context.set(self.target, self.value.evaluate(context)) + + +class AugAssign(Statement): + """ + Represents something like 'result += '. + The 'op' is again the operator class name (e.g. 'Add', 'Mult', etc.). + """ + + type: Literal["AugAssign"] = "AugAssign" + target: str + op: str + value: ExpressionType + + def evaluate(self, context: EvaluationContext) -> None: + old_val = context.get(self.target) + increment = self.value.evaluate(context) + if self.op == "Add": + context.set(self.target, old_val + increment) + elif self.op == "Sub": + context.set(self.target, old_val - increment) + elif self.op == "Mult": + context.set(self.target, old_val * increment) + elif self.op == "Div": + context.set(self.target, old_val / increment) + else: + raise ValueError(f"Unsupported augmented assignment operator: {self.op}") + + +class IfElse(Statement): + """ + Represents an if/else block: + if condition: + + else: + + """ + + type: Literal["IfElse"] = "IfElse" + condition: ExpressionType + body: list["StatementType"] + orelse: list["StatementType"] + + def evaluate(self, context: EvaluationContext) -> None: + if self.condition.evaluate(context): + for stmt in self.body: + stmt.evaluate(context) + else: + for stmt in self.orelse: + stmt.evaluate(context) + + +class ForLoop(Statement): + """ + Represents a for loop: + for in : + + """ + + type: Literal["ForLoop"] = "ForLoop" + target: str + iter: ExpressionType + body: list["StatementType"] + + def evaluate(self, context: EvaluationContext) -> None: + iterable = self.iter.evaluate(context) + for item in iterable: + context.set(self.target, item) + for stmt in self.body: + stmt.evaluate(context) + + +class Return(Statement): + """ + 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) -> None: + val = self.value.evaluate(context) + raise ReturnValue(val) + + +class TupleUnpack(Statement): + """Model for tuple unpacking assignments.""" + + type: Literal["TupleUnpack"] = "TupleUnpack" + 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): + context.set(target, value) diff --git a/flow360/component/simulation/blueprint/utils/__init__.py b/flow360/component/simulation/blueprint/utils/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/flow360/component/simulation/blueprint/utils/operators.py b/flow360/component/simulation/blueprint/utils/operators.py new file mode 100644 index 000000000..249f15546 --- /dev/null +++ b/flow360/component/simulation/blueprint/utils/operators.py @@ -0,0 +1,47 @@ +import operator +from collections.abc import Callable +from typing import Any, Union + + +class OpInfo: + """Class to hold operator information.""" + + def __init__( + self, func: Union[Callable[[Any], Any], Callable[[Any, Any], Any]], symbol: str + ) -> None: + self.func = func + self.symbol = symbol + + def __call__(self, *args: Any) -> Any: + return self.func(*args) + + +UNARY_OPERATORS = { + "UAdd": OpInfo(operator.pos, "+"), + "USub": OpInfo(operator.neg, "-"), +} + +BINARY_OPERATORS = { + # Arithmetic operators + "Add": OpInfo(operator.add, "+"), + "Sub": OpInfo(operator.sub, "-"), + "Mult": OpInfo(operator.mul, "*"), + "Div": OpInfo(operator.truediv, "/"), + "FloorDiv": OpInfo(operator.floordiv, "//"), + "Mod": OpInfo(operator.mod, "%"), + "Pow": OpInfo(operator.pow, "**"), + # Comparison operators + "Eq": OpInfo(operator.eq, "=="), + "NotEq": OpInfo(operator.ne, "!="), + "Lt": OpInfo(operator.lt, "<"), + "LtE": OpInfo(operator.le, "<="), + "Gt": OpInfo(operator.gt, ">"), + "GtE": OpInfo(operator.ge, ">="), + "Is": OpInfo(operator.is_, "is"), + # Bitwise operators + "BitAnd": OpInfo(operator.and_, "&"), + "BitOr": OpInfo(operator.or_, "|"), + "BitXor": OpInfo(operator.xor, "^"), + "LShift": OpInfo(operator.lshift, "<<"), + "RShift": OpInfo(operator.rshift, ">>"), +} diff --git a/flow360/component/simulation/blueprint/utils/whitelisted.py b/flow360/component/simulation/blueprint/utils/whitelisted.py new file mode 100644 index 000000000..967fc803c --- /dev/null +++ b/flow360/component/simulation/blueprint/utils/whitelisted.py @@ -0,0 +1,240 @@ +"""Whitelisted functions and classes that can be called from blueprint functions.""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + + +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}") + + +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 + + +def _unit_list(): + import unyt + + unit_symbols = set() + + for key, 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_utilities(name: str) -> Callable[..., Any]: + """Import and return a utility callable.""" + from rich import print + + callables = { + "print": print, + } + return callables[name] + + +# Single source of truth for whitelisted callables +WHITELISTED_CALLABLES = { + "tidy3d.core": { + "prefix": "td.", + "callables": [ + "Medium", + "GridSpec", + "Box", + "Structure", + "Simulation", + "BoundarySpec", + "Periodic", + "ModeSpec", + "inf", + ], + }, + "tidy3d.plugins": { + "prefix": "", + "callables": ["ModeSolver"], + }, + "tidy3d.constants": { + "prefix": "", + "callables": ["C_0"], + }, + "utilities": { + "prefix": "", + "callables": ["print"], + }, + "flow360": {"prefix": "u.", "callables": _unit_list()}, +} + +# Define allowed modules +ALLOWED_MODULES = { + "ModeSolver", # For the ModeSolver class + "tidy3d", # For the tidy3d module + "td", # For the tidy3d alias +} + +# Generate ALLOWED_CALLABLES from the single source of truth +ALLOWED_CALLABLES = { + "td": None, + **{ + f"{group['prefix']}{name}": None + for group in WHITELISTED_CALLABLES.values() + for name in group["callables"] + }, +} + +# Generate import category mapping +_IMPORT_FUNCTIONS = { + ("fl", "u"): _import_flow360, + ("td", "td.", "ModeSolver", "C_0"): _import_tidy3d, + ("print",): _import_utilities, +} + + +class CallableResolver: + """Manages resolution and validation of callable objects. + + Provides a unified interface for resolving function names, methods, and + attributes while enforcing whitelisting rules. + """ + + def __init__(self) -> 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 + + def register_module(self, name: str, module: Any) -> None: + """Register a module for attribute access.""" + self._allowed_modules[name] = module + + def get_callable( + self, + qualname: str, + context: EvaluationContext | None = None, # noqa: F821 + ) -> Callable[..., Any]: + """Resolve a callable by its qualified name. + + Args: + qualname: Fully qualified name (e.g., "np.array" or "len") + context: Optional evaluation context for local lookups + + Returns: + The resolved callable object + + Raises: + ValueError: If the callable is not allowed or cannot be found + """ + # Try context first if provided + if context is not None: + try: + return context.get(qualname) + except KeyError: + pass + + # Check direct allowed callables + 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(".") + if module_name in self._allowed_modules: + obj = self._allowed_modules[module_name] + for part in attr_parts: + obj = getattr(obj, part) + if qualname in ALLOWED_CALLABLES: + return obj + # Try importing if it's a whitelisted callable + if qualname in ALLOWED_CALLABLES: + for names, import_func in _IMPORT_FUNCTIONS.items(): + if module_name in names: + module = import_func(module_name) + self.register_module(module_name, module) + obj = module + for part in attr_parts: + obj = getattr(obj, part) + return obj + + raise ValueError(f"Callable '{qualname}' is not allowed") + + +# Create global resolver instance +resolver = CallableResolver() + + +def get_allowed_callable( + qualname: str, + context: EvaluationContext | None = None, # noqa: F821 +) -> Callable[..., Any]: + """Get an allowed callable by name.""" + # Try getting from resolver first + try: + return resolver.get_callable(qualname, context) + except ValueError as e: + # Check if it's a whitelisted callable before trying to import + if ( + qualname in ALLOWED_CALLABLES + or qualname in ALLOWED_MODULES + or any( + qualname.startswith(f"{group['prefix']}{name}") + for group in WHITELISTED_CALLABLES.values() + for name in group["callables"] + ) + ): + # If found in resolver, try importing on demand + for names, import_func in _IMPORT_FUNCTIONS.items(): + if qualname in names or any(qualname.startswith(prefix) for prefix in names): + callable_obj = import_func(qualname) + resolver.register_callable(qualname, callable_obj) + return callable_obj + raise ValueError(f"Callable '{qualname}' is not allowed") from e diff --git a/flow360/component/simulation/expressions.py b/flow360/component/simulation/expressions.py new file mode 100644 index 000000000..dbcd6d152 --- /dev/null +++ b/flow360/component/simulation/expressions.py @@ -0,0 +1,341 @@ +from typing import get_origin, Generic, TypeVar, Self +import re + +from flow360.component.simulation.unit_system import * +from flow360.component.simulation.blueprint.core import EvaluationContext +from flow360.component.simulation.framework.base_model import Flow360BaseModel +from flow360.component.simulation.blueprint import expression_to_model + +import pydantic as pd +from numbers import Number +from unyt import Unit, unyt_quantity, unyt_array + +_global_ctx: EvaluationContext = EvaluationContext() + + +def _is_descendant_of(t, base): + if t is None: + return False + origin = get_origin(t) or t + return issubclass(origin, base) + + +def _is_number_string(s: str) -> bool: + try: + float(s) + return True + except ValueError: + return False + + +def _split_keep_delimiters(input: str, delimiters: list) -> list: + escaped_delimiters = [re.escape(d) for d in delimiters] + pattern = f"({'|'.join(escaped_delimiters)})" + result = re.split(pattern, input) + return [part for part in result if part != ""] + + +def _convert_argument(other): + parenthesize = False + unit_delimiters = ["+", "-", "*", "/", "(", ")"] + if isinstance(other, Expression): + arg = other.body + parenthesize = True + elif isinstance(other, Variable): + arg = other.name + elif isinstance(other, Number): + arg = str(other) + elif isinstance(other, Unit): + unit = str(other) + tokens = _split_keep_delimiters(unit, unit_delimiters) + arg = "" + for token in tokens: + if token not in unit_delimiters and not _is_number_string(token): + token = f"u.{token}" + arg += token + else: + arg += token + elif isinstance(other, unyt_quantity): + unit = str(other.units) + tokens = _split_keep_delimiters(unit, unit_delimiters) + arg = f"{str(other.value)} * " + for token in tokens: + if token not in unit_delimiters and not _is_number_string(token): + token = f"u.{token}" + arg += token + else: + arg += token + elif isinstance(other, unyt_array): + unit = str(other.units) + tokens = _split_keep_delimiters(unit, unit_delimiters) + arg = f"{str(other.value)} * " + for token in tokens: + if token not in unit_delimiters and not _is_number_string(token): + token = f"u.{token}" + arg += token + else: + arg += token + else: + raise ValueError(f"Incompatible argument of type {type(other)}") + return arg, parenthesize + + +class Variable(Flow360BaseModel): + name: str = pd.Field() + value: Union[list[float], float, unyt_quantity, unyt_array] = pd.Field() + + model_config = pd.ConfigDict(validate_assignment=True) + + @pd.model_validator(mode="after") + @classmethod + def update_context(cls, value): + _global_ctx.set(value.name, value.value) + + def __add__(self, other): + (arg, parenthesize) = _convert_argument(other) + str_arg = arg if not parenthesize else f"({arg})" + return Expression(body=f"{self.name} + {str_arg}") + + def __sub__(self, other): + (arg, parenthesize) = _convert_argument(other) + str_arg = arg if not parenthesize else f"({arg})" + return Expression(body=f"{self.name} - {str_arg}") + + def __mul__(self, other): + (arg, parenthesize) = _convert_argument(other) + str_arg = arg if not parenthesize else f"({arg})" + return Expression(body=f"{self.name} * {str_arg}") + + def __truediv__(self, other): + (arg, parenthesize) = _convert_argument(other) + str_arg = arg if not parenthesize else f"({arg})" + return Expression(body=f"{self.name} / {str_arg}") + + def __floordiv__(self, other): + (arg, parenthesize) = _convert_argument(other) + str_arg = arg if not parenthesize else f"({arg})" + return Expression(body=f"{self.name} // {str_arg}") + + def __mod__(self, other): + (arg, parenthesize) = _convert_argument(other) + str_arg = arg if not parenthesize else f"({arg})" + return Expression(body=f"{self.name} % {str_arg}") + + def __pow__(self, other): + (arg, parenthesize) = _convert_argument(other) + str_arg = arg if not parenthesize else f"({arg})" + return Expression(body=f"{self.name} ** {str_arg}") + + def __neg__(self): + return Expression(body=f"-{self.name}") + + def __pos__(self): + return Expression(body=f"+{self.name}") + + def __abs__(self): + return Expression(body=f"abs({self.name})") + + def __radd__(self, other): + (arg, parenthesize) = _convert_argument(other) + str_arg = arg if not parenthesize else f"({arg})" + return Expression(body=f"{str_arg} + {self.name}") + + def __rsub__(self, other): + (arg, parenthesize) = _convert_argument(other) + str_arg = arg if not parenthesize else f"({arg})" + return Expression(body=f"{str_arg} - {self.name}") + + def __rmul__(self, other): + (arg, parenthesize) = _convert_argument(other) + str_arg = arg if not parenthesize else f"({arg})" + return Expression(body=f"{str_arg} * {self.name}") + + def __rtruediv__(self, other): + (arg, parenthesize) = _convert_argument(other) + str_arg = arg if not parenthesize else f"({arg})" + return Expression(body=f"{str_arg} / {self.name}") + + def __rfloordiv__(self, other): + (arg, parenthesize) = _convert_argument(other) + str_arg = arg if not parenthesize else f"({arg})" + return Expression(body=f"{str_arg} // {self.name}") + + def __rmod__(self, other): + (arg, parenthesize) = _convert_argument(other) + str_arg = arg if not parenthesize else f"({arg})" + return Expression(body=f"{str_arg} % {self.name}") + + def __rpow__(self, other): + (arg, parenthesize) = _convert_argument(other) + str_arg = arg if not parenthesize else f"({arg})" + return Expression(body=f"{str_arg} ** {self.name}") + + def __str__(self): + return self.name + + def __repr__(self): + return f"Flow360Variable({self.name} = {self.value})" + + +def _get_internal_validator(internal_type): + def _internal_validator(value: Expression): + result = value.evaluate() + pd.TypeAdapter(internal_type).validate_python(result) + return value + + return _internal_validator + + +class Expression(Flow360BaseModel): + body: str = pd.Field("") + + model_config = pd.ConfigDict(validate_assignment=True) + + @pd.model_validator(mode="wrap") + @classmethod + def _validate_expression(cls, value, handler) -> Self: + if isinstance(value, str): + body = value + elif isinstance(value, dict) and "body" in value.keys(): + body = value["body"] + elif isinstance(value, Expression): + body = value.body + elif isinstance(value, Variable): + body = str(value) + else: + details = InitErrorDetails( + type="value_error", ctx={"error": f"Invalid type {type(value)}"} + ) + raise pd.ValidationError.from_exception_data("expression type error", [details]) + + try: + _ = expression_to_model(body) + except SyntaxError as s_err: + details = InitErrorDetails(type="value_error", ctx={"error": s_err}) + raise pd.ValidationError.from_exception_data("expression syntax error", [details]) + except ValueError as v_err: + details = InitErrorDetails(type="value_error", ctx={"error": v_err}) + raise pd.ValidationError.from_exception_data("expression value error", [details]) + + return handler({"body": body}) + + def evaluate(self) -> float: + expr = expression_to_model(self.body) + result = expr.evaluate(_global_ctx) + return result + + def __add__(self, other): + (arg, parenthesize) = _convert_argument(other) + str_arg = arg if not parenthesize else f"({arg})" + return Expression(body=f"{self.body} + {str_arg}") + + def __sub__(self, other): + (arg, parenthesize) = _convert_argument(other) + str_arg = arg if not parenthesize else f"({arg})" + return Expression(body=f"{self.body} - {str_arg}") + + def __mul__(self, other): + (arg, parenthesize) = _convert_argument(other) + str_arg = arg if not parenthesize else f"({arg})" + return Expression(body=f"({self.body}) * {str_arg}") + + def __truediv__(self, other): + (arg, parenthesize) = _convert_argument(other) + str_arg = arg if not parenthesize else f"({arg})" + return Expression(body=f"({self.body}) / {str_arg}") + + def __floordiv__(self, other): + (arg, parenthesize) = _convert_argument(other) + str_arg = arg if not parenthesize else f"({arg})" + return Expression(body=f"({self.body}) // {str_arg}") + + def __mod__(self, other): + (arg, parenthesize) = _convert_argument(other) + str_arg = arg if not parenthesize else f"({arg})" + return Expression(body=f"({self.body}) % {str_arg}") + + def __pow__(self, other): + (arg, parenthesize) = _convert_argument(other) + str_arg = arg if not parenthesize else f"({arg})" + return Expression(body=f"({self.body}) ** {str_arg}") + + def __neg__(self): + return Expression(body=f"-({self.body})") + + def __pos__(self): + return Expression(body=f"+({self.body})") + + def __abs__(self): + return Expression(body=f"abs({self.body})") + + def __radd__(self, other): + (arg, parenthesize) = _convert_argument(other) + str_arg = arg if not parenthesize else f"({arg})" + return Expression(body=f"{str_arg} + {self.body}") + + def __rsub__(self, other): + (arg, parenthesize) = _convert_argument(other) + str_arg = arg if not parenthesize else f"({arg})" + return Expression(body=f"{str_arg} - {self.body}") + + def __rmul__(self, other): + (arg, parenthesize) = _convert_argument(other) + str_arg = arg if not parenthesize else f"({arg})" + return Expression(body=f"{str_arg} * ({self.body})") + + def __rtruediv__(self, other): + (arg, parenthesize) = _convert_argument(other) + str_arg = arg if not parenthesize else f"({arg})" + return Expression(body=f"{str_arg} / ({self.body})") + + def __rfloordiv__(self, other): + (arg, parenthesize) = _convert_argument(other) + str_arg = arg if not parenthesize else f"({arg})" + return Expression(body=f"{str_arg} // ({self.body})") + + def __rmod__(self, other): + (arg, parenthesize) = _convert_argument(other) + str_arg = arg if not parenthesize else f"({arg})" + return Expression(body=f"{str_arg} % ({self.body})") + + def __rpow__(self, other): + (arg, parenthesize) = _convert_argument(other) + str_arg = arg if not parenthesize else f"({arg})" + return Expression(body=f"{str_arg} ** ({self.body})") + + def __str__(self): + return self.body + + def __repr__(self): + return f"Flow360Expression({self.body})" + + +T = TypeVar("T") + + +class ValueOrExpression(Expression, Generic[T]): + + def __class_getitem__(cls, internal_type): + if isinstance(internal_type, Number): + + def _non_dimensional_validator(value): + result = value.evaluate() + if isinstance(result, Number): + return value + msg = "The evaluated value needs to be a non-dimensional scalar" + details = InitErrorDetails(type="value_error", ctx={"error": msg}) + raise pd.ValidationError.from_exception_data("expression value error", [details]) + + expr_type = Annotated[Expression, pd.AfterValidator(_non_dimensional_validator)] + else: + expr_type = Annotated[ + Expression, pd.AfterValidator(_get_internal_validator(internal_type)) + ] + + return Union[internal_type, expr_type] + + @pd.model_validator(mode="wrap") + @classmethod + def _convert_to_dict(cls, value, handler) -> Self: + value = Expression.model_validate(value) + return handler({"body": value.body}) diff --git a/tests/simulation/test_expressions.py b/tests/simulation/test_expressions.py new file mode 100644 index 000000000..3354ad40f --- /dev/null +++ b/tests/simulation/test_expressions.py @@ -0,0 +1,325 @@ +import pytest + +from flow360.component.simulation.expressions import ( + ValueOrExpression, + Variable, + Expression, +) +from flow360.component.simulation.framework.base_model import Flow360BaseModel + +import pydantic as pd +from flow360 import u + +from flow360.component.simulation.unit_system import ( + LengthType, + AngleType, + MassType, + TimeType, + AbsoluteTemperatureType, + VelocityType, + AreaType, + ForceType, + PressureType, + DensityType, + ViscosityType, + PowerType, + MomentType, + AngularVelocityType, + HeatFluxType, + HeatSourceType, + SpecificHeatCapacityType, + InverseAreaType, + MassFlowRateType, + SpecificEnergyType, + FrequencyType, + ThermalConductivityType, + InverseLengthType, +) + + +def test_expression_init(): + class TestModel(Flow360BaseModel): + field: ValueOrExpression[float] = pd.Field() + + # Declare a variable + x = Variable(name="x", value=1) + + # Initialize with value + model_1 = TestModel(field=1) + assert isinstance(model_1.field, float) + assert model_1.field == 1 + assert str(model_1.field) == "1.0" + + # Initialize with variable + model_2 = TestModel(field=x) + assert isinstance(model_2.field, Expression) + assert model_2.field.evaluate() == 1 + assert str(model_2.field) == "x" + + # Initialize with variable and value + model_3 = TestModel(field=x + 1) + assert isinstance(model_3.field, Expression) + assert model_3.field.evaluate() == 2 + assert str(model_3.field) == "x + 1" + + # Initialize with another expression + model_4 = TestModel(field=model_3.field + 1) + assert isinstance(model_4.field, Expression) + assert model_4.field.evaluate() == 3 + assert str(model_4.field) == "x + 1 + 1" + + +def test_variable_reassignment(): + class TestModel(Flow360BaseModel): + field: ValueOrExpression[float] = pd.Field() + + # Declare a variable + x = Variable(name="x", value=1) + + model = TestModel(field=x) + assert isinstance(model.field, Expression) + assert model.field.evaluate() == 1 + assert str(model.field) == "x" + + # Change variable value + x.value = 2 + + assert model.field.evaluate() == 2 + + +def test_expression_operators(): + class TestModel(Flow360BaseModel): + field: ValueOrExpression[float] = pd.Field() + + # Declare two variables + x = Variable(name="x", value=3) + y = Variable(name="y", value=2) + + model = TestModel(field=x + y) + + # Addition + model.field = x + y + assert isinstance(model.field, Expression) + assert model.field.evaluate() == 5 + assert str(model.field) == "x + y" + + # Subtraction + model.field = x - y + assert isinstance(model.field, Expression) + assert model.field.evaluate() == 1 + assert str(model.field) == "x - y" + + # Multiplication + model.field = x * y + assert isinstance(model.field, Expression) + assert model.field.evaluate() == 6 + assert str(model.field) == "x * y" + + # Division + model.field = x / y + assert isinstance(model.field, Expression) + assert model.field.evaluate() == 1.5 + assert str(model.field) == "x / y" + + # Exponentiation + model.field = x**y + assert isinstance(model.field, Expression) + assert model.field.evaluate() == 9 + assert str(model.field) == "x ** y" + + # Modulus + model.field = x % y + assert isinstance(model.field, Expression) + assert model.field.evaluate() == 1 + assert str(model.field) == "x % y" + + # Negation + model.field = -x + assert isinstance(model.field, Expression) + assert model.field.evaluate() == -3 + assert str(model.field) == "-x" + + # Identity + model.field = +x + assert isinstance(model.field, Expression) + 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 + 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" + + +def test_dimensioned_expressions(): + class TestModel(Flow360BaseModel): + length: ValueOrExpression[LengthType] = pd.Field() + angle: ValueOrExpression[AngleType] = pd.Field() + mass: ValueOrExpression[MassType] = pd.Field() + time: ValueOrExpression[TimeType] = pd.Field() + absolute_temp: ValueOrExpression[AbsoluteTemperatureType] = pd.Field() + velocity: ValueOrExpression[VelocityType] = pd.Field() + area: ValueOrExpression[AreaType] = pd.Field() + force: ValueOrExpression[ForceType] = pd.Field() + pressure: ValueOrExpression[PressureType] = pd.Field() + density: ValueOrExpression[DensityType] = pd.Field() + viscosity: ValueOrExpression[ViscosityType] = pd.Field() + power: ValueOrExpression[PowerType] = pd.Field() + moment: ValueOrExpression[MomentType] = pd.Field() + angular_velocity: ValueOrExpression[AngularVelocityType] = pd.Field() + heat_flux: ValueOrExpression[HeatFluxType] = pd.Field() + heat_source: ValueOrExpression[HeatSourceType] = pd.Field() + specific_heat_capacity: ValueOrExpression[SpecificHeatCapacityType] = pd.Field() + thermal_conductivity: ValueOrExpression[ThermalConductivityType] = pd.Field() + inverse_area: ValueOrExpression[InverseAreaType] = pd.Field() + inverse_length: ValueOrExpression[InverseLengthType] = pd.Field() + mass_flow_rate: ValueOrExpression[MassFlowRateType] = pd.Field() + specific_energy: ValueOrExpression[SpecificEnergyType] = pd.Field() + frequency: ValueOrExpression[FrequencyType] = pd.Field() + + model_legacy = TestModel( + length=1 * u.m, + angle=1 * u.rad, + mass=1 * u.kg, + time=1 * u.s, + absolute_temp=1 * u.K, + velocity=1 * u.m / u.s, + area=1 * u.m**2, + force=1 * u.N, + pressure=1 * u.Pa, + density=1 * u.kg / u.m**3, + viscosity=1 * u.Pa * u.s, + power=1 * u.W, + moment=1 * u.N * u.m, + angular_velocity=1 * u.rad / u.s, + heat_flux=1 * u.W / u.m**2, + heat_source=1 * u.kg / u.m / u.s**3, + specific_heat_capacity=1 * u.J / u.kg / u.K, + thermal_conductivity=1 * u.W / u.m / u.K, + inverse_area=1 / u.m**2, + inverse_length=1 / u.m, + mass_flow_rate=1 * u.kg / u.s, + specific_energy=1 * u.J / u.kg, + frequency=1 * u.Hz, + ) + + assert model_legacy + + x = Variable(name="x", value=1) + + model_expression = TestModel( + length=x * u.m, + angle=x * u.rad, + mass=x * u.kg, + time=x * u.s, + absolute_temp=x * u.K, + velocity=x * u.m / u.s, + area=x * u.m**2, + force=x * u.N, + pressure=x * u.Pa, + density=x * u.kg / u.m**3, + viscosity=x * u.Pa * u.s, + power=x * u.W, + moment=x * u.N * u.m, + angular_velocity=x * u.rad / u.s, + heat_flux=x * u.W / u.m**2, + heat_source=x * u.kg / u.m / u.s**3, + specific_heat_capacity=x * u.J / u.kg / u.K, + thermal_conductivity=x * u.W / u.m / u.K, + inverse_area=x / u.m**2, + inverse_length=x / u.m, + mass_flow_rate=x * u.kg / u.s, + specific_energy=x * u.J / u.kg, + frequency=x * u.Hz, + ) + + assert model_expression + + +def test_constrained_scalar_type(): + class TestModel(Flow360BaseModel): + field: ValueOrExpression[pd.confloat(ge=0)] = pd.Field() + + x = Variable(name="x", value=1) + + model = TestModel(field=x) + + assert isinstance(model.field, Expression) + assert model.field.evaluate() == 1 + assert str(model.field) == "x" + + with pytest.raises(pd.ValidationError): + model.field = -x + + +def test_constrained_dimensioned_type(): + class TestModel(Flow360BaseModel): + field: ValueOrExpression[LengthType.Positive] = pd.Field() + + x = Variable(name="x", value=1) + + model = TestModel(field=x * u.m) + + assert isinstance(model.field, Expression) + assert model.field.evaluate() == 1 * u.m + assert str(model.field) == "x * u.m" + + with pytest.raises(pd.ValidationError): + model.field = -x * u.m + + +def test_vector_types(): + class TestModel(Flow360BaseModel): + vector: ValueOrExpression[LengthType.Vector] = pd.Field() + axis: ValueOrExpression[LengthType.Axis] = pd.Field() + array: ValueOrExpression[LengthType.Array] = pd.Field() + direction: ValueOrExpression[LengthType.Direction] = pd.Field() + moment: ValueOrExpression[LengthType.Moment] = pd.Field() + + x = Variable(name="x", value=[1, 0, 0]) + y = Variable(name="y", value=[0, 0, 0]) + z = Variable(name="z", value=[1, 0, 0, 0]) + w = Variable(name="w", value=[1, 1, 1]) + + model = TestModel( + vector=y * u.m, axis=x * u.m, array=z * u.m, direction=x * u.m, moment=w * u.m + ) + + assert isinstance(model.vector, Expression) + assert (model.vector.evaluate() == [0, 0, 0] * u.m).all() + assert str(model.vector) == "y * u.m" + + assert isinstance(model.axis, Expression) + assert (model.axis.evaluate() == [1, 0, 0] * u.m).all() + assert str(model.axis) == "x * u.m" + + assert isinstance(model.array, Expression) + assert (model.array.evaluate() == [1, 0, 0, 0] * u.m).all() + assert str(model.array) == "z * u.m" + + assert isinstance(model.direction, Expression) + assert (model.direction.evaluate() == [1, 0, 0] * u.m).all() + assert str(model.direction) == "x * u.m" + + assert isinstance(model.moment, Expression) + assert (model.moment.evaluate() == [1, 1, 1] * u.m).all() + assert str(model.moment) == "w * u.m" + + with pytest.raises(pd.ValidationError): + model.vector = z * u.m + + with pytest.raises(pd.ValidationError): + model.axis = y * u.m + + with pytest.raises(pd.ValidationError): + model.direction = y * u.m + + with pytest.raises(pd.ValidationError): + model.moment = x * u.m From de228bccd6084100b833ae0fdded456a84aa7a82 Mon Sep 17 00:00:00 2001 From: Andrzej Krupka <156919532+andrzej-krupka@users.noreply.github.com> Date: Thu, 10 Apr 2025 10:43:49 +0200 Subject: [PATCH 02/34] Make core blueprint package framework-agnostic, add support for partial evaluation of builtin solver variables (#858) * Move Flow360 and Tidy3D-specific logic outside of core blueprint module * Add support for partial evaluation of solver variables --- flow360/__init__.py | 3 + .../simulation/blueprint/codegen/parser.py | 4 +- .../simulation/blueprint/core/context.py | 19 +- .../simulation/blueprint/core/expressions.py | 47 ++-- .../simulation/blueprint/core/resolver.py | 112 ++++++++ .../simulation/blueprint/flow360/__init__.py | 1 + .../simulation/blueprint/flow360/symbols.py | 70 +++++ .../simulation/blueprint/tidy3d/__init__.py | 1 + .../simulation/blueprint/tidy3d/symbols.py | 107 ++++++++ .../simulation/blueprint/utils/whitelisted.py | 240 ------------------ .../component/simulation/solver_builtins.py | 3 + .../{expressions.py => user_code.py} | 19 +- flow360/component/simulation/utils.py | 31 +++ tests/simulation/test_expressions.py | 22 +- 14 files changed, 399 insertions(+), 280 deletions(-) create mode 100644 flow360/component/simulation/blueprint/core/resolver.py create mode 100644 flow360/component/simulation/blueprint/flow360/__init__.py create mode 100644 flow360/component/simulation/blueprint/flow360/symbols.py create mode 100644 flow360/component/simulation/blueprint/tidy3d/__init__.py create mode 100644 flow360/component/simulation/blueprint/tidy3d/symbols.py delete mode 100644 flow360/component/simulation/blueprint/utils/whitelisted.py create mode 100644 flow360/component/simulation/solver_builtins.py rename flow360/component/simulation/{expressions.py => user_code.py} (95%) diff --git a/flow360/__init__.py b/flow360/__init__.py index a09e672a6..efb305b1f 100644 --- a/flow360/__init__.py +++ b/flow360/__init__.py @@ -147,6 +147,9 @@ from flow360.component.simulation.user_defined_dynamics.user_defined_dynamics import ( UserDefinedDynamic, ) +from flow360.component.simulation.solver_builtins import ( + example_solver_variable +) from flow360.component.surface_mesh_v2 import SurfaceMeshV2 as SurfaceMesh from flow360.component.volume_mesh import VolumeMeshV2 as VolumeMesh from flow360.environment import Env diff --git a/flow360/component/simulation/blueprint/codegen/parser.py b/flow360/component/simulation/blueprint/codegen/parser.py index c20757c68..94a5db635 100644 --- a/flow360/component/simulation/blueprint/codegen/parser.py +++ b/flow360/component/simulation/blueprint/codegen/parser.py @@ -244,7 +244,7 @@ def function_to_model( def expression_to_model( source: str, - ctx: EvaluationContext | None = None, + ctx: EvaluationContext, ) -> Expression: """Parse a Python rvalue expression @@ -252,8 +252,6 @@ def expression_to_model( source: a string containing the source ctx: Optional evaluation context """ - if ctx is None: - ctx = EvaluationContext() # Parse the source code into an AST tree = ast.parse(source) diff --git a/flow360/component/simulation/blueprint/core/context.py b/flow360/component/simulation/blueprint/core/context.py index 387464547..db012813f 100644 --- a/flow360/component/simulation/blueprint/core/context.py +++ b/flow360/component/simulation/blueprint/core/context.py @@ -1,6 +1,6 @@ from typing import Any -from ..utils.whitelisted import get_allowed_callable +from .resolver import CallableResolver class ReturnValue(Exception): @@ -19,14 +19,17 @@ class EvaluationContext: Manages variable scope and access during function evaluation. """ - def __init__(self, initial_values: dict[str, Any] | None = None) -> None: + def __init__( + self, resolver: CallableResolver, initial_values: dict[str, Any] | None = None + ) -> None: self._values = initial_values or {} + self._resolver = resolver def get(self, name: str) -> Any: if name not in self._values: - # Try loading from whitelisted callables/constants if possible + # Try loading from builtin callables/constants if possible try: - val = get_allowed_callable(name) + val = self.resolve(name) # If successful, store it so we don't need to import again self._values[name] = val except ValueError as err: @@ -36,6 +39,12 @@ def get(self, name: str) -> Any: def set(self, name: str, value: Any) -> None: self._values[name] = value + def resolve(self, name): + return self._resolver.get_allowed_callable(name) + + def can_evaluate(self, name) -> bool: + return self._resolver.can_evaluate(name) + def copy(self) -> "EvaluationContext": """Create a copy of the current context.""" - return EvaluationContext(dict(self._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 94f6503a3..432798559 100644 --- a/flow360/component/simulation/blueprint/core/expressions.py +++ b/flow360/component/simulation/blueprint/core/expressions.py @@ -2,7 +2,6 @@ import pydantic as pd -from ..utils.whitelisted import get_allowed_callable from .context import EvaluationContext ExpressionType = Annotated[ @@ -25,7 +24,7 @@ class Expression(pd.BaseModel): Base class for expressions (like x > 3, range(n), etc.). """ - def evaluate(self, context: EvaluationContext) -> Any: + def evaluate(self, context: EvaluationContext, strict: bool) -> Any: raise NotImplementedError @@ -33,7 +32,9 @@ class Name(Expression): type: Literal["Name"] = "Name" id: str - def evaluate(self, context: EvaluationContext) -> Any: + def evaluate(self, context: EvaluationContext, strict: bool) -> Any: + if strict and not context.can_evaluate(self.id): + raise ValueError(f"Name '{self.id}' cannot be evaluated at client runtime") return context.get(self.id) @@ -41,7 +42,7 @@ class Constant(Expression): type: Literal["Constant"] = "Constant" value: Any - def evaluate(self, context: EvaluationContext) -> Any: # noqa: ARG002 + def evaluate(self, context: EvaluationContext, strict: bool) -> Any: # noqa: ARG002 return self.value @@ -50,10 +51,10 @@ class UnaryOp(Expression): op: str operand: "ExpressionType" - def evaluate(self, context: EvaluationContext) -> Any: + def evaluate(self, context: EvaluationContext, strict: bool) -> Any: from ..utils.operators import UNARY_OPERATORS - operand_val = self.operand.evaluate(context) + operand_val = self.operand.evaluate(context, strict) if self.op not in UNARY_OPERATORS: raise ValueError(f"Unsupported operator: {self.op}") @@ -72,11 +73,11 @@ class BinOp(Expression): op: str right: "ExpressionType" - def evaluate(self, context: EvaluationContext) -> Any: + def evaluate(self, context: EvaluationContext, strict: bool) -> Any: from ..utils.operators import BINARY_OPERATORS - left_val = self.left.evaluate(context) - right_val = self.right.evaluate(context) + left_val = self.left.evaluate(context, strict) + right_val = self.right.evaluate(context, strict) if self.op not in BINARY_OPERATORS: raise ValueError(f"Unsupported operator: {self.op}") @@ -92,8 +93,8 @@ class RangeCall(Expression): type: Literal["RangeCall"] = "RangeCall" arg: "ExpressionType" - def evaluate(self, context: EvaluationContext) -> range: - return range(self.arg.evaluate(context)) + def evaluate(self, context: EvaluationContext, strict: bool) -> range: + return range(self.arg.evaluate(context, strict)) class CallModel(Expression): @@ -111,7 +112,7 @@ class CallModel(Expression): args: list["ExpressionType"] = [] kwargs: dict[str, "ExpressionType"] = {} - def evaluate(self, context: EvaluationContext) -> Any: + 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 @@ -133,10 +134,10 @@ def evaluate(self, context: EvaluationContext) -> Any: if len(parts) == 1: # Direct function call - func = get_allowed_callable(parts[0], context) + func = context.resolve(parts[0]) else: # Method or nested attribute call - base = get_allowed_callable(parts[0], context) + base = context.resolve(parts[0]) # Traverse the attribute chain for part in parts[1:-1]: @@ -146,8 +147,8 @@ def evaluate(self, context: EvaluationContext) -> Any: func = getattr(base, parts[-1]) # Evaluate arguments - args = [arg.evaluate(context) for arg in self.args] - kwargs = {k: v.evaluate(context) for k, v in self.kwargs.items()} + args = [arg.evaluate(context, strict) for arg in self.args] + kwargs = {k: v.evaluate(context, strict) for k, v in self.kwargs.items()} return func(*args, **kwargs) @@ -165,8 +166,8 @@ class Tuple(Expression): type: Literal["Tuple"] = "Tuple" elements: list["ExpressionType"] - def evaluate(self, context: EvaluationContext) -> tuple: - return tuple(elem.evaluate(context) for elem in self.elements) + def evaluate(self, context: EvaluationContext, strict: bool) -> tuple: + return tuple(elem.evaluate(context, strict) for elem in self.elements) class List(Expression): @@ -175,8 +176,8 @@ class List(Expression): type: Literal["List"] = "List" elements: list["ExpressionType"] - def evaluate(self, context: EvaluationContext) -> list: - return [elem.evaluate(context) for elem in self.elements] + def evaluate(self, context: EvaluationContext, strict: bool) -> list: + return [elem.evaluate(context, strict) for elem in self.elements] class ListComp(Expression): @@ -187,12 +188,12 @@ class ListComp(Expression): target: str # The loop variable name iter: "ExpressionType" # The iterable expression - def evaluate(self, context: EvaluationContext) -> list: + def evaluate(self, context: EvaluationContext, strict: bool) -> list: result = [] - iterable = self.iter.evaluate(context) + iterable = self.iter.evaluate(context, strict) 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)) + result.append(self.element.evaluate(iter_context, strict)) return result diff --git a/flow360/component/simulation/blueprint/core/resolver.py b/flow360/component/simulation/blueprint/core/resolver.py new file mode 100644 index 000000000..b4b32d116 --- /dev/null +++ b/flow360/component/simulation/blueprint/core/resolver.py @@ -0,0 +1,112 @@ +"""Whitelisted functions and classes that can be called from blueprint functions.""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + + +class CallableResolver: + """Manages resolution and validation of callable objects. + + Provides a unified interface for resolving function names, methods, and + attributes while enforcing whitelisting rules. + """ + + def __init__(self, callables, modules, imports, blacklist) -> None: + self._import_builtins = imports + self._callable_builtins = callables + self._module_builtins = modules + self._evaluation_blacklist = blacklist + + 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 + + def register_module(self, name: str, module: Any) -> None: + """Register a module for attribute access.""" + self._allowed_modules[name] = module + + def can_evaluate(self, qualname: str) -> bool: + return qualname not in self._evaluation_blacklist + + def get_callable(self, qualname: str) -> Callable[..., Any]: + """Resolve a callable by its qualified name. + + Args: + qualname: Fully qualified name (e.g., "np.array" or "len") + context: Optional evaluation context for local lookups + + Returns: + The resolved callable object + + Raises: + ValueError: If the callable is not allowed or cannot be found + """ + # Check direct allowed callables + 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(".") + if module_name in self._allowed_modules: + obj = self._allowed_modules[module_name] + for part in attr_parts: + obj = getattr(obj, part) + if qualname in self._callable_builtins: + return obj + # Try importing if it's a whitelisted callable + if qualname in self._callable_builtins: + for names, import_func in self._import_builtins.items(): + if module_name in names: + module = import_func(module_name) + self.register_module(module_name, module) + obj = module + for part in attr_parts: + obj = getattr(obj, part) + return obj + + raise ValueError(f"Callable '{qualname}' is not allowed") + + def get_allowed_callable(self, qualname: str) -> Callable[..., Any]: + """Get an allowed callable by name.""" + try: + return self.get_callable(qualname) + except ValueError as e: + # Check if it's a whitelisted callable before trying to import + if ( + qualname in self._callable_builtins + or qualname in self._module_builtins + or any( + qualname.startswith(f"{group['prefix']}{name}") + for group in self._callable_builtins.values() + for name in group["callables"] + ) + ): + # If found in resolver, try importing on demand + for names, import_func in self._import_builtins.items(): + if qualname in names or any(qualname.startswith(prefix) for prefix in names): + callable_obj = import_func(qualname) + self.register_callable(qualname, callable_obj) + return callable_obj + raise ValueError(f"Callable '{qualname}' is not allowed") from e diff --git a/flow360/component/simulation/blueprint/flow360/__init__.py b/flow360/component/simulation/blueprint/flow360/__init__.py new file mode 100644 index 000000000..e2639ab6f --- /dev/null +++ b/flow360/component/simulation/blueprint/flow360/__init__.py @@ -0,0 +1 @@ +from .symbols import resolver diff --git a/flow360/component/simulation/blueprint/flow360/symbols.py b/flow360/component/simulation/blueprint/flow360/symbols.py new file mode 100644 index 000000000..c0806c387 --- /dev/null +++ b/flow360/component/simulation/blueprint/flow360/symbols.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +from typing import Any + +from ..core.resolver import CallableResolver + + +def _unit_list(): + import unyt + + unit_symbols = set() + + for key, 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 + + +WHITELISTED_CALLABLES = { + "flow360.units": { + "prefix": "u.", + "callables": _unit_list(), + "evaluate": True + }, + "flow360.solver_builtins": { + "prefix": "fl.", + "callables": ["example_solver_variable"], + "evaluate": False + } +} + +# Define allowed modules +ALLOWED_MODULES = {"flow360", "fl"} + +ALLOWED_CALLABLES = { + "fl": 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"] + }, +} + +IMPORT_FUNCTIONS = { + ("fl", "u"): _import_flow360, +} + +resolver = CallableResolver(ALLOWED_CALLABLES, ALLOWED_MODULES, IMPORT_FUNCTIONS, EVALUATION_BLACKLIST) diff --git a/flow360/component/simulation/blueprint/tidy3d/__init__.py b/flow360/component/simulation/blueprint/tidy3d/__init__.py new file mode 100644 index 000000000..e2639ab6f --- /dev/null +++ b/flow360/component/simulation/blueprint/tidy3d/__init__.py @@ -0,0 +1 @@ +from .symbols import resolver diff --git a/flow360/component/simulation/blueprint/tidy3d/symbols.py b/flow360/component/simulation/blueprint/tidy3d/symbols.py new file mode 100644 index 000000000..7a0aca1d3 --- /dev/null +++ b/flow360/component/simulation/blueprint/tidy3d/symbols.py @@ -0,0 +1,107 @@ +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/whitelisted.py b/flow360/component/simulation/blueprint/utils/whitelisted.py deleted file mode 100644 index 967fc803c..000000000 --- a/flow360/component/simulation/blueprint/utils/whitelisted.py +++ /dev/null @@ -1,240 +0,0 @@ -"""Whitelisted functions and classes that can be called from blueprint functions.""" - -from __future__ import annotations - -from collections.abc import Callable -from typing import Any - - -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}") - - -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 - - -def _unit_list(): - import unyt - - unit_symbols = set() - - for key, 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_utilities(name: str) -> Callable[..., Any]: - """Import and return a utility callable.""" - from rich import print - - callables = { - "print": print, - } - return callables[name] - - -# Single source of truth for whitelisted callables -WHITELISTED_CALLABLES = { - "tidy3d.core": { - "prefix": "td.", - "callables": [ - "Medium", - "GridSpec", - "Box", - "Structure", - "Simulation", - "BoundarySpec", - "Periodic", - "ModeSpec", - "inf", - ], - }, - "tidy3d.plugins": { - "prefix": "", - "callables": ["ModeSolver"], - }, - "tidy3d.constants": { - "prefix": "", - "callables": ["C_0"], - }, - "utilities": { - "prefix": "", - "callables": ["print"], - }, - "flow360": {"prefix": "u.", "callables": _unit_list()}, -} - -# Define allowed modules -ALLOWED_MODULES = { - "ModeSolver", # For the ModeSolver class - "tidy3d", # For the tidy3d module - "td", # For the tidy3d alias -} - -# Generate ALLOWED_CALLABLES from the single source of truth -ALLOWED_CALLABLES = { - "td": None, - **{ - f"{group['prefix']}{name}": None - for group in WHITELISTED_CALLABLES.values() - for name in group["callables"] - }, -} - -# Generate import category mapping -_IMPORT_FUNCTIONS = { - ("fl", "u"): _import_flow360, - ("td", "td.", "ModeSolver", "C_0"): _import_tidy3d, - ("print",): _import_utilities, -} - - -class CallableResolver: - """Manages resolution and validation of callable objects. - - Provides a unified interface for resolving function names, methods, and - attributes while enforcing whitelisting rules. - """ - - def __init__(self) -> 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 - - def register_module(self, name: str, module: Any) -> None: - """Register a module for attribute access.""" - self._allowed_modules[name] = module - - def get_callable( - self, - qualname: str, - context: EvaluationContext | None = None, # noqa: F821 - ) -> Callable[..., Any]: - """Resolve a callable by its qualified name. - - Args: - qualname: Fully qualified name (e.g., "np.array" or "len") - context: Optional evaluation context for local lookups - - Returns: - The resolved callable object - - Raises: - ValueError: If the callable is not allowed or cannot be found - """ - # Try context first if provided - if context is not None: - try: - return context.get(qualname) - except KeyError: - pass - - # Check direct allowed callables - 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(".") - if module_name in self._allowed_modules: - obj = self._allowed_modules[module_name] - for part in attr_parts: - obj = getattr(obj, part) - if qualname in ALLOWED_CALLABLES: - return obj - # Try importing if it's a whitelisted callable - if qualname in ALLOWED_CALLABLES: - for names, import_func in _IMPORT_FUNCTIONS.items(): - if module_name in names: - module = import_func(module_name) - self.register_module(module_name, module) - obj = module - for part in attr_parts: - obj = getattr(obj, part) - return obj - - raise ValueError(f"Callable '{qualname}' is not allowed") - - -# Create global resolver instance -resolver = CallableResolver() - - -def get_allowed_callable( - qualname: str, - context: EvaluationContext | None = None, # noqa: F821 -) -> Callable[..., Any]: - """Get an allowed callable by name.""" - # Try getting from resolver first - try: - return resolver.get_callable(qualname, context) - except ValueError as e: - # Check if it's a whitelisted callable before trying to import - if ( - qualname in ALLOWED_CALLABLES - or qualname in ALLOWED_MODULES - or any( - qualname.startswith(f"{group['prefix']}{name}") - for group in WHITELISTED_CALLABLES.values() - for name in group["callables"] - ) - ): - # If found in resolver, try importing on demand - for names, import_func in _IMPORT_FUNCTIONS.items(): - if qualname in names or any(qualname.startswith(prefix) for prefix in names): - callable_obj = import_func(qualname) - resolver.register_callable(qualname, callable_obj) - return callable_obj - raise ValueError(f"Callable '{qualname}' is not allowed") from e diff --git a/flow360/component/simulation/solver_builtins.py b/flow360/component/simulation/solver_builtins.py new file mode 100644 index 000000000..69a7845bd --- /dev/null +++ b/flow360/component/simulation/solver_builtins.py @@ -0,0 +1,3 @@ +from flow360.component.simulation.user_code import Variable + +example_solver_variable = Variable(name="fl.example_solver_variable", value=float("NaN")) \ No newline at end of file diff --git a/flow360/component/simulation/expressions.py b/flow360/component/simulation/user_code.py similarity index 95% rename from flow360/component/simulation/expressions.py rename to flow360/component/simulation/user_code.py index dbcd6d152..c7e50dd17 100644 --- a/flow360/component/simulation/expressions.py +++ b/flow360/component/simulation/user_code.py @@ -1,6 +1,7 @@ -from typing import get_origin, Generic, TypeVar, Self +from typing import get_origin, Generic, TypeVar, Self, Optional import re +from flow360.component.simulation.blueprint.flow360 import resolver from flow360.component.simulation.unit_system import * from flow360.component.simulation.blueprint.core import EvaluationContext from flow360.component.simulation.framework.base_model import Flow360BaseModel @@ -10,7 +11,8 @@ from numbers import Number from unyt import Unit, unyt_quantity, unyt_array -_global_ctx: EvaluationContext = EvaluationContext() + +_global_ctx: EvaluationContext = EvaluationContext(resolver) def _is_descendant_of(t, base): @@ -179,7 +181,7 @@ def __repr__(self): def _get_internal_validator(internal_type): def _internal_validator(value: Expression): - result = value.evaluate() + result = value.evaluate(strict=False) pd.TypeAdapter(internal_type).validate_python(result) return value @@ -209,7 +211,7 @@ def _validate_expression(cls, value, handler) -> Self: raise pd.ValidationError.from_exception_data("expression type error", [details]) try: - _ = expression_to_model(body) + _ = expression_to_model(body, _global_ctx) except SyntaxError as s_err: details = InitErrorDetails(type="value_error", ctx={"error": s_err}) raise pd.ValidationError.from_exception_data("expression syntax error", [details]) @@ -219,9 +221,9 @@ def _validate_expression(cls, value, handler) -> Self: return handler({"body": body}) - def evaluate(self) -> float: - expr = expression_to_model(self.body) - result = expr.evaluate(_global_ctx) + def evaluate(self, strict=True) -> float: + expr = expression_to_model(self.body, _global_ctx) + result = expr.evaluate(_global_ctx, strict) return result def __add__(self, other): @@ -319,8 +321,9 @@ def __class_getitem__(cls, internal_type): if isinstance(internal_type, Number): def _non_dimensional_validator(value): - result = value.evaluate() + result = value.evaluate(strict=False) if isinstance(result, Number): + return value msg = "The evaluated value needs to be a non-dimensional scalar" details = InitErrorDetails(type="value_error", ctx={"error": msg}) diff --git a/flow360/component/simulation/utils.py b/flow360/component/simulation/utils.py index e3ff3e8b5..8aa8681c9 100644 --- a/flow360/component/simulation/utils.py +++ b/flow360/component/simulation/utils.py @@ -72,3 +72,34 @@ 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/tests/simulation/test_expressions.py b/tests/simulation/test_expressions.py index 3354ad40f..c23c37932 100644 --- a/tests/simulation/test_expressions.py +++ b/tests/simulation/test_expressions.py @@ -1,6 +1,8 @@ +from math import isnan + import pytest -from flow360.component.simulation.expressions import ( +from flow360.component.simulation.user_code import ( ValueOrExpression, Variable, Expression, @@ -10,6 +12,8 @@ import pydantic as pd from flow360 import u +import flow360 as fl + from flow360.component.simulation.unit_system import ( LengthType, AngleType, @@ -323,3 +327,19 @@ class TestModel(Flow360BaseModel): with pytest.raises(pd.ValidationError): model.moment = x * u.m + + +def test_solver_builtin(): + class TestModel(Flow360BaseModel): + field: ValueOrExpression[float] = pd.Field() + + x = Variable(name="x", value=4) + + model = TestModel(field=x * u.m + fl.example_solver_variable * u.cm) + + assert str(model.field) == "x * u.m + (fl.example_solver_variable * u.cm)" + + # Raises when trying to evaluate with a message about this variable being blacklisted + with pytest.raises(ValueError): + model.field.evaluate() + From d257d5b27d0f844676f4c11386403ec22b670d5f Mon Sep 17 00:00:00 2001 From: Andrzej Krupka Date: Thu, 10 Apr 2025 13:24:01 +0200 Subject: [PATCH 03/34] Fix Python 3.9 compatibility --- flow360/component/simulation/blueprint/codegen/parser.py | 6 +++--- flow360/component/simulation/blueprint/core/context.py | 4 ++-- flow360/component/simulation/user_code.py | 5 +++-- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/flow360/component/simulation/blueprint/codegen/parser.py b/flow360/component/simulation/blueprint/codegen/parser.py index 94a5db635..2af09f57d 100644 --- a/flow360/component/simulation/blueprint/codegen/parser.py +++ b/flow360/component/simulation/blueprint/codegen/parser.py @@ -1,7 +1,7 @@ import ast import inspect from collections.abc import Callable -from typing import Any +from typing import Any, Union, Optional from ..core.context import EvaluationContext from ..core.expressions import ( @@ -193,8 +193,8 @@ def parse_stmt(node: ast.AST, ctx: EvaluationContext) -> Any: def function_to_model( - source: str | Callable[..., Any], - ctx: EvaluationContext | None = None, + source: Union[str, Callable[..., Any]], + ctx: Optional[EvaluationContext] = None, ) -> Function: """Parse a Python function definition into our intermediate representation. diff --git a/flow360/component/simulation/blueprint/core/context.py b/flow360/component/simulation/blueprint/core/context.py index db012813f..6284a8a6e 100644 --- a/flow360/component/simulation/blueprint/core/context.py +++ b/flow360/component/simulation/blueprint/core/context.py @@ -1,4 +1,4 @@ -from typing import Any +from typing import Any, Optional from .resolver import CallableResolver @@ -20,7 +20,7 @@ class EvaluationContext: """ def __init__( - self, resolver: CallableResolver, initial_values: dict[str, Any] | None = None + self, resolver: CallableResolver, initial_values: Optional[dict[str, Any]] = None ) -> None: self._values = initial_values or {} self._resolver = resolver diff --git a/flow360/component/simulation/user_code.py b/flow360/component/simulation/user_code.py index c7e50dd17..730865943 100644 --- a/flow360/component/simulation/user_code.py +++ b/flow360/component/simulation/user_code.py @@ -1,4 +1,5 @@ -from typing import get_origin, Generic, TypeVar, Self, Optional +from typing import get_origin, Generic, TypeVar +from typing_extensions import Self import re from flow360.component.simulation.blueprint.flow360 import resolver @@ -335,7 +336,7 @@ def _non_dimensional_validator(value): Expression, pd.AfterValidator(_get_internal_validator(internal_type)) ] - return Union[internal_type, expr_type] + return Union[expr_type, internal_type] @pd.model_validator(mode="wrap") @classmethod From 622b5871b8f4b8b14f18c3032fa6768fd216e6ad Mon Sep 17 00:00:00 2001 From: Andrzej Krupka <156919532+andrzej-krupka@users.noreply.github.com> Date: Tue, 15 Apr 2025 11:59:15 +0200 Subject: [PATCH 04/34] Added complete list of usable solver variables (#888) * Added complete list of usable solver variables * Add variable capture when creating simulation params * Serialization improvements * Fix serialization and deserialization issues --------- Co-authored-by: Andrzej Krupka --- flow360/__init__.py | 87 ++++++- .../simulation/blueprint/core/context.py | 4 +- .../simulation/blueprint/core/expressions.py | 48 ++++ .../simulation/blueprint/flow360/symbols.py | 46 +++- .../simulation/framework/param_utils.py | 39 +++- .../simulation/outputs/output_fields.py | 2 +- flow360/component/simulation/primitives.py | 3 +- .../component/simulation/simulation_params.py | 6 + .../component/simulation/solver_builtins.py | 49 +++- flow360/component/simulation/user_code.py | 216 +++++++++++------- .../validation_simulation_params.py | 14 ++ tests/simulation/test_expressions.py | 88 +++++-- 12 files changed, 499 insertions(+), 103 deletions(-) diff --git a/flow360/__init__.py b/flow360/__init__.py index efb305b1f..e463cfb7c 100644 --- a/flow360/__init__.py +++ b/flow360/__init__.py @@ -148,7 +148,49 @@ UserDefinedDynamic, ) from flow360.component.simulation.solver_builtins import ( - example_solver_variable + 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, ) from flow360.component.surface_mesh_v2 import SurfaceMeshV2 as SurfaceMesh from flow360.component.volume_mesh import VolumeMeshV2 as VolumeMesh @@ -274,4 +316,47 @@ "PointArray2D", "StreamlineOutput", "Transformation", + "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", ] diff --git a/flow360/component/simulation/blueprint/core/context.py b/flow360/component/simulation/blueprint/core/context.py index 6284a8a6e..0a9499c9f 100644 --- a/flow360/component/simulation/blueprint/core/context.py +++ b/flow360/component/simulation/blueprint/core/context.py @@ -25,10 +25,12 @@ def __init__( self._values = initial_values or {} self._resolver = resolver - def get(self, name: str) -> Any: + def get(self, name: str, resolve: bool = True) -> Any: if name not in self._values: # Try loading from builtin callables/constants if possible try: + if not resolve: + raise ValueError(f"{name} was not defined explicitly in the context") val = self.resolve(name) # If successful, store it so we don't need to import again self._values[name] = val diff --git a/flow360/component/simulation/blueprint/core/expressions.py b/flow360/component/simulation/blueprint/core/expressions.py index 432798559..70327c234 100644 --- a/flow360/component/simulation/blueprint/core/expressions.py +++ b/flow360/component/simulation/blueprint/core/expressions.py @@ -27,6 +27,9 @@ class Expression(pd.BaseModel): def evaluate(self, context: EvaluationContext, strict: bool) -> Any: raise NotImplementedError + def used_names(self) -> set[str]: + raise NotImplementedError + class Name(Expression): type: Literal["Name"] = "Name" @@ -37,6 +40,9 @@ def evaluate(self, context: EvaluationContext, strict: bool) -> Any: raise ValueError(f"Name '{self.id}' cannot be evaluated at client runtime") return context.get(self.id) + def used_names(self) -> set[str]: + return {self.id} + class Constant(Expression): type: Literal["Constant"] = "Constant" @@ -45,6 +51,9 @@ class Constant(Expression): def evaluate(self, context: EvaluationContext, strict: bool) -> Any: # noqa: ARG002 return self.value + def used_names(self) -> set[str]: + return set() + class UnaryOp(Expression): type: Literal["UnaryOp"] = "UnaryOp" @@ -61,6 +70,9 @@ def evaluate(self, context: EvaluationContext, strict: bool) -> Any: return UNARY_OPERATORS[self.op](operand_val) + def used_names(self) -> set[str]: + return self.operand.used_names() + class BinOp(Expression): """ @@ -84,6 +96,11 @@ def evaluate(self, context: EvaluationContext, strict: bool) -> Any: return BINARY_OPERATORS[self.op](left_val, right_val) + def used_names(self) -> set[str]: + left = self.left.used_names() + right = self.right.used_names() + return left.union(right) + class RangeCall(Expression): """ @@ -96,6 +113,9 @@ class RangeCall(Expression): def evaluate(self, context: EvaluationContext, strict: bool) -> range: return range(self.arg.evaluate(context, strict)) + def used_names(self) -> set[str]: + return self.arg.used_names() + class CallModel(Expression): """Represents a function or method call expression. @@ -159,6 +179,17 @@ def evaluate(self, context: EvaluationContext, strict: bool) -> Any: except Exception as e: raise ValueError(f"Error evaluating call to '{self.func_qualname}': {str(e)}") from e + def used_names(self) -> set[str]: + names = set() + + for arg in self.args: + names = names.union(arg.used_names()) + + for (keyword, arg) in self.kwargs.items(): + names = names.union(arg.used_names()) + + return names + class Tuple(Expression): """Model for tuple expressions.""" @@ -169,6 +200,9 @@ class Tuple(Expression): def evaluate(self, context: EvaluationContext, strict: bool) -> tuple: return tuple(elem.evaluate(context, strict) for elem in self.elements) + def used_names(self) -> set[str]: + return self.arg.used_names() + class List(Expression): """Model for list expressions.""" @@ -178,6 +212,14 @@ class List(Expression): def evaluate(self, context: EvaluationContext, strict: bool) -> list: return [elem.evaluate(context, strict) for elem in self.elements] + + def used_names(self) -> set[str]: + names = set() + + for arg in self.elements: + names = names.union(arg.used_names()) + + return names class ListComp(Expression): @@ -197,3 +239,9 @@ def evaluate(self, context: EvaluationContext, strict: bool) -> list: iter_context.set(self.target, item) result.append(self.element.evaluate(iter_context, strict)) return result + + def used_names(self) -> set[str]: + element = self.element.used_names() + iterable = self.iter.used_names() + + return element.union(iterable) diff --git a/flow360/component/simulation/blueprint/flow360/symbols.py b/flow360/component/simulation/blueprint/flow360/symbols.py index c0806c387..033032ed1 100644 --- a/flow360/component/simulation/blueprint/flow360/symbols.py +++ b/flow360/component/simulation/blueprint/flow360/symbols.py @@ -38,7 +38,51 @@ def _import_flow360(name: str) -> Any: }, "flow360.solver_builtins": { "prefix": "fl.", - "callables": ["example_solver_variable"], + "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 } } diff --git a/flow360/component/simulation/framework/param_utils.py b/flow360/component/simulation/framework/param_utils.py index aeb808433..dc07fe4ee 100644 --- a/flow360/component/simulation/framework/param_utils.py +++ b/flow360/component/simulation/framework/param_utils.py @@ -1,6 +1,6 @@ """pre processing and post processing utilities for simulation parameters.""" -from typing import Optional, Union +from typing import Optional, Union, List import pydantic as pd @@ -18,6 +18,7 @@ _VolumeEntityBase, ) from flow360.component.simulation.unit_system import LengthType +from flow360.component.simulation.user_code import UserVariable from flow360.component.simulation.utils import model_attribute_unlock @@ -38,6 +39,7 @@ class AssetCache(Flow360BaseModel): use_geometry_AI: bool = pd.Field( False, description="Flag whether user requested the use of GAI." ) + project_variables: Optional[List[UserVariable]] = pd.Field(None) @property def boundaries(self): @@ -49,6 +51,41 @@ def boundaries(self): return self.project_entity_info.get_boundaries() +def find_instances(obj, target_type): + stack = [obj] + seen_ids = set() + results = set() + + while stack: + current = stack.pop() + + obj_id = id(current) + if obj_id in seen_ids: + continue + seen_ids.add(obj_id) + + if isinstance(current, target_type): + results.add(current) + + if isinstance(current, dict): + stack.extend(current.keys()) + stack.extend(current.values()) + + elif isinstance(current, (list, tuple, set, frozenset)): + stack.extend(current) + + elif hasattr(current, '__dict__'): + stack.extend(vars(current).values()) + + elif hasattr(current, '__iter__') and not isinstance(current, (str, bytes)): + try: + stack.extend(iter(current)) + except Exception: + pass # skip problematic iterables + + return list(results) + + def register_entity_list(model: Flow360BaseModel, registry: EntityRegistry) -> None: """ Registers entities used/occurred in a Flow360BaseModel instance to an EntityRegistry. diff --git a/flow360/component/simulation/outputs/output_fields.py b/flow360/component/simulation/outputs/output_fields.py index f01178317..455a7895c 100644 --- a/flow360/component/simulation/outputs/output_fields.py +++ b/flow360/component/simulation/outputs/output_fields.py @@ -370,7 +370,7 @@ def _distribute_shared_output_fields(solver_values: dict, item_names: str): return shared_fields = solver_values.pop("output_fields") if solver_values[item_names] is not None: - for name in solver_values[item_names].names(): + for name in solver_values[item_names].user_variables(): item = solver_values[item_names][name] for field in shared_fields: if item.output_fields is None: diff --git a/flow360/component/simulation/primitives.py b/flow360/component/simulation/primitives.py index 30262733b..e07c4adfb 100644 --- a/flow360/component/simulation/primitives.py +++ b/flow360/component/simulation/primitives.py @@ -21,6 +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.utils import model_attribute_unlock from flow360.component.types import Axis @@ -87,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[AreaType.Positive] = pd.Field( + area: Optional[ValueOrExpression[AreaType.Positive]] = pd.Field( None, description="The reference area of the geometry." ) diff --git a/flow360/component/simulation/simulation_params.py b/flow360/component/simulation/simulation_params.py index 7e503dc2f..de209ecc0 100644 --- a/flow360/component/simulation/simulation_params.py +++ b/flow360/component/simulation/simulation_params.py @@ -82,6 +82,7 @@ _check_time_average_output, _check_unsteadiness_to_use_hybrid_model, _check_valid_models_for_liquid, + _save_project_variables, ) from flow360.component.utils import remove_properties_by_name from flow360.error_messages import ( @@ -408,6 +409,11 @@ def check_duplicate_user_defined_fields(cls, v): return v + @pd.model_validator(mode="after") + def save_project_variables(self): + """Populate project variables private attribute used in the simulation params""" + return _save_project_variables(self) + @pd.model_validator(mode="after") def check_cht_solver_settings(self): """Check the Conjugate Heat Transfer settings, transferred from checkCHTSolverSettings""" diff --git a/flow360/component/simulation/solver_builtins.py b/flow360/component/simulation/solver_builtins.py index 69a7845bd..916758531 100644 --- a/flow360/component/simulation/solver_builtins.py +++ b/flow360/component/simulation/solver_builtins.py @@ -1,3 +1,48 @@ -from flow360.component.simulation.user_code import Variable +from flow360.component.simulation.user_code import SolverVariable + +mut = SolverVariable(name="fl.mut", value=float("NaN")) # Turbulent viscosity +mu = SolverVariable(name="fl.mu", value=float("NaN")) # Laminar viscosity +solutionNavierStokes = SolverVariable(name="fl.solutionNavierStokes", value=float("NaN")) # Solution for N-S equation in conservative form +residualNavierStokes = SolverVariable(name="fl.residualNavierStokes", value=float("NaN")) # Residual for N-S equation in conservative form +solutionTurbulence = SolverVariable(name="fl.solutionTurbulence", value=float("NaN")) # Solution for turbulence model +residualTurbulence = SolverVariable(name="fl.residualTurbulence", value=float("NaN")) # Residual for turbulence model +kOmega = SolverVariable(name="fl.kOmega", value=float("NaN")) # Effectively solutionTurbulence when using SST model +nuHat = SolverVariable(name="fl.nuHat", value=float("NaN")) # Effectively solutionTurbulence when using SA model +solutionTransition = SolverVariable(name="fl.solutionTransition", value=float("NaN")) # Solution for transition model +residualTransition = SolverVariable(name="fl.residualTransition", value=float("NaN")) # Residual for transition model +solutionHeatSolver = SolverVariable(name="fl.solutionHeatSolver", value=float("NaN")) # Solution for heat equation +residualHeatSolver = SolverVariable(name="fl.residualHeatSolver", value=float("NaN")) # Residual for heat equation +coordinate = SolverVariable(name="fl.coordinate", value=float("NaN")) # Grid coordinates + +physicalStep = SolverVariable(name="fl.physicalStep", value=float("NaN")) # Physical time step, starting from 0 +pseudoStep = SolverVariable(name="fl.pseudoStep", value=float("NaN")) # Pseudo time step within physical time step +timeStepSize = SolverVariable(name="fl.timeStepSize", value=float("NaN")) # Physical time step size +alphaAngle = SolverVariable(name="fl.alphaAngle", value=float("NaN")) # Alpha angle specified in freestream +betaAngle = SolverVariable(name="fl.betaAngle", value=float("NaN")) # Beta angle specified in freestream +pressureFreestream = SolverVariable(name="fl.pressureFreestream", value=float("NaN")) # Freestream reference pressure (1.0/1.4) +momentLengthX = SolverVariable(name="fl.momentLengthX", value=float("NaN")) # X component of momentLength +momentLengthY = SolverVariable(name="fl.momentLengthY", value=float("NaN")) # Y component of momentLength +momentLengthZ = SolverVariable(name="fl.momentLengthZ", value=float("NaN")) # Z component of momentLength +momentCenterX = SolverVariable(name="fl.momentCenterX", value=float("NaN")) # X component of momentCenter +momentCenterY = SolverVariable(name="fl.momentCenterY", value=float("NaN")) # Y component of momentCenter +momentCenterZ = SolverVariable(name="fl.momentCenterZ", value=float("NaN")) # Z component of momentCenter + +bet_thrust = SolverVariable(name="fl.bet_thrust", value=float("NaN")) # Thrust force for BET disk +bet_torque = SolverVariable(name="fl.bet_torque", value=float("NaN")) # Torque for BET disk +bet_omega = SolverVariable(name="fl.bet_omega", value=float("NaN")) # Rotation speed for BET disk +CD = SolverVariable(name="fl.CD", value=float("NaN")) # Drag coefficient on patch +CL = SolverVariable(name="fl.CL", value=float("NaN")) # Lift coefficient on patch +forceX = SolverVariable(name="fl.forceX", value=float("NaN")) # Total force in X direction +forceY = SolverVariable(name="fl.forceY", value=float("NaN")) # Total force in Y direction +forceZ = SolverVariable(name="fl.forceZ", value=float("NaN")) # Total force in Z direction +momentX = SolverVariable(name="fl.momentX", value=float("NaN")) # Total moment in X direction +momentY = SolverVariable(name="fl.momentY", value=float("NaN")) # Total moment in Y direction +momentZ = SolverVariable(name="fl.momentZ", value=float("NaN")) # Total moment in Z direction +nodeNormals = SolverVariable(name="fl.nodeNormals", value=float("NaN")) # Normal vector of patch +theta = SolverVariable(name="fl.theta", value=float("NaN")) # Rotation angle of volume zone +omega = SolverVariable(name="fl.omega", value=float("NaN")) # Rotation speed of volume zone +omegaDot = SolverVariable(name="fl.omegaDot", value=float("NaN")) # Rotation acceleration of volume zone +wallFunctionMetric = SolverVariable(name="fl.wallFunctionMetric", value=float("NaN")) # Wall model quality indicator +wallShearStress = SolverVariable(name="fl.wallShearStress", value=float("NaN")) # Wall viscous shear stress +yPlus = SolverVariable(name="fl.yPlus", value=float("NaN")) # Non-dimensional wall distance -example_solver_variable = Variable(name="fl.example_solver_variable", value=float("NaN")) \ No newline at end of file diff --git a/flow360/component/simulation/user_code.py b/flow360/component/simulation/user_code.py index 730865943..c715ffbfb 100644 --- a/flow360/component/simulation/user_code.py +++ b/flow360/component/simulation/user_code.py @@ -1,4 +1,7 @@ -from typing import get_origin, Generic, TypeVar +from __future__ import annotations +from typing import get_origin, Generic, TypeVar, Optional, Iterable + +from pydantic import WrapSerializer, WrapValidator from typing_extensions import Self import re @@ -14,6 +17,7 @@ _global_ctx: EvaluationContext = EvaluationContext(resolver) +_user_variables: set[str] = set() def _is_descendant_of(t, base): @@ -42,7 +46,7 @@ def _convert_argument(other): parenthesize = False unit_delimiters = ["+", "-", "*", "/", "(", ")"] if isinstance(other, Expression): - arg = other.body + arg = other.expression parenthesize = True elif isinstance(other, Variable): arg = other.name @@ -83,114 +87,124 @@ def _convert_argument(other): return arg, parenthesize +class SerializedValueOrExpression(Flow360BaseModel): + type_name: Union[Literal["number"], Literal["expression"]] = pd.Field(None, alias="typeName") + value: Optional[Union[Number, Iterable[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, alias="evaluatedValue") + evaluated_units: Optional[str] = pd.Field(None, alias="evaluatedUnits") + + class Variable(Flow360BaseModel): name: str = pd.Field() value: Union[list[float], float, unyt_quantity, unyt_array] = pd.Field() model_config = pd.ConfigDict(validate_assignment=True) - @pd.model_validator(mode="after") - @classmethod - def update_context(cls, value): - _global_ctx.set(value.name, value.value) - def __add__(self, other): (arg, parenthesize) = _convert_argument(other) str_arg = arg if not parenthesize else f"({arg})" - return Expression(body=f"{self.name} + {str_arg}") + return Expression(expression=f"{self.name} + {str_arg}") def __sub__(self, other): (arg, parenthesize) = _convert_argument(other) str_arg = arg if not parenthesize else f"({arg})" - return Expression(body=f"{self.name} - {str_arg}") + return Expression(expression=f"{self.name} - {str_arg}") def __mul__(self, other): (arg, parenthesize) = _convert_argument(other) str_arg = arg if not parenthesize else f"({arg})" - return Expression(body=f"{self.name} * {str_arg}") + return Expression(expression=f"{self.name} * {str_arg}") def __truediv__(self, other): (arg, parenthesize) = _convert_argument(other) str_arg = arg if not parenthesize else f"({arg})" - return Expression(body=f"{self.name} / {str_arg}") + return Expression(expression=f"{self.name} / {str_arg}") def __floordiv__(self, other): (arg, parenthesize) = _convert_argument(other) str_arg = arg if not parenthesize else f"({arg})" - return Expression(body=f"{self.name} // {str_arg}") + return Expression(expression=f"{self.name} // {str_arg}") def __mod__(self, other): (arg, parenthesize) = _convert_argument(other) str_arg = arg if not parenthesize else f"({arg})" - return Expression(body=f"{self.name} % {str_arg}") + return Expression(expression=f"{self.name} % {str_arg}") def __pow__(self, other): (arg, parenthesize) = _convert_argument(other) str_arg = arg if not parenthesize else f"({arg})" - return Expression(body=f"{self.name} ** {str_arg}") + return Expression(expression=f"{self.name} ** {str_arg}") def __neg__(self): - return Expression(body=f"-{self.name}") + return Expression(expression=f"-{self.name}") def __pos__(self): - return Expression(body=f"+{self.name}") + return Expression(expression=f"+{self.name}") def __abs__(self): - return Expression(body=f"abs({self.name})") + return Expression(expression=f"abs({self.name})") def __radd__(self, other): (arg, parenthesize) = _convert_argument(other) str_arg = arg if not parenthesize else f"({arg})" - return Expression(body=f"{str_arg} + {self.name}") + return Expression(expression=f"{str_arg} + {self.name}") def __rsub__(self, other): (arg, parenthesize) = _convert_argument(other) str_arg = arg if not parenthesize else f"({arg})" - return Expression(body=f"{str_arg} - {self.name}") + return Expression(expression=f"{str_arg} - {self.name}") def __rmul__(self, other): (arg, parenthesize) = _convert_argument(other) str_arg = arg if not parenthesize else f"({arg})" - return Expression(body=f"{str_arg} * {self.name}") + return Expression(expression=f"{str_arg} * {self.name}") def __rtruediv__(self, other): (arg, parenthesize) = _convert_argument(other) str_arg = arg if not parenthesize else f"({arg})" - return Expression(body=f"{str_arg} / {self.name}") + return Expression(expression=f"{str_arg} / {self.name}") def __rfloordiv__(self, other): (arg, parenthesize) = _convert_argument(other) str_arg = arg if not parenthesize else f"({arg})" - return Expression(body=f"{str_arg} // {self.name}") + return Expression(expression=f"{str_arg} // {self.name}") def __rmod__(self, other): (arg, parenthesize) = _convert_argument(other) str_arg = arg if not parenthesize else f"({arg})" - return Expression(body=f"{str_arg} % {self.name}") + return Expression(expression=f"{str_arg} % {self.name}") def __rpow__(self, other): (arg, parenthesize) = _convert_argument(other) str_arg = arg if not parenthesize else f"({arg})" - return Expression(body=f"{str_arg} ** {self.name}") + return Expression(expression=f"{str_arg} ** {self.name}") def __str__(self): return self.name def __repr__(self): - return f"Flow360Variable({self.name} = {self.value})" + return f"Variable({self.name} = {self.value})" -def _get_internal_validator(internal_type): - def _internal_validator(value: Expression): - result = value.evaluate(strict=False) - pd.TypeAdapter(internal_type).validate_python(result) - return value +class UserVariable(Variable): + @pd.model_validator(mode="after") + @classmethod + def update_context(cls, value): + _global_ctx.set(value.name, value.value) + _user_variables.add(value.name) - return _internal_validator + +class SolverVariable(Variable): + @pd.model_validator(mode="after") + @classmethod + def update_context(cls, value): + _global_ctx.set(value.name, value.value) class Expression(Flow360BaseModel): - body: str = pd.Field("") + expression: str = pd.Field("") model_config = pd.ConfigDict(validate_assignment=True) @@ -198,13 +212,13 @@ class Expression(Flow360BaseModel): @classmethod def _validate_expression(cls, value, handler) -> Self: if isinstance(value, str): - body = value - elif isinstance(value, dict) and "body" in value.keys(): - body = value["body"] + expression = value + elif isinstance(value, dict) and "expression" in value.keys(): + expression = value["expression"] elif isinstance(value, Expression): - body = value.body + expression = value.expression elif isinstance(value, Variable): - body = str(value) + expression = str(value) else: details = InitErrorDetails( type="value_error", ctx={"error": f"Invalid type {type(value)}"} @@ -212,7 +226,7 @@ def _validate_expression(cls, value, handler) -> Self: raise pd.ValidationError.from_exception_data("expression type error", [details]) try: - _ = expression_to_model(body, _global_ctx) + _ = expression_to_model(expression, _global_ctx) except SyntaxError as s_err: details = InitErrorDetails(type="value_error", ctx={"error": s_err}) raise pd.ValidationError.from_exception_data("expression syntax error", [details]) @@ -220,126 +234,168 @@ def _validate_expression(cls, value, handler) -> Self: details = InitErrorDetails(type="value_error", ctx={"error": v_err}) raise pd.ValidationError.from_exception_data("expression value error", [details]) - return handler({"body": body}) + return handler({"expression": expression}) def evaluate(self, strict=True) -> float: - expr = expression_to_model(self.body, _global_ctx) + expr = expression_to_model(self.expression, _global_ctx) result = expr.evaluate(_global_ctx, strict) return result + def user_variables(self): + expr = expression_to_model(self.expression, _global_ctx) + 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] + + def __hash__(self): + return hash(self.expression) + def __add__(self, other): (arg, parenthesize) = _convert_argument(other) str_arg = arg if not parenthesize else f"({arg})" - return Expression(body=f"{self.body} + {str_arg}") + return Expression(expression=f"{self.expression} + {str_arg}") def __sub__(self, other): (arg, parenthesize) = _convert_argument(other) str_arg = arg if not parenthesize else f"({arg})" - return Expression(body=f"{self.body} - {str_arg}") + return Expression(expression=f"{self.expression} - {str_arg}") def __mul__(self, other): (arg, parenthesize) = _convert_argument(other) str_arg = arg if not parenthesize else f"({arg})" - return Expression(body=f"({self.body}) * {str_arg}") + return Expression(expression=f"({self.expression}) * {str_arg}") def __truediv__(self, other): (arg, parenthesize) = _convert_argument(other) str_arg = arg if not parenthesize else f"({arg})" - return Expression(body=f"({self.body}) / {str_arg}") + return Expression(expression=f"({self.expression}) / {str_arg}") def __floordiv__(self, other): (arg, parenthesize) = _convert_argument(other) str_arg = arg if not parenthesize else f"({arg})" - return Expression(body=f"({self.body}) // {str_arg}") + return Expression(expression=f"({self.expression}) // {str_arg}") def __mod__(self, other): (arg, parenthesize) = _convert_argument(other) str_arg = arg if not parenthesize else f"({arg})" - return Expression(body=f"({self.body}) % {str_arg}") + return Expression(expression=f"({self.expression}) % {str_arg}") def __pow__(self, other): (arg, parenthesize) = _convert_argument(other) str_arg = arg if not parenthesize else f"({arg})" - return Expression(body=f"({self.body}) ** {str_arg}") + return Expression(expression=f"({self.expression}) ** {str_arg}") def __neg__(self): - return Expression(body=f"-({self.body})") + return Expression(expression=f"-({self.expression})") def __pos__(self): - return Expression(body=f"+({self.body})") + return Expression(expression=f"+({self.expression})") def __abs__(self): - return Expression(body=f"abs({self.body})") + return Expression(expression=f"abs({self.expression})") def __radd__(self, other): (arg, parenthesize) = _convert_argument(other) str_arg = arg if not parenthesize else f"({arg})" - return Expression(body=f"{str_arg} + {self.body}") + return Expression(expression=f"{str_arg} + {self.expression}") def __rsub__(self, other): (arg, parenthesize) = _convert_argument(other) str_arg = arg if not parenthesize else f"({arg})" - return Expression(body=f"{str_arg} - {self.body}") + return Expression(expression=f"{str_arg} - {self.expression}") def __rmul__(self, other): (arg, parenthesize) = _convert_argument(other) str_arg = arg if not parenthesize else f"({arg})" - return Expression(body=f"{str_arg} * ({self.body})") + return Expression(expression=f"{str_arg} * ({self.expression})") def __rtruediv__(self, other): (arg, parenthesize) = _convert_argument(other) str_arg = arg if not parenthesize else f"({arg})" - return Expression(body=f"{str_arg} / ({self.body})") + return Expression(expression=f"{str_arg} / ({self.expression})") def __rfloordiv__(self, other): (arg, parenthesize) = _convert_argument(other) str_arg = arg if not parenthesize else f"({arg})" - return Expression(body=f"{str_arg} // ({self.body})") + return Expression(expression=f"{str_arg} // ({self.expression})") def __rmod__(self, other): (arg, parenthesize) = _convert_argument(other) str_arg = arg if not parenthesize else f"({arg})" - return Expression(body=f"{str_arg} % ({self.body})") + return Expression(expression=f"{str_arg} % ({self.expression})") def __rpow__(self, other): (arg, parenthesize) = _convert_argument(other) str_arg = arg if not parenthesize else f"({arg})" - return Expression(body=f"{str_arg} ** ({self.body})") + return Expression(expression=f"{str_arg} ** ({self.expression})") def __str__(self): - return self.body + return self.expression def __repr__(self): - return f"Flow360Expression({self.body})" + return f"Expression({self.expression})" T = TypeVar("T") class ValueOrExpression(Expression, Generic[T]): - def __class_getitem__(cls, internal_type): - if isinstance(internal_type, Number): - - def _non_dimensional_validator(value): - result = value.evaluate(strict=False) - if isinstance(result, Number): + def _internal_validator(value: Expression): + result = value.evaluate(strict=False) + validated = pd.TypeAdapter(internal_type).validate_python(result, strict=True) + return value + + expr_type = Annotated[Expression, pd.AfterValidator(_internal_validator)] + + def _deserialize(value, handler) -> Self: + try: + value = SerializedValueOrExpression.model_validate(value, strict=True) + if value.type_name == "number": + if value.units is not None: + return handler(unyt_quantity(value.value, value.units)) + else: + return handler(value.value) + elif value.type_name == "expression": + return handler(value.expression) + except Exception as err: + pass + + return handler(value) + + def _serializer(value, handler, info) -> dict: + if isinstance(value, Expression): + serialized = SerializedValueOrExpression(typeName="expression") + + serialized.expression = value.expression + + evaluated = value.evaluate(strict=False) + + if isinstance(evaluated, Number): + serialized.evaluated_value = evaluated + elif isinstance(evaluated, unyt_quantity) or isinstance(evaluated, unyt_array): + + if evaluated.size == 1: + serialized.evaluated_value = float(evaluated.value) + else: + serialized.evaluated_value = tuple(evaluated.value.tolist()) + + serialized.evaluated_units = str(evaluated.units.expr) + else: + serialized = SerializedValueOrExpression(typeName="number") + if isinstance(value, Number): + serialized.value = value + elif isinstance(value, unyt_quantity) or isinstance(value, unyt_array): - return value - msg = "The evaluated value needs to be a non-dimensional scalar" - details = InitErrorDetails(type="value_error", ctx={"error": msg}) - raise pd.ValidationError.from_exception_data("expression value error", [details]) + if value.size == 1: + serialized.value = float(value.value) + else: + serialized.value = tuple(value.value.tolist()) - expr_type = Annotated[Expression, pd.AfterValidator(_non_dimensional_validator)] - else: - expr_type = Annotated[ - Expression, pd.AfterValidator(_get_internal_validator(internal_type)) - ] + serialized.units = str(value.units.expr) - return Union[expr_type, internal_type] + return serialized.model_dump(**info.__dict__) - @pd.model_validator(mode="wrap") - @classmethod - def _convert_to_dict(cls, value, handler) -> Self: - value = Expression.model_validate(value) - return handler({"body": value.body}) + return Annotated[Annotated[Union[expr_type, internal_type], WrapSerializer(_serializer)], WrapValidator(_deserialize)] diff --git a/flow360/component/simulation/validation/validation_simulation_params.py b/flow360/component/simulation/validation/validation_simulation_params.py index 3ccbc4dff..d5bae95cd 100644 --- a/flow360/component/simulation/validation/validation_simulation_params.py +++ b/flow360/component/simulation/validation/validation_simulation_params.py @@ -5,6 +5,7 @@ from typing import get_args from flow360.component.simulation.entity_info import DraftEntityTypes +from flow360.component.simulation.framework.param_utils import find_instances from flow360.component.simulation.models.solver_numerics import NoneSolver from flow360.component.simulation.models.surface_models import ( Inflow, @@ -26,6 +27,7 @@ _VolumeEntityBase, ) from flow360.component.simulation.time_stepping.time_stepping import Steady, Unsteady +from flow360.component.simulation.user_code import Expression from flow360.component.simulation.validation.validation_context import ( ALL, CASE, @@ -34,6 +36,18 @@ ) +def _save_project_variables(v): + expressions = find_instances(v, Expression) + user_variables = set() + + for expression in expressions: + user_variables = user_variables.union(expression.user_variables()) + + v.private_attribute_asset_cache.project_variables = list(user_variables) + + return v + + def _check_consistency_wall_function_and_surface_output(v): models = v.models diff --git a/tests/simulation/test_expressions.py b/tests/simulation/test_expressions.py index c23c37932..69cc8dce7 100644 --- a/tests/simulation/test_expressions.py +++ b/tests/simulation/test_expressions.py @@ -1,10 +1,11 @@ from math import isnan +from pprint import pprint import pytest from flow360.component.simulation.user_code import ( ValueOrExpression, - Variable, + UserVariable, Expression, ) from flow360.component.simulation.framework.base_model import Flow360BaseModel @@ -46,7 +47,7 @@ class TestModel(Flow360BaseModel): field: ValueOrExpression[float] = pd.Field() # Declare a variable - x = Variable(name="x", value=1) + x = UserVariable(name="x", value=1) # Initialize with value model_1 = TestModel(field=1) @@ -78,7 +79,7 @@ class TestModel(Flow360BaseModel): field: ValueOrExpression[float] = pd.Field() # Declare a variable - x = Variable(name="x", value=1) + x = UserVariable(name="x", value=1) model = TestModel(field=x) assert isinstance(model.field, Expression) @@ -96,8 +97,8 @@ class TestModel(Flow360BaseModel): field: ValueOrExpression[float] = pd.Field() # Declare two variables - x = Variable(name="x", value=3) - y = Variable(name="y", value=2) + x = UserVariable(name="x", value=3) + y = UserVariable(name="y", value=2) model = TestModel(field=x + y) @@ -216,7 +217,7 @@ class TestModel(Flow360BaseModel): assert model_legacy - x = Variable(name="x", value=1) + x = UserVariable(name="x", value=1) model_expression = TestModel( length=x * u.m, @@ -251,7 +252,7 @@ def test_constrained_scalar_type(): class TestModel(Flow360BaseModel): field: ValueOrExpression[pd.confloat(ge=0)] = pd.Field() - x = Variable(name="x", value=1) + x = UserVariable(name="x", value=1) model = TestModel(field=x) @@ -267,7 +268,7 @@ def test_constrained_dimensioned_type(): class TestModel(Flow360BaseModel): field: ValueOrExpression[LengthType.Positive] = pd.Field() - x = Variable(name="x", value=1) + x = UserVariable(name="x", value=1) model = TestModel(field=x * u.m) @@ -287,10 +288,10 @@ class TestModel(Flow360BaseModel): direction: ValueOrExpression[LengthType.Direction] = pd.Field() moment: ValueOrExpression[LengthType.Moment] = pd.Field() - x = Variable(name="x", value=[1, 0, 0]) - y = Variable(name="y", value=[0, 0, 0]) - z = Variable(name="z", value=[1, 0, 0, 0]) - w = Variable(name="w", value=[1, 1, 1]) + x = UserVariable(name="x", value=[1, 0, 0]) + y = UserVariable(name="y", value=[0, 0, 0]) + z = UserVariable(name="z", value=[1, 0, 0, 0]) + w = UserVariable(name="w", value=[1, 1, 1]) model = TestModel( vector=y * u.m, axis=x * u.m, array=z * u.m, direction=x * u.m, moment=w * u.m @@ -333,13 +334,70 @@ def test_solver_builtin(): class TestModel(Flow360BaseModel): field: ValueOrExpression[float] = pd.Field() - x = Variable(name="x", value=4) + x = UserVariable(name="x", value=4) - model = TestModel(field=x * u.m + fl.example_solver_variable * u.cm) + model = TestModel(field=x * u.m + fl.kOmega * u.cm) - assert str(model.field) == "x * u.m + (fl.example_solver_variable * u.cm)" + assert str(model.field) == "x * u.m + (fl.kOmega * u.cm)" # Raises when trying to evaluate with a message about this variable being blacklisted with pytest.raises(ValueError): model.field.evaluate() + +def test_serializer(): + class TestModel(Flow360BaseModel): + field: ValueOrExpression[VelocityType] = pd.Field() + + x = UserVariable(name="x", value=4) + + 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)' + + serialized = model.model_dump(exclude_none=True) + + print(model.model_dump_json(indent=2, 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)' + + model = TestModel(field=4 * u.m / u.s) + + serialized = model.model_dump(exclude_none=True) + + print(model.model_dump_json(indent=2, exclude_none=True)) + + assert serialized["field"]["type_name"] == "number" + assert serialized["field"]["value"] == 4 + assert serialized["field"]["units"] == "m/s" + + +def test_deserializer(): + class TestModel(Flow360BaseModel): + field: ValueOrExpression[VelocityType] = pd.Field() + + x = UserVariable(name="x", value=4) + + model = { + "type_name": "expression", + "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)' + + model = { + "type_name": "number", + "value": 4.0, + "units": "m/s" + } + + deserialized = TestModel(field=model) + + assert str(deserialized.field) == '4.0 m/s' + + From eedb1a79933a52ae2af31a3dc50c71082218056f Mon Sep 17 00:00:00 2001 From: Andrzej Krupka Date: Wed, 16 Apr 2025 12:37:50 +0200 Subject: [PATCH 05/34] Ensure global scope variables are visible within validation service --- flow360/component/simulation/services.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/flow360/component/simulation/services.py b/flow360/component/simulation/services.py index d37d6198d..d4079976f 100644 --- a/flow360/component/simulation/services.py +++ b/flow360/component/simulation/services.py @@ -7,6 +7,9 @@ import pydantic as pd +# Required for correct global scope initialization +from flow360.component.simulation.solver_builtins import * + from flow360.component.simulation.framework.multi_constructor_model_base import ( parse_model_dict, ) From 49318b03510c059e4edb508bcd925afbc66a9aba Mon Sep 17 00:00:00 2001 From: Andrzej Krupka Date: Fri, 18 Apr 2025 19:32:04 +0200 Subject: [PATCH 06/34] Fix validation ordering bug --- .../simulation/blueprint/core/resolver.py | 2 +- flow360/component/simulation/primitives.py | 2 +- flow360/component/simulation/user_code.py | 33 +++++++++---------- 3 files changed, 17 insertions(+), 20 deletions(-) diff --git a/flow360/component/simulation/blueprint/core/resolver.py b/flow360/component/simulation/blueprint/core/resolver.py index b4b32d116..99416954a 100644 --- a/flow360/component/simulation/blueprint/core/resolver.py +++ b/flow360/component/simulation/blueprint/core/resolver.py @@ -99,7 +99,7 @@ def get_allowed_callable(self, qualname: str) -> Callable[..., Any]: or qualname in self._module_builtins or any( qualname.startswith(f"{group['prefix']}{name}") - for group in self._callable_builtins.values() + for group in self._callable_builtins.values() if group is not None for name in group["callables"] ) ): diff --git a/flow360/component/simulation/primitives.py b/flow360/component/simulation/primitives.py index e07c4adfb..c9288f2bb 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.Positive]] = pd.Field( + area: Optional[ValueOrExpression[AreaType]] = pd.Field( None, description="The reference area of the geometry." ) diff --git a/flow360/component/simulation/user_code.py b/flow360/component/simulation/user_code.py index c715ffbfb..130f822ae 100644 --- a/flow360/component/simulation/user_code.py +++ b/flow360/component/simulation/user_code.py @@ -1,7 +1,7 @@ from __future__ import annotations from typing import get_origin, Generic, TypeVar, Optional, Iterable -from pydantic import WrapSerializer, WrapValidator +from pydantic import BeforeValidator, WrapSerializer, WrapValidator from typing_extensions import Self import re @@ -20,13 +20,6 @@ _user_variables: set[str] = set() -def _is_descendant_of(t, base): - if t is None: - return False - origin = get_origin(t) or t - return issubclass(origin, base) - - def _is_number_string(s: str) -> bool: try: float(s) @@ -208,9 +201,9 @@ class Expression(Flow360BaseModel): model_config = pd.ConfigDict(validate_assignment=True) - @pd.model_validator(mode="wrap") + @pd.model_validator(mode="before") @classmethod - def _validate_expression(cls, value, handler) -> Self: + def _validate_expression(cls, value) -> Self: if isinstance(value, str): expression = value elif isinstance(value, dict) and "expression" in value.keys(): @@ -234,7 +227,7 @@ def _validate_expression(cls, value, handler) -> Self: details = InitErrorDetails(type="value_error", ctx={"error": v_err}) raise pd.ValidationError.from_exception_data("expression value error", [details]) - return handler({"expression": expression}) + return {"expression": expression} def evaluate(self, strict=True) -> float: expr = expression_to_model(self.expression, _global_ctx) @@ -350,22 +343,22 @@ def _internal_validator(value: Expression): expr_type = Annotated[Expression, pd.AfterValidator(_internal_validator)] - def _deserialize(value, handler) -> Self: + def _deserialize(value) -> Self: try: value = SerializedValueOrExpression.model_validate(value, strict=True) if value.type_name == "number": if value.units is not None: - return handler(unyt_quantity(value.value, value.units)) + return unyt_quantity(value.value, value.units) else: - return handler(value.value) + return value.value elif value.type_name == "expression": - return handler(value.expression) + return expr_type(expression=value.expression) except Exception as err: pass - return handler(value) + return value - def _serializer(value, handler, info) -> dict: + def _serializer(value, info) -> dict: if isinstance(value, Expression): serialized = SerializedValueOrExpression(typeName="expression") @@ -397,5 +390,9 @@ def _serializer(value, handler, info) -> dict: serialized.units = str(value.units.expr) 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)] - return Annotated[Annotated[Union[expr_type, internal_type], WrapSerializer(_serializer)], WrapValidator(_deserialize)] + return union_type From b0a9d064435ed4ef5424383871cf06e554e6e0c2 Mon Sep 17 00:00:00 2001 From: Andrzej Krupka Date: Tue, 22 Apr 2025 11:28:03 +0200 Subject: [PATCH 07/34] Allow extra fields in variable objects --- flow360/component/simulation/user_code.py | 2 +- tests/simulation/test_expressions.py | 17 +++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/flow360/component/simulation/user_code.py b/flow360/component/simulation/user_code.py index 130f822ae..5e3062813 100644 --- a/flow360/component/simulation/user_code.py +++ b/flow360/component/simulation/user_code.py @@ -93,7 +93,7 @@ class Variable(Flow360BaseModel): name: str = pd.Field() value: Union[list[float], float, unyt_quantity, unyt_array] = pd.Field() - model_config = pd.ConfigDict(validate_assignment=True) + model_config = pd.ConfigDict(validate_assignment=True, extra='allow') def __add__(self, other): (arg, parenthesize) = _convert_argument(other) diff --git a/tests/simulation/test_expressions.py b/tests/simulation/test_expressions.py index 69cc8dce7..adc588695 100644 --- a/tests/simulation/test_expressions.py +++ b/tests/simulation/test_expressions.py @@ -1,5 +1,6 @@ from math import isnan from pprint import pprint +from typing import List import pytest @@ -42,6 +43,22 @@ ) +def test_variable_init(): + class TestModel(Flow360BaseModel): + field: List[UserVariable] = pd.Field() + + # Variables can be initialized with a... + + # Value + a = UserVariable(name="a", value=1) + + # Dimensioned value + b = UserVariable(name="b", value=1 * u.m) + + # A dictionary (can contain extra values - important for frontend) + c = UserVariable.model_validate({"name": "c", "value": 1, "extra": "foo"}) + + def test_expression_init(): class TestModel(Flow360BaseModel): field: ValueOrExpression[float] = pd.Field() From 858413d3c7f91343cbe84348655f5c4fd9d10358 Mon Sep 17 00:00:00 2001 From: Maciej Skarysz <83596707+maciej-flexcompute@users.noreply.github.com> Date: Thu, 24 Apr 2025 14:18:03 +0200 Subject: [PATCH 08/34] Improved error messages (#945) --- .../simulation/framework/base_model.py | 54 +++++++++++---- flow360/component/simulation/user_code.py | 42 ++++++++++-- tests/simulation/test_expressions.py | 66 +++++++++++++++++++ 3 files changed, 143 insertions(+), 19 deletions(-) diff --git a/flow360/component/simulation/framework/base_model.py b/flow360/component/simulation/framework/base_model.py index f86889479..82a5b224f 100644 --- a/flow360/component/simulation/framework/base_model.py +++ b/flow360/component/simulation/framework/base_model.py @@ -3,6 +3,7 @@ from __future__ import annotations import copy +import re import hashlib import json from itertools import chain @@ -30,6 +31,10 @@ "private_attribute_entity_type_name", ] +# matches every virtual path part that Pydantic adds for function validators +_FUNCTION_SEGMENT = re.compile(r"^function-") + + def _preprocess_nested_list(value, required_by, params, exclude, registry_lookup): new_list = [] @@ -323,26 +328,47 @@ def validate_conditionally_required_field(cls, value, info): @classmethod def populate_ctx_to_error_messages(cls, values, handler, info) -> Any: """ - this validator populates ctx messages of fields tagged with "relevant_for" context - it will populate to all child messages + this validator: + - populates ctx messages of fields tagged with "relevant_for" context it will populate to all child messages + - flattens ``loc`` by removing any segment that starts with "function-" """ try: return handler(values) except pd.ValidationError as e: - validation_errors = e.errors() + raw_errors = e.errors() relevant_for = cls._get_field_context(info, "relevant_for") - if relevant_for is not None: - for i, error in enumerate(validation_errors): - ctx = error.get("ctx", {}) - if ctx.get("relevant_for") is None: - # Enforce the relevant_for to be a list for consistency - ctx["relevant_for"] = ( - relevant_for if isinstance(relevant_for, list) else [relevant_for] - ) - validation_errors[i]["ctx"] = ctx + cleaned_errors: list[InitErrorDetails] = [] + + for error in raw_errors: + + new_loc = tuple( + seg for seg in error["loc"] + if not (isinstance(seg, str) and _FUNCTION_SEGMENT.match(seg)) + ) + + ctx = error.get("ctx", {}) + if relevant_for is not None and ctx.get("relevant_for") is None: + # Enforce the relevant_for to be a list for consistency + ctx["relevant_for"] = ( + relevant_for if isinstance(relevant_for, list) else [relevant_for] + ) + + cleaned_errors.append( + InitErrorDetails( + type=error["type"], + loc=new_loc, + msg=error["msg"], + input=error.get("input"), + ctx=ctx, + ) + ) + + raise pd.ValidationError.from_exception_data( - title=cls.__class__.__name__, line_errors=validation_errors - ) + title=cls.__class__.__name__, line_errors=cleaned_errors + ) from None + + # Note: to_solver architecture will be reworked in favor of splitting the models between # the user-side and solver-side models (see models.py and models_avl.py for reference diff --git a/flow360/component/simulation/user_code.py b/flow360/component/simulation/user_code.py index 5e3062813..1a6c7accf 100644 --- a/flow360/component/simulation/user_code.py +++ b/flow360/component/simulation/user_code.py @@ -1,7 +1,7 @@ from __future__ import annotations from typing import get_origin, Generic, TypeVar, Optional, Iterable -from pydantic import BeforeValidator, WrapSerializer, WrapValidator +from pydantic import BeforeValidator from typing_extensions import Self import re @@ -196,6 +196,34 @@ def update_context(cls, value): _global_ctx.set(value.name, value.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): expression: str = pd.Field("") @@ -221,8 +249,7 @@ def _validate_expression(cls, value) -> Self: try: _ = expression_to_model(expression, _global_ctx) except SyntaxError as s_err: - details = InitErrorDetails(type="value_error", ctx={"error": s_err}) - raise pd.ValidationError.from_exception_data("expression syntax error", [details]) + raise _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]) @@ -337,8 +364,13 @@ def __repr__(self): class ValueOrExpression(Expression, Generic[T]): def __class_getitem__(cls, internal_type): def _internal_validator(value: Expression): - result = value.evaluate(strict=False) - validated = pd.TypeAdapter(internal_type).validate_python(result, strict=True) + 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, strict=True) + return value expr_type = Annotated[Expression, pd.AfterValidator(_internal_validator)] diff --git a/tests/simulation/test_expressions.py b/tests/simulation/test_expressions.py index adc588695..54823b888 100644 --- a/tests/simulation/test_expressions.py +++ b/tests/simulation/test_expressions.py @@ -418,3 +418,69 @@ class TestModel(Flow360BaseModel): assert str(deserialized.field) == '4.0 m/s' + + +def test_error_message(): + class TestModel(Flow360BaseModel): + field: ValueOrExpression[VelocityType] = pd.Field() + + x = UserVariable(name="x", value=4) + + try: + model = TestModel(field="1 + nonexisting * 1") + except pd.ValidationError as err: + validation_errors = err.errors() + + assert len(validation_errors) >= 1 + assert validation_errors[0]["type"] == "value_error" + assert "Name 'nonexisting' is not defined" in validation_errors[0]["msg"] + + try: + model = TestModel(field="1 + x * 1") + except pd.ValidationError as err: + validation_errors = err.errors() + + assert len(validation_errors) >= 1 + assert validation_errors[0]["type"] == "value_error" + assert "does not match (length)/(time) dimension" in validation_errors[0]["msg"] + + + try: + model = TestModel(field="1 * 1 +") + except pd.ValidationError as err: + validation_errors = err.errors() + + assert len(validation_errors) >= 1 + assert validation_errors[0]["type"] == "value_error" + assert "invalid syntax" in validation_errors[0]["msg"] + assert "1 * 1 +" in validation_errors[0]["msg"] + assert "line" in validation_errors[0]["ctx"] + assert "column" in validation_errors[0]["ctx"] + assert validation_errors[0]["ctx"]["column"] == 8 + + try: + model = TestModel(field="1 * 1 +* 2") + except pd.ValidationError as err: + validation_errors = err.errors() + + assert len(validation_errors) >= 1 + assert validation_errors[0]["type"] == "value_error" + assert "invalid syntax" in validation_errors[0]["msg"] + assert "1 * 1 +* 2" in validation_errors[0]["msg"] + assert "line" in validation_errors[0]["ctx"] + assert "column" in validation_errors[0]["ctx"] + assert validation_errors[0]["ctx"]["column"] == 8 + + try: + model = TestModel(field="1 * 1 + (2") + except pd.ValidationError as err: + validation_errors = err.errors() + + assert len(validation_errors) >= 1 + assert validation_errors[0]["type"] == "value_error" + assert "unexpected EOF" in validation_errors[0]["msg"] + assert "1 * 1 + (2" in validation_errors[0]["msg"] + assert "line" in validation_errors[0]["ctx"] + assert "column" in validation_errors[0]["ctx"] + assert validation_errors[0]["ctx"]["column"] == 11 + From d86b2dd24a288099f476bf20a847759708dfc05a Mon Sep 17 00:00:00 2001 From: Andrzej Krupka <156919532+andrzej-krupka@users.noreply.github.com> Date: Thu, 8 May 2025 17:37:44 +0200 Subject: [PATCH 09/34] Nested expression support + expression validation endpoints (#946) * Nested variables are now available for use * Added expression validation endpoint support * Extend expressions endpoint to support multiple expressions * Add ability to use expressions in vector fields * Added numpy interop capabilities along with subscript operator support * Apply PR feedback --------- Co-authored-by: Andrzej Krupka --- flow360/__init__.py | 84 ++++---- .../simulation/blueprint/__init__.py | 3 +- .../simulation/blueprint/codegen/generator.py | 9 +- .../simulation/blueprint/codegen/parser.py | 35 ++- .../simulation/blueprint/core/__init__.py | 4 + .../simulation/blueprint/core/expressions.py | 42 +++- .../simulation/blueprint/core/resolver.py | 3 +- .../simulation/blueprint/core/types.py | 10 + .../simulation/blueprint/flow360/symbols.py | 27 ++- .../simulation/blueprint/tidy3d/symbols.py | 27 +-- .../simulation/framework/base_model.py | 9 +- .../simulation/framework/param_utils.py | 6 +- flow360/component/simulation/services.py | 56 ++++- .../component/simulation/solver_builtins.py | 135 ++++++++---- flow360/component/simulation/user_code.py | 186 ++++++++++------ flow360/component/simulation/utils.py | 2 +- tests/simulation/test_expressions.py | 200 +++++++++++++----- 17 files changed, 559 insertions(+), 279 deletions(-) create mode 100644 flow360/component/simulation/blueprint/core/types.py diff --git a/flow360/__init__.py b/flow360/__init__.py index 6c0970895..c8b52f0f1 100644 --- a/flow360/__init__.py +++ b/flow360/__init__.py @@ -134,65 +134,65 @@ Transformation, ) from flow360.component.simulation.simulation_params import SimulationParams -from flow360.component.simulation.time_stepping.time_stepping import ( - AdaptiveCFL, - RampCFL, - Steady, - Unsteady, -) -from flow360.component.simulation.unit_system import ( - CGS_unit_system, - SI_unit_system, - imperial_unit_system, -) -from flow360.component.simulation.user_defined_dynamics.user_defined_dynamics import ( - UserDefinedDynamic, -) from flow360.component.simulation.solver_builtins import ( - mut, - mu, - solutionNavierStokes, - residualNavierStokes, - solutionTurbulence, - residualTurbulence, - kOmega, - nuHat, - solutionTransition, - residualTransition, - solutionHeatSolver, - residualHeatSolver, - coordinate, - physicalStep, - pseudoStep, - timeStepSize, + CD, + CL, alphaAngle, - betaAngle, - pressureFreestream, - momentLengthX, - momentLengthY, - momentLengthZ, - momentCenterX, - momentCenterY, - momentCenterZ, + bet_omega, bet_thrust, bet_torque, - bet_omega, - CD, - CL, + betaAngle, + coordinate, forceX, forceY, forceZ, + kOmega, + momentCenterX, + momentCenterY, + momentCenterZ, + momentLengthX, + momentLengthY, + momentLengthZ, momentX, momentY, momentZ, + mu, + mut, nodeNormals, - theta, + nuHat, omega, omegaDot, + physicalStep, + pressureFreestream, + pseudoStep, + residualHeatSolver, + residualNavierStokes, + residualTransition, + residualTurbulence, + solutionHeatSolver, + solutionNavierStokes, + solutionTransition, + solutionTurbulence, + theta, + timeStepSize, wallFunctionMetric, wallShearStress, yPlus, ) +from flow360.component.simulation.time_stepping.time_stepping import ( + AdaptiveCFL, + RampCFL, + Steady, + Unsteady, +) +from flow360.component.simulation.unit_system import ( + CGS_unit_system, + SI_unit_system, + imperial_unit_system, +) +from flow360.component.simulation.user_defined_dynamics.user_defined_dynamics import ( + UserDefinedDynamic, +) from flow360.component.surface_mesh_v2 import SurfaceMeshV2 as SurfaceMesh from flow360.component.volume_mesh import VolumeMeshV2 as VolumeMesh from flow360.environment import Env diff --git a/flow360/component/simulation/blueprint/__init__.py b/flow360/component/simulation/blueprint/__init__.py index 7b9bb46a1..0998e6e9b 100644 --- a/flow360/component/simulation/blueprint/__init__.py +++ b/flow360/component/simulation/blueprint/__init__.py @@ -1,7 +1,8 @@ """Blueprint: Safe function serialization and visual programming integration.""" from .codegen.generator import model_to_function -from .codegen.parser import function_to_model, expression_to_model +from .codegen.parser import expression_to_model, function_to_model from .core.function import Function +from .core.types import Evaluable __all__ = ["Function", "function_to_model", "model_to_function", "expression_to_model"] diff --git a/flow360/component/simulation/blueprint/codegen/generator.py b/flow360/component/simulation/blueprint/codegen/generator.py index a324a694b..8e112c792 100644 --- a/flow360/component/simulation/blueprint/codegen/generator.py +++ b/flow360/component/simulation/blueprint/codegen/generator.py @@ -11,14 +11,7 @@ Tuple, ) from ..core.function import Function -from ..core.statements import ( - Assign, - AugAssign, - ForLoop, - IfElse, - Return, - TupleUnpack, -) +from ..core.statements import Assign, AugAssign, ForLoop, IfElse, Return, TupleUnpack from ..utils.operators import BINARY_OPERATORS diff --git a/flow360/component/simulation/blueprint/codegen/parser.py b/flow360/component/simulation/blueprint/codegen/parser.py index 2af09f57d..9b7f1d1a9 100644 --- a/flow360/component/simulation/blueprint/codegen/parser.py +++ b/flow360/component/simulation/blueprint/codegen/parser.py @@ -1,32 +1,14 @@ import ast import inspect from collections.abc import Callable -from typing import Any, Union, Optional +from typing import Any, Optional, Union from ..core.context import EvaluationContext -from ..core.expressions import ( - BinOp, - UnaryOp, - CallModel, - Constant, - ListComp, - Name, - RangeCall, - Tuple, - Expression, -) -from ..core.expressions import ( - List as ListExpr, -) +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 ..core.statements import Assign, AugAssign, ForLoop, IfElse, Return, TupleUnpack def parse_expr(node: ast.AST, ctx: EvaluationContext) -> Any: @@ -73,6 +55,13 @@ def parse_expr(node: ast.AST, ctx: EvaluationContext) -> Any: right=parse_expr(node.comparators[0], ctx), ) + elif 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.func, ast.Name) and node.func.id == "range" and len(node.args) == 1: return RangeCall(arg=parse_expr(node.args[0], ctx)) diff --git a/flow360/component/simulation/blueprint/core/__init__.py b/flow360/component/simulation/blueprint/core/__init__.py index 7e37f4bb6..d81c36bed 100644 --- a/flow360/component/simulation/blueprint/core/__init__.py +++ b/flow360/component/simulation/blueprint/core/__init__.py @@ -11,6 +11,7 @@ ListComp, Name, RangeCall, + Subscript, Tuple, ) from .function import Function @@ -24,6 +25,7 @@ StatementType, TupleUnpack, ) +from .types import Evaluable def _model_rebuild() -> None: @@ -38,6 +40,7 @@ def _model_rebuild() -> None: "Tuple": Tuple, "List": List, "ListComp": ListComp, + "Subscript": Subscript, "ExpressionType": ExpressionType, # Statement types "Assign": Assign, @@ -58,6 +61,7 @@ def _model_rebuild() -> None: Tuple.model_rebuild(_types_namespace=namespace) List.model_rebuild(_types_namespace=namespace) ListComp.model_rebuild(_types_namespace=namespace) + Subscript.model_rebuild(_types_namespace=namespace) # Then update statement classes that depend on both types Assign.model_rebuild(_types_namespace=namespace) diff --git a/flow360/component/simulation/blueprint/core/expressions.py b/flow360/component/simulation/blueprint/core/expressions.py index 70327c234..26b299697 100644 --- a/flow360/component/simulation/blueprint/core/expressions.py +++ b/flow360/component/simulation/blueprint/core/expressions.py @@ -3,6 +3,7 @@ import pydantic as pd from .context import EvaluationContext +from .types import Evaluable ExpressionType = Annotated[ Union[ @@ -14,6 +15,7 @@ "Tuple", "List", "ListComp", + "Subscript", ], pd.Field(discriminator="type"), ] @@ -31,14 +33,20 @@ def used_names(self) -> set[str]: raise NotImplementedError -class Name(Expression): +class Name(Expression, Evaluable): type: Literal["Name"] = "Name" id: str def evaluate(self, context: EvaluationContext, strict: bool) -> Any: if strict and not context.can_evaluate(self.id): raise ValueError(f"Name '{self.id}' cannot be evaluated at client runtime") - return context.get(self.id) + value = context.get(self.id) + + # Recursively evaluate if the returned value is evaluable + if isinstance(value, Evaluable): + value = value.evaluate(context, strict) + + return value def used_names(self) -> set[str]: return {self.id} @@ -102,6 +110,28 @@ def used_names(self) -> set[str]: return left.union(right) +class Subscript(Expression): + + type: Literal["Subscript"] = "Subscript" + value: "ExpressionType" + slice: "ExpressionType" # No proper slicing for now, only constants.. + ctx: str # Only load context + + def evaluate(self, context: EvaluationContext, strict: bool) -> Any: + value = self.value.evaluate(context, strict) + item = self.slice.evaluate(context, strict) + + if self.ctx == "Load": + return value[item] + elif self.ctx == "Store": + raise NotImplementedError("Subscripted writes are not supported yet") + + def used_names(self) -> set[str]: + value = self.value.used_names() + item = self.slice.used_names() + return value.union(item) + + class RangeCall(Expression): """ Model for something like range(). @@ -185,9 +215,9 @@ 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 keyword, arg in self.kwargs.items(): names = names.union(arg.used_names()) - + return names @@ -212,13 +242,13 @@ class List(Expression): def evaluate(self, context: EvaluationContext, strict: bool) -> list: return [elem.evaluate(context, strict) for elem in self.elements] - + def used_names(self) -> set[str]: names = set() for arg in self.elements: names = names.union(arg.used_names()) - + return names diff --git a/flow360/component/simulation/blueprint/core/resolver.py b/flow360/component/simulation/blueprint/core/resolver.py index 99416954a..7ab1467e1 100644 --- a/flow360/component/simulation/blueprint/core/resolver.py +++ b/flow360/component/simulation/blueprint/core/resolver.py @@ -99,7 +99,8 @@ def get_allowed_callable(self, qualname: str) -> Callable[..., Any]: or qualname in self._module_builtins or any( qualname.startswith(f"{group['prefix']}{name}") - for group in self._callable_builtins.values() if group is not None + for group in self._callable_builtins.values() + if group is not None for name in group["callables"] ) ): diff --git a/flow360/component/simulation/blueprint/core/types.py b/flow360/component/simulation/blueprint/core/types.py new file mode 100644 index 000000000..4a808d719 --- /dev/null +++ b/flow360/component/simulation/blueprint/core/types.py @@ -0,0 +1,10 @@ +import abc +from typing import Any + +from .context import EvaluationContext + + +class Evaluable(metaclass=abc.ABCMeta): + @abc.abstractmethod + def evaluate(self, context: EvaluationContext, strict: bool) -> Any: + pass diff --git a/flow360/component/simulation/blueprint/flow360/symbols.py b/flow360/component/simulation/blueprint/flow360/symbols.py index 033032ed1..8fe597574 100644 --- a/flow360/component/simulation/blueprint/flow360/symbols.py +++ b/flow360/component/simulation/blueprint/flow360/symbols.py @@ -30,12 +30,15 @@ def _import_flow360(name: str) -> Any: return u +def _import_numpy(name: str) -> Any: + import numpy as np + + if name == "np": + return np + + WHITELISTED_CALLABLES = { - "flow360.units": { - "prefix": "u.", - "callables": _unit_list(), - "evaluate": True - }, + "flow360.units": {"prefix": "u.", "callables": _unit_list(), "evaluate": True}, "flow360.solver_builtins": { "prefix": "fl.", "callables": [ @@ -83,12 +86,12 @@ def _import_flow360(name: str) -> Any: "wallShearStress", "yPlus", ], - "evaluate": False - } + "evaluate": False, + }, } # Define allowed modules -ALLOWED_MODULES = {"flow360", "fl"} +ALLOWED_MODULES = {"flow360", "fl", "np"} ALLOWED_CALLABLES = { "fl": None, @@ -103,12 +106,16 @@ def _import_flow360(name: str) -> Any: **{ f"{group['prefix']}{name}": None for group in WHITELISTED_CALLABLES.values() - for name in group["callables"] if not group["evaluate"] + for name in group["callables"] + if not group["evaluate"] }, } IMPORT_FUNCTIONS = { ("fl", "u"): _import_flow360, + "np": _import_numpy, } -resolver = CallableResolver(ALLOWED_CALLABLES, ALLOWED_MODULES, IMPORT_FUNCTIONS, EVALUATION_BLACKLIST) +resolver = CallableResolver( + ALLOWED_CALLABLES, ALLOWED_MODULES, IMPORT_FUNCTIONS, EVALUATION_BLACKLIST +) diff --git a/flow360/component/simulation/blueprint/tidy3d/symbols.py b/flow360/component/simulation/blueprint/tidy3d/symbols.py index 7a0aca1d3..e7e64839c 100644 --- a/flow360/component/simulation/blueprint/tidy3d/symbols.py +++ b/flow360/component/simulation/blueprint/tidy3d/symbols.py @@ -55,23 +55,11 @@ def _import_tidy3d(name: str) -> Any: "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 + "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 @@ -94,7 +82,8 @@ def _import_tidy3d(name: str) -> Any: **{ f"{group['prefix']}{name}": None for group in WHITELISTED_CALLABLES.values() - for name in group["callables"] if not group["evaluate"] + for name in group["callables"] + if not group["evaluate"] }, } @@ -104,4 +93,6 @@ def _import_tidy3d(name: str) -> Any: ("print",): _import_utilities, } -resolver = CallableResolver(ALLOWED_CALLABLES, ALLOWED_MODULES, IMPORT_FUNCTIONS, EVALUATION_BLACKLIST) +resolver = CallableResolver( + ALLOWED_CALLABLES, ALLOWED_MODULES, IMPORT_FUNCTIONS, EVALUATION_BLACKLIST +) diff --git a/flow360/component/simulation/framework/base_model.py b/flow360/component/simulation/framework/base_model.py index 82a5b224f..06988b85d 100644 --- a/flow360/component/simulation/framework/base_model.py +++ b/flow360/component/simulation/framework/base_model.py @@ -3,9 +3,9 @@ from __future__ import annotations import copy -import re import hashlib import json +import re from itertools import chain from typing import Any, List, Literal, Set, get_origin @@ -35,7 +35,6 @@ _FUNCTION_SEGMENT = re.compile(r"^function-") - def _preprocess_nested_list(value, required_by, params, exclude, registry_lookup): new_list = [] for i, item in enumerate(value): @@ -342,7 +341,8 @@ def populate_ctx_to_error_messages(cls, values, handler, info) -> Any: for error in raw_errors: new_loc = tuple( - seg for seg in error["loc"] + seg + for seg in error["loc"] if not (isinstance(seg, str) and _FUNCTION_SEGMENT.match(seg)) ) @@ -363,13 +363,10 @@ def populate_ctx_to_error_messages(cls, values, handler, info) -> Any: ) ) - raise pd.ValidationError.from_exception_data( title=cls.__class__.__name__, line_errors=cleaned_errors ) from None - - # Note: to_solver architecture will be reworked in favor of splitting the models between # the user-side and solver-side models (see models.py and models_avl.py for reference # in the design360 repo) diff --git a/flow360/component/simulation/framework/param_utils.py b/flow360/component/simulation/framework/param_utils.py index dc07fe4ee..4ae031209 100644 --- a/flow360/component/simulation/framework/param_utils.py +++ b/flow360/component/simulation/framework/param_utils.py @@ -1,6 +1,6 @@ """pre processing and post processing utilities for simulation parameters.""" -from typing import Optional, Union, List +from typing import List, Optional, Union import pydantic as pd @@ -74,10 +74,10 @@ def find_instances(obj, target_type): elif isinstance(current, (list, tuple, set, frozenset)): stack.extend(current) - elif hasattr(current, '__dict__'): + elif hasattr(current, "__dict__"): stack.extend(vars(current).values()) - elif hasattr(current, '__iter__') and not isinstance(current, (str, bytes)): + elif hasattr(current, "__iter__") and not isinstance(current, (str, bytes)): try: stack.extend(iter(current)) except Exception: diff --git a/flow360/component/simulation/services.py b/flow360/component/simulation/services.py index 57347322d..f32093079 100644 --- a/flow360/component/simulation/services.py +++ b/flow360/component/simulation/services.py @@ -2,13 +2,14 @@ # pylint: disable=duplicate-code import json +import re from enum import Enum +from numbers import Number from typing import Any, Collection, Dict, Literal, Optional, Tuple, Union import pydantic as pd - -# Required for correct global scope initialization -from flow360.component.simulation.solver_builtins import * +from unyt import unyt_array, unyt_quantity +from unyt.exceptions import UnitParseError from flow360.component.simulation.exposed_units import supported_units_by_front_end from flow360.component.simulation.framework.multi_constructor_model_base import ( @@ -34,6 +35,9 @@ ReferenceGeometry, SimulationParams, ) + +# Required for correct global scope initialization +from flow360.component.simulation.solver_builtins import * from flow360.component.simulation.translator.solver_translator import get_solver_json from flow360.component.simulation.translator.surface_meshing_translator import ( get_surface_meshing_json, @@ -51,6 +55,7 @@ u, unit_system_manager, ) +from flow360.component.simulation.user_code import Expression, UserVariable from flow360.component.simulation.utils import model_attribute_unlock from flow360.component.simulation.validation.validation_context import ( ALL, @@ -767,3 +772,48 @@ def update_simulation_json(*, params_as_dict: dict, target_python_api_version: s # Expected exceptions errors.append(str(e)) return updated_params_as_dict, errors + + +def validate_expression(variables: list[dict], expressions: list[str]): + """ + Validate all given expressions using the specified variable space (which is also validated) + """ + errors = [] + values = [] + units = [] + + loc = "" + + # Populate variable scope + for i in range(len(variables)): + variable = variables[i] + loc = f"variables/{i}" + try: + variable = UserVariable(name=variable["name"], value=variable["value"]) + if variable and isinstance(variable.value, Expression): + _ = variable.value.evaluate() + except (ValueError, KeyError, NameError, UnitParseError) as e: + errors.append({"loc": loc, "msg": str(e)}) + + for i in range(len(expressions)): + expression = expressions[i] + loc = f"expressions/{i}" + value = None + unit = None + try: + expression_object = Expression(expression=expression) + result = expression_object.evaluate() + if isinstance(result, Number): + value = result + elif isinstance(result, unyt_array): + if result.size == 1: + value = float(result.value) + else: + value = tuple(result.value.tolist()) + unit = str(result.units.expr) + except (ValueError, KeyError, NameError, UnitParseError) as e: + errors.append({"loc": loc, "msg": str(e)}) + values.append(value) + units.append(unit) + + return errors, values, units diff --git a/flow360/component/simulation/solver_builtins.py b/flow360/component/simulation/solver_builtins.py index 916758531..e4fa38e31 100644 --- a/flow360/component/simulation/solver_builtins.py +++ b/flow360/component/simulation/solver_builtins.py @@ -1,48 +1,95 @@ from flow360.component.simulation.user_code import SolverVariable -mut = SolverVariable(name="fl.mut", value=float("NaN")) # Turbulent viscosity -mu = SolverVariable(name="fl.mu", value=float("NaN")) # Laminar viscosity -solutionNavierStokes = SolverVariable(name="fl.solutionNavierStokes", value=float("NaN")) # Solution for N-S equation in conservative form -residualNavierStokes = SolverVariable(name="fl.residualNavierStokes", value=float("NaN")) # Residual for N-S equation in conservative form -solutionTurbulence = SolverVariable(name="fl.solutionTurbulence", value=float("NaN")) # Solution for turbulence model -residualTurbulence = SolverVariable(name="fl.residualTurbulence", value=float("NaN")) # Residual for turbulence model -kOmega = SolverVariable(name="fl.kOmega", value=float("NaN")) # Effectively solutionTurbulence when using SST model -nuHat = SolverVariable(name="fl.nuHat", value=float("NaN")) # Effectively solutionTurbulence when using SA model -solutionTransition = SolverVariable(name="fl.solutionTransition", value=float("NaN")) # Solution for transition model -residualTransition = SolverVariable(name="fl.residualTransition", value=float("NaN")) # Residual for transition model -solutionHeatSolver = SolverVariable(name="fl.solutionHeatSolver", value=float("NaN")) # Solution for heat equation -residualHeatSolver = SolverVariable(name="fl.residualHeatSolver", value=float("NaN")) # Residual for heat equation -coordinate = SolverVariable(name="fl.coordinate", value=float("NaN")) # Grid coordinates +mut = SolverVariable(name="fl.mut", value=float("NaN")) # Turbulent viscosity +mu = SolverVariable(name="fl.mu", value=float("NaN")) # Laminar viscosity +solutionNavierStokes = SolverVariable( + name="fl.solutionNavierStokes", value=float("NaN") +) # Solution for N-S equation in conservative form +residualNavierStokes = SolverVariable( + name="fl.residualNavierStokes", value=float("NaN") +) # Residual for N-S equation in conservative form +solutionTurbulence = SolverVariable( + name="fl.solutionTurbulence", value=float("NaN") +) # Solution for turbulence model +residualTurbulence = SolverVariable( + name="fl.residualTurbulence", value=float("NaN") +) # Residual for turbulence model +kOmega = SolverVariable( + name="fl.kOmega", value=float("NaN") +) # Effectively solutionTurbulence when using SST model +nuHat = SolverVariable( + name="fl.nuHat", value=float("NaN") +) # Effectively solutionTurbulence when using SA model +solutionTransition = SolverVariable( + name="fl.solutionTransition", value=float("NaN") +) # Solution for transition model +residualTransition = SolverVariable( + name="fl.residualTransition", value=float("NaN") +) # Residual for transition model +solutionHeatSolver = SolverVariable( + name="fl.solutionHeatSolver", value=float("NaN") +) # Solution for heat equation +residualHeatSolver = SolverVariable( + name="fl.residualHeatSolver", value=float("NaN") +) # Residual for heat equation +coordinate = SolverVariable(name="fl.coordinate", value=float("NaN")) # Grid coordinates -physicalStep = SolverVariable(name="fl.physicalStep", value=float("NaN")) # Physical time step, starting from 0 -pseudoStep = SolverVariable(name="fl.pseudoStep", value=float("NaN")) # Pseudo time step within physical time step -timeStepSize = SolverVariable(name="fl.timeStepSize", value=float("NaN")) # Physical time step size -alphaAngle = SolverVariable(name="fl.alphaAngle", value=float("NaN")) # Alpha angle specified in freestream -betaAngle = SolverVariable(name="fl.betaAngle", value=float("NaN")) # Beta angle specified in freestream -pressureFreestream = SolverVariable(name="fl.pressureFreestream", value=float("NaN")) # Freestream reference pressure (1.0/1.4) -momentLengthX = SolverVariable(name="fl.momentLengthX", value=float("NaN")) # X component of momentLength -momentLengthY = SolverVariable(name="fl.momentLengthY", value=float("NaN")) # Y component of momentLength -momentLengthZ = SolverVariable(name="fl.momentLengthZ", value=float("NaN")) # Z component of momentLength -momentCenterX = SolverVariable(name="fl.momentCenterX", value=float("NaN")) # X component of momentCenter -momentCenterY = SolverVariable(name="fl.momentCenterY", value=float("NaN")) # Y component of momentCenter -momentCenterZ = SolverVariable(name="fl.momentCenterZ", value=float("NaN")) # Z component of momentCenter - -bet_thrust = SolverVariable(name="fl.bet_thrust", value=float("NaN")) # Thrust force for BET disk -bet_torque = SolverVariable(name="fl.bet_torque", value=float("NaN")) # Torque for BET disk -bet_omega = SolverVariable(name="fl.bet_omega", value=float("NaN")) # Rotation speed for BET disk -CD = SolverVariable(name="fl.CD", value=float("NaN")) # Drag coefficient on patch -CL = SolverVariable(name="fl.CL", value=float("NaN")) # Lift coefficient on patch -forceX = SolverVariable(name="fl.forceX", value=float("NaN")) # Total force in X direction -forceY = SolverVariable(name="fl.forceY", value=float("NaN")) # Total force in Y direction -forceZ = SolverVariable(name="fl.forceZ", value=float("NaN")) # Total force in Z direction -momentX = SolverVariable(name="fl.momentX", value=float("NaN")) # Total moment in X direction -momentY = SolverVariable(name="fl.momentY", value=float("NaN")) # Total moment in Y direction -momentZ = SolverVariable(name="fl.momentZ", value=float("NaN")) # Total moment in Z direction -nodeNormals = SolverVariable(name="fl.nodeNormals", value=float("NaN")) # Normal vector of patch -theta = SolverVariable(name="fl.theta", value=float("NaN")) # Rotation angle of volume zone -omega = SolverVariable(name="fl.omega", value=float("NaN")) # Rotation speed of volume zone -omegaDot = SolverVariable(name="fl.omegaDot", value=float("NaN")) # Rotation acceleration of volume zone -wallFunctionMetric = SolverVariable(name="fl.wallFunctionMetric", value=float("NaN")) # Wall model quality indicator -wallShearStress = SolverVariable(name="fl.wallShearStress", value=float("NaN")) # Wall viscous shear stress -yPlus = SolverVariable(name="fl.yPlus", value=float("NaN")) # Non-dimensional wall distance +physicalStep = SolverVariable( + name="fl.physicalStep", value=float("NaN") +) # Physical time step, starting from 0 +pseudoStep = SolverVariable( + name="fl.pseudoStep", value=float("NaN") +) # Pseudo time step within physical time step +timeStepSize = SolverVariable(name="fl.timeStepSize", value=float("NaN")) # Physical time step size +alphaAngle = SolverVariable( + name="fl.alphaAngle", value=float("NaN") +) # Alpha angle specified in freestream +betaAngle = SolverVariable( + name="fl.betaAngle", value=float("NaN") +) # Beta angle specified in freestream +pressureFreestream = SolverVariable( + name="fl.pressureFreestream", value=float("NaN") +) # Freestream reference pressure (1.0/1.4) +momentLengthX = SolverVariable( + name="fl.momentLengthX", value=float("NaN") +) # X component of momentLength +momentLengthY = SolverVariable( + name="fl.momentLengthY", value=float("NaN") +) # Y component of momentLength +momentLengthZ = SolverVariable( + name="fl.momentLengthZ", value=float("NaN") +) # Z component of momentLength +momentCenterX = SolverVariable( + name="fl.momentCenterX", value=float("NaN") +) # X component of momentCenter +momentCenterY = SolverVariable( + name="fl.momentCenterY", value=float("NaN") +) # Y component of momentCenter +momentCenterZ = SolverVariable( + name="fl.momentCenterZ", value=float("NaN") +) # Z component of momentCenter +bet_thrust = SolverVariable(name="fl.bet_thrust", value=float("NaN")) # Thrust force for BET disk +bet_torque = SolverVariable(name="fl.bet_torque", value=float("NaN")) # Torque for BET disk +bet_omega = SolverVariable(name="fl.bet_omega", value=float("NaN")) # Rotation speed for BET disk +CD = SolverVariable(name="fl.CD", value=float("NaN")) # Drag coefficient on patch +CL = SolverVariable(name="fl.CL", value=float("NaN")) # Lift coefficient on patch +forceX = SolverVariable(name="fl.forceX", value=float("NaN")) # Total force in X direction +forceY = SolverVariable(name="fl.forceY", value=float("NaN")) # Total force in Y direction +forceZ = SolverVariable(name="fl.forceZ", value=float("NaN")) # Total force in Z direction +momentX = SolverVariable(name="fl.momentX", value=float("NaN")) # Total moment in X direction +momentY = SolverVariable(name="fl.momentY", value=float("NaN")) # Total moment in Y direction +momentZ = SolverVariable(name="fl.momentZ", value=float("NaN")) # Total moment in Z direction +nodeNormals = SolverVariable(name="fl.nodeNormals", value=float("NaN")) # Normal vector of patch +theta = SolverVariable(name="fl.theta", value=float("NaN")) # Rotation angle of volume zone +omega = SolverVariable(name="fl.omega", value=float("NaN")) # Rotation speed of volume zone +omegaDot = SolverVariable( + name="fl.omegaDot", value=float("NaN") +) # Rotation acceleration of volume zone +wallFunctionMetric = SolverVariable( + name="fl.wallFunctionMetric", value=float("NaN") +) # Wall model quality indicator +wallShearStress = SolverVariable( + name="fl.wallShearStress", value=float("NaN") +) # Wall viscous shear stress +yPlus = SolverVariable(name="fl.yPlus", value=float("NaN")) # Non-dimensional wall distance diff --git a/flow360/component/simulation/user_code.py b/flow360/component/simulation/user_code.py index 1a6c7accf..8f7b58bad 100644 --- a/flow360/component/simulation/user_code.py +++ b/flow360/component/simulation/user_code.py @@ -1,20 +1,19 @@ from __future__ import annotations -from typing import get_origin, Generic, TypeVar, Optional, Iterable +import re +from numbers import Number +from typing import Generic, Iterable, Optional, TypeVar + +import pydantic as pd from pydantic import BeforeValidator from typing_extensions import Self -import re +from unyt import Unit, unyt_array -from flow360.component.simulation.blueprint.flow360 import resolver -from flow360.component.simulation.unit_system import * +from flow360.component.simulation.blueprint import Evaluable, expression_to_model from flow360.component.simulation.blueprint.core import EvaluationContext +from flow360.component.simulation.blueprint.flow360 import resolver from flow360.component.simulation.framework.base_model import Flow360BaseModel -from flow360.component.simulation.blueprint import expression_to_model - -import pydantic as pd -from numbers import Number -from unyt import Unit, unyt_quantity, unyt_array - +from flow360.component.simulation.unit_system import * _global_ctx: EvaluationContext = EvaluationContext(resolver) _user_variables: set[str] = set() @@ -35,18 +34,18 @@ def _split_keep_delimiters(input: str, delimiters: list) -> list: return [part for part in result if part != ""] -def _convert_argument(other): +def _convert_argument(value): parenthesize = False unit_delimiters = ["+", "-", "*", "/", "(", ")"] - if isinstance(other, Expression): - arg = other.expression + if isinstance(value, Expression): + arg = value.expression parenthesize = True - elif isinstance(other, Variable): - arg = other.name - elif isinstance(other, Number): - arg = str(other) - elif isinstance(other, Unit): - unit = str(other) + elif isinstance(value, Variable): + arg = value.name + elif isinstance(value, Number): + arg = str(value) + elif isinstance(value, Unit): + unit = str(value) tokens = _split_keep_delimiters(unit, unit_delimiters) arg = "" for token in tokens: @@ -55,28 +54,20 @@ def _convert_argument(other): arg += token else: arg += token - elif isinstance(other, unyt_quantity): - unit = str(other.units) - tokens = _split_keep_delimiters(unit, unit_delimiters) - arg = f"{str(other.value)} * " - for token in tokens: - if token not in unit_delimiters and not _is_number_string(token): - token = f"u.{token}" - arg += token - else: - arg += token - elif isinstance(other, unyt_array): - unit = str(other.units) + elif isinstance(value, unyt_array): + unit = str(value.units) tokens = _split_keep_delimiters(unit, unit_delimiters) - arg = f"{str(other.value)} * " + arg = f"{str(value.value)} * " for token in tokens: if token not in unit_delimiters and not _is_number_string(token): token = f"u.{token}" arg += token else: arg += token + elif isinstance(value, np.ndarray): + arg = f"np.array([{','.join([_convert_argument(item)[0] for item in value])}])" else: - raise ValueError(f"Incompatible argument of type {type(other)}") + raise ValueError(f"Incompatible argument of type {type(value)}") return arg, parenthesize @@ -85,15 +76,17 @@ class SerializedValueOrExpression(Flow360BaseModel): value: Optional[Union[Number, Iterable[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, alias="evaluatedValue") + evaluated_value: Optional[Union[Number, Iterable[Number]]] = pd.Field( + None, alias="evaluatedValue" + ) evaluated_units: Optional[str] = pd.Field(None, alias="evaluatedUnits") class Variable(Flow360BaseModel): name: str = pd.Field() - value: Union[list[float], float, unyt_quantity, unyt_array] = pd.Field() + value: ValueOrExpression[Any] = pd.Field() - model_config = pd.ConfigDict(validate_assignment=True, extra='allow') + model_config = pd.ConfigDict(validate_assignment=True, extra="allow") def __add__(self, other): (arg, parenthesize) = _convert_argument(other) @@ -106,6 +99,9 @@ def __sub__(self, other): return Expression(expression=f"{self.name} - {str_arg}") def __mul__(self, other): + if isinstance(other, Number) and other == 0: + return Expression(expression="0") + (arg, parenthesize) = _convert_argument(other) str_arg = arg if not parenthesize else f"({arg})" return Expression(expression=f"{self.name} * {str_arg}") @@ -150,6 +146,9 @@ def __rsub__(self, other): return Expression(expression=f"{str_arg} - {self.name}") def __rmul__(self, other): + if isinstance(other, Number) and other == 0: + return Expression(expression="0") + (arg, parenthesize) = _convert_argument(other) str_arg = arg if not parenthesize else f"({arg})" return Expression(expression=f"{str_arg} * {self.name}") @@ -174,12 +173,37 @@ def __rpow__(self, other): str_arg = arg if not parenthesize else f"({arg})" return Expression(expression=f"{str_arg} ** {self.name}") + def __getitem__(self, item): + (arg, _) = _convert_argument(item) + return Expression(expression=f"{self.name}[{arg}]") + def __str__(self): return self.name def __repr__(self): return f"Variable({self.name} = {self.value})" + def sqrt(self): + return Expression(expression=f"np.sqrt({self.expression})") + + def sin(self): + return Expression(expression=f"np.sin({self.expression})") + + def cos(self): + return Expression(expression=f"np.cos({self.expression})") + + def tan(self): + return Expression(expression=f"np.tan({self.expression})") + + def arcsin(self): + return Expression(expression=f"np.arcsin({self.expression})") + + def arccos(self): + return Expression(expression=f"np.arccos({self.expression})") + + def arctan(self): + return Expression(expression=f"np.arctan({self.expression})") + class UserVariable(Variable): @pd.model_validator(mode="after") @@ -187,6 +211,7 @@ class UserVariable(Variable): def update_context(cls, value): _global_ctx.set(value.name, value.value) _user_variables.add(value.name) + return value class SolverVariable(Variable): @@ -194,14 +219,11 @@ class SolverVariable(Variable): @classmethod def update_context(cls, value): _global_ctx.set(value.name, value.value) + return value def _handle_syntax_error(se: SyntaxError, source: str): - caret = ( - " " * (se.offset - 1) + "^" - if se.text and se.offset - else None - ) + 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}" @@ -223,8 +245,7 @@ def _handle_syntax_error(se: SyntaxError, source: str): ) - -class Expression(Flow360BaseModel): +class Expression(Flow360BaseModel, Evaluable): expression: str = pd.Field("") model_config = pd.ConfigDict(validate_assignment=True) @@ -237,28 +258,35 @@ def _validate_expression(cls, value) -> Self: elif isinstance(value, dict) and "expression" in value.keys(): expression = value["expression"] elif isinstance(value, Expression): - expression = value.expression + expression = str(value) elif isinstance(value, Variable): expression = str(value) + elif isinstance(value, np.ndarray) and not isinstance(value, unyt_array): + expression = f"np.array([{','.join([_convert_argument(item)[0] for item in value])}])" else: details = InitErrorDetails( type="value_error", ctx={"error": f"Invalid type {type(value)}"} ) raise pd.ValidationError.from_exception_data("expression type error", [details]) - try: _ = expression_to_model(expression, _global_ctx) except SyntaxError as s_err: raise _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]) + raise pd.ValidationError.from_exception_data("Expression value error", [details]) return {"expression": expression} - def evaluate(self, strict=True) -> float: - expr = expression_to_model(self.expression, _global_ctx) - result = expr.evaluate(_global_ctx, strict) + def evaluate( + self, context: EvaluationContext = None, strict: bool = True + ) -> Union[float, list[float], unyt_array]: + if context is None: + context = _global_ctx + + expr = expression_to_model(self.expression, context) + result = expr.evaluate(context, strict) + return result def user_variables(self): @@ -283,6 +311,9 @@ def __sub__(self, other): return Expression(expression=f"{self.expression} - {str_arg}") def __mul__(self, other): + if isinstance(other, Number) and other == 0: + return Expression(expression="0") + (arg, parenthesize) = _convert_argument(other) str_arg = arg if not parenthesize else f"({arg})" return Expression(expression=f"({self.expression}) * {str_arg}") @@ -327,6 +358,9 @@ def __rsub__(self, other): return Expression(expression=f"{str_arg} - {self.expression}") def __rmul__(self, other): + if isinstance(other, Number) and other == 0: + return Expression(expression="0") + (arg, parenthesize) = _convert_argument(other) str_arg = arg if not parenthesize else f"({arg})" return Expression(expression=f"{str_arg} * ({self.expression})") @@ -351,12 +385,37 @@ 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) + return Expression(expression=f"({self.expression})[{arg}]") + def __str__(self): return self.expression def __repr__(self): return f"Expression({self.expression})" + def sqrt(self): + return Expression(expression=f"np.sqrt({self.expression})") + + def sin(self): + return Expression(expression=f"np.sin({self.expression})") + + def cos(self): + return Expression(expression=f"np.cos({self.expression})") + + def tan(self): + return Expression(expression=f"np.tan({self.expression})") + + def arcsin(self): + return Expression(expression=f"np.arcsin({self.expression})") + + def arccos(self): + return Expression(expression=f"np.arccos({self.expression})") + + def arctan(self): + return Expression(expression=f"np.arctan({self.expression})") + T = TypeVar("T") @@ -367,28 +426,30 @@ 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, strict=True) - + raise ValueError(f"expression evaluation failed: {err}") from err + pd.TypeAdapter(internal_type).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, strict=True) + 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_quantity(value.value, value.units) + return unyt_array(value.value, value.units) else: return value.value elif value.type_name == "expression": return expr_type(expression=value.expression) - except Exception as err: - pass - - return value + else: + return value def _serializer(value, info) -> dict: if isinstance(value, Expression): @@ -400,7 +461,7 @@ def _serializer(value, info) -> dict: if isinstance(evaluated, Number): serialized.evaluated_value = evaluated - elif isinstance(evaluated, unyt_quantity) or isinstance(evaluated, unyt_array): + elif isinstance(evaluated, unyt_array): if evaluated.size == 1: serialized.evaluated_value = float(evaluated.value) @@ -412,7 +473,7 @@ def _serializer(value, info) -> dict: serialized = SerializedValueOrExpression(typeName="number") if isinstance(value, Number): serialized.value = value - elif isinstance(value, unyt_quantity) or isinstance(value, unyt_array): + elif isinstance(value, unyt_array): if value.size == 1: serialized.value = float(value.value) @@ -422,8 +483,9 @@ def _serializer(value, info) -> dict: serialized.units = str(value.units.expr) 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)] diff --git a/flow360/component/simulation/utils.py b/flow360/component/simulation/utils.py index 4b6682240..e2bf9ceb2 100644 --- a/flow360/component/simulation/utils.py +++ b/flow360/component/simulation/utils.py @@ -61,7 +61,7 @@ def is_instance_of_type_in_union(obj, typ) -> bool: class UnknownFloat(float): def __new__(cls): - return super().__new__(cls, float('nan')) + return super().__new__(cls, float("nan")) def __repr__(self): return "UnknownFloat()" diff --git a/tests/simulation/test_expressions.py b/tests/simulation/test_expressions.py index 54823b888..f4de08c74 100644 --- a/tests/simulation/test_expressions.py +++ b/tests/simulation/test_expressions.py @@ -1,45 +1,41 @@ -from math import isnan -from pprint import pprint from typing import List -import pytest - -from flow360.component.simulation.user_code import ( - ValueOrExpression, - UserVariable, - Expression, -) -from flow360.component.simulation.framework.base_model import Flow360BaseModel - +import numpy as np import pydantic as pd -from flow360 import u +import pytest import flow360 as fl - +from flow360 import u +from flow360.component.simulation.framework.base_model import Flow360BaseModel from flow360.component.simulation.unit_system import ( - LengthType, - AngleType, - MassType, - TimeType, AbsoluteTemperatureType, - VelocityType, + AngleType, + AngularVelocityType, AreaType, - ForceType, - PressureType, DensityType, - ViscosityType, - PowerType, - MomentType, - AngularVelocityType, + ForceType, + FrequencyType, HeatFluxType, HeatSourceType, - SpecificHeatCapacityType, InverseAreaType, + InverseLengthType, + LengthType, MassFlowRateType, + MassType, + MomentType, + PowerType, + PressureType, SpecificEnergyType, - FrequencyType, + SpecificHeatCapacityType, ThermalConductivityType, - InverseLengthType, + TimeType, + VelocityType, + ViscosityType, +) +from flow360.component.simulation.user_code import ( + Expression, + UserVariable, + ValueOrExpression, ) @@ -55,8 +51,11 @@ class TestModel(Flow360BaseModel): # Dimensioned value b = UserVariable(name="b", value=1 * u.m) + # Expression (possibly with other variable) + c = UserVariable(name="c", value=b + 1 * u.m) + # A dictionary (can contain extra values - important for frontend) - c = UserVariable.model_validate({"name": "c", "value": 1, "extra": "foo"}) + d = UserVariable.model_validate({"name": "d", "value": 1, "extra": "foo"}) def test_expression_init(): @@ -368,23 +367,19 @@ class TestModel(Flow360BaseModel): x = UserVariable(name="x", value=4) - model = TestModel(field=x * u.m / u.s + 4 * x ** 2 * u.m / u.s) + 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) - print(model.model_dump_json(indent=2, 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) serialized = model.model_dump(exclude_none=True) - print(model.model_dump_json(indent=2, exclude_none=True)) - assert serialized["field"]["type_name"] == "number" assert serialized["field"]["value"] == 4 assert serialized["field"]["units"] == "m/s" @@ -400,25 +395,130 @@ class TestModel(Flow360BaseModel): "type_name": "expression", "expression": "(x * u.m) / u.s + (((4 * (x ** 2)) * u.m) / u.s)", "evaluated_value": 68.0, - "evaluated_units": "m/s" + "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" - } + model = {"type_name": "number", "value": 4.0, "units": "m/s"} deserialized = TestModel(field=model) - assert str(deserialized.field) == '4.0 m/s' + assert str(deserialized.field) == "4.0 m/s" + + +def test_numpy_interop_scalars(): + # Disclaimer - doesn't fully work yet with dimensioned types... + + 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(): + # Disclaimer - doesn't fully work yet with dimensioned types... + + 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() + + x = UserVariable(name="x", value=np.array([2, 3, 4])) + + model = ScalarModel(scalar=x[0] + x[1] + x[2] + 1) + + assert str(model.scalar) == "x[0] + (x[1]) + (x[2]) + 1" + + assert model.scalar.evaluate() == 10 + + model = ScalarModel(scalar="x[0] + x[1] + x[2] + 1") + + assert str(model.scalar) == "x[0] + x[1] + x[2] + 1" + + assert model.scalar.evaluate() == 10 + def test_error_message(): class TestModel(Flow360BaseModel): @@ -430,26 +530,25 @@ class TestModel(Flow360BaseModel): model = TestModel(field="1 + nonexisting * 1") except pd.ValidationError as err: validation_errors = err.errors() - + assert len(validation_errors) >= 1 assert validation_errors[0]["type"] == "value_error" assert "Name 'nonexisting' is not defined" in validation_errors[0]["msg"] - + try: model = TestModel(field="1 + x * 1") except pd.ValidationError as err: validation_errors = err.errors() - + assert len(validation_errors) >= 1 assert validation_errors[0]["type"] == "value_error" assert "does not match (length)/(time) dimension" in validation_errors[0]["msg"] - try: model = TestModel(field="1 * 1 +") except pd.ValidationError as err: validation_errors = err.errors() - + assert len(validation_errors) >= 1 assert validation_errors[0]["type"] == "value_error" assert "invalid syntax" in validation_errors[0]["msg"] @@ -462,7 +561,7 @@ class TestModel(Flow360BaseModel): model = TestModel(field="1 * 1 +* 2") except pd.ValidationError as err: validation_errors = err.errors() - + assert len(validation_errors) >= 1 assert validation_errors[0]["type"] == "value_error" assert "invalid syntax" in validation_errors[0]["msg"] @@ -475,7 +574,7 @@ class TestModel(Flow360BaseModel): model = TestModel(field="1 * 1 + (2") except pd.ValidationError as err: validation_errors = err.errors() - + assert len(validation_errors) >= 1 assert validation_errors[0]["type"] == "value_error" assert "unexpected EOF" in validation_errors[0]["msg"] @@ -483,4 +582,3 @@ class TestModel(Flow360BaseModel): assert "line" in validation_errors[0]["ctx"] assert "column" in validation_errors[0]["ctx"] assert validation_errors[0]["ctx"]["column"] == 11 - From 6f41bf4a32182e3a0a19a5e3af11043a88e021c3 Mon Sep 17 00:00:00 2001 From: Andrzej Krupka <156919532+andrzej-krupka@users.noreply.github.com> Date: Thu, 8 May 2025 19:11:35 +0200 Subject: [PATCH 10/34] Reorganized solver variables into target namespaces (#986) * Reorganized solver variables into target namespaces * Apply PR feedback * Added ability to convert expressions to C++ syntax (#1009) Co-authored-by: Andrzej Krupka * Update CI yaml * Fix unit tests after alias changes * Removed unused __init__.py items --------- Co-authored-by: Andrzej Krupka Co-authored-by: benflexcompute --- .github/workflows/codestyle.yml | 8 - .github/workflows/test.yml | 1 - flow360/__init__.py | 90 +----- .../simulation/blueprint/__init__.py | 4 +- .../simulation/blueprint/codegen/generator.py | 295 ++++++++++++------ .../simulation/blueprint/codegen/parser.py | 2 +- .../simulation/blueprint/flow360/symbols.py | 64 +++- .../simulation/blueprint/utils/operators.py | 2 + .../simulation/blueprint/utils/types.py | 7 + flow360/component/simulation/services.py | 9 +- .../component/simulation/solver_builtins.py | 95 ------ flow360/component/simulation/user_code.py | 36 ++- .../simulation/variables/__init__.py | 0 .../simulation/variables/control_variables.py | 48 +++ .../variables/solution_variables.py | 61 ++++ tests/simulation/test_expressions.py | 24 +- 16 files changed, 441 insertions(+), 305 deletions(-) create mode 100644 flow360/component/simulation/blueprint/utils/types.py delete mode 100644 flow360/component/simulation/solver_builtins.py create mode 100644 flow360/component/simulation/variables/__init__.py create mode 100644 flow360/component/simulation/variables/control_variables.py create mode 100644 flow360/component/simulation/variables/solution_variables.py diff --git a/.github/workflows/codestyle.yml b/.github/workflows/codestyle.yml index 3d6b397a4..16061566c 100644 --- a/.github/workflows/codestyle.yml +++ b/.github/workflows/codestyle.yml @@ -1,11 +1,3 @@ -# This workflow will upload a Python Package using Twine when a release is created -# For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries - -# This workflow uses actions that are not certified by GitHub. -# They are provided by a third-party and are governed by -# separate terms of service, privacy policy, and support -# documentation. - name: Codestyle checking on: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index fbf55accd..2dd3db0a6 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -6,7 +6,6 @@ on: branches: [ develop, release-candidate/* ] pull_request: types: [ opened, synchronize, reopened, ready_for_review ] - branches: [ develop, release-candidate/* ] workflow_call: jobs: diff --git a/flow360/__init__.py b/flow360/__init__.py index c8b52f0f1..1fcb2cece 100644 --- a/flow360/__init__.py +++ b/flow360/__init__.py @@ -134,51 +134,6 @@ Transformation, ) from flow360.component.simulation.simulation_params import SimulationParams -from flow360.component.simulation.solver_builtins import ( - CD, - CL, - alphaAngle, - bet_omega, - bet_thrust, - bet_torque, - betaAngle, - coordinate, - forceX, - forceY, - forceZ, - kOmega, - momentCenterX, - momentCenterY, - momentCenterZ, - momentLengthX, - momentLengthY, - momentLengthZ, - momentX, - momentY, - momentZ, - mu, - mut, - nodeNormals, - nuHat, - omega, - omegaDot, - physicalStep, - pressureFreestream, - pseudoStep, - residualHeatSolver, - residualNavierStokes, - residualTransition, - residualTurbulence, - solutionHeatSolver, - solutionNavierStokes, - solutionTransition, - solutionTurbulence, - theta, - timeStepSize, - wallFunctionMetric, - wallShearStress, - yPlus, -) from flow360.component.simulation.time_stepping.time_stepping import ( AdaptiveCFL, RampCFL, @@ -193,6 +148,8 @@ 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 @@ -317,48 +274,5 @@ "PointArray2D", "StreamlineOutput", "Transformation", - "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", "WallRotation", ] diff --git a/flow360/component/simulation/blueprint/__init__.py b/flow360/component/simulation/blueprint/__init__.py index 0998e6e9b..94cb27d9d 100644 --- a/flow360/component/simulation/blueprint/__init__.py +++ b/flow360/component/simulation/blueprint/__init__.py @@ -1,8 +1,8 @@ """Blueprint: Safe function serialization and visual programming integration.""" from .codegen.generator import model_to_function -from .codegen.parser import expression_to_model, function_to_model +from .codegen.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", "expression_to_model"] +__all__ = ["Function", "function_to_model", "model_to_function", "expr_to_model"] diff --git a/flow360/component/simulation/blueprint/codegen/generator.py b/flow360/component/simulation/blueprint/codegen/generator.py index 8e112c792..2b06b6984 100644 --- a/flow360/component/simulation/blueprint/codegen/generator.py +++ b/flow360/component/simulation/blueprint/codegen/generator.py @@ -1,3 +1,4 @@ +import functools from typing import Any from ..core.expressions import ( @@ -9,10 +10,12 @@ Name, RangeCall, Tuple, + UnaryOp, ) from ..core.function import Function from ..core.statements import Assign, AugAssign, ForLoop, IfElse, Return, TupleUnpack -from ..utils.operators import BINARY_OPERATORS +from ..utils.operators import BINARY_OPERATORS, UNARY_OPERATORS +from ..utils.types import TargetSyntax def _indent(code: str, level: int = 1) -> str: @@ -21,33 +24,77 @@ def _indent(code: str, level: int = 1) -> str: return "\n".join(spaces + line if line else line for line in code.split("\n")) -def expr_to_code(expr: Any) -> str: - """Convert an expression model back to Python code.""" - if expr is None: +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: + return "nullptr" - if isinstance(expr, Name): - return expr.id - elif isinstance(expr, Constant): - if isinstance(expr.value, str): - return f"'{expr.value}'" - return str(expr.value) +@check_syntax_type +def _name(expr, remap): + return expr.id if expr.id not in remap else remap[expr.id] - elif isinstance(expr, BinOp): - op_info = BINARY_OPERATORS[expr.op] - return f"({expr_to_code(expr.left)} {op_info.symbol} {expr_to_code(expr.right)})" - elif isinstance(expr, RangeCall): - return f"range({expr_to_code(expr.arg)})" +@check_syntax_type +def _constant(expr): + if isinstance(expr.value, str): + return f"'{expr.value}'" + return str(expr.value) - elif isinstance(expr, CallModel): - args_str = ", ".join(expr_to_code(arg) for arg in expr.args) + +@check_syntax_type +def _unary_op(expr, syntax, remap): + op_info = UNARY_OPERATORS[expr.op] + return f"{op_info.symbol}{expr_to_code(expr.operand, syntax, remap)}" + + +@check_syntax_type +def _binary_op(expr, syntax, remap): + if syntax == TargetSyntax.CPP: + # Special case handling for operators not directly supported in CPP syntax, requires #include + if expr.op == "FloorDiv": + return f"floor({expr_to_code(expr.left, syntax, remap)} / {expr_to_code(expr.right, syntax, remap)})" + if expr.op == "Pow": + return f"pow({expr_to_code(expr.left, syntax, remap)}, {expr_to_code(expr.right, syntax, remap)})" + if expr.op == "Is": + return f"&{expr_to_code(expr.left, syntax, remap)} == &{expr_to_code(expr.right, syntax, remap)}" + + op_info = BINARY_OPERATORS[expr.op] + return f"({expr_to_code(expr.left, syntax, remap)} {op_info.symbol} {expr_to_code(expr.right, syntax, remap)})" + + +@check_syntax_type +def _range_call(expr, syntax, remap): + if syntax == TargetSyntax.PYTHON: + return f"range({expr_to_code(expr.arg, syntax, remap)})" + + raise ValueError("Range calls are only supported for Python target syntax") + + +@check_syntax_type +def _call_model(expr, syntax, remap): + if syntax == TargetSyntax.PYTHON: + args_str = ", ".join(expr_to_code(arg, syntax, remap) for arg in expr.args) kwargs_parts = [] for k, v in expr.kwargs.items(): if v is None: continue - val_str = expr_to_code(v) + val_str = expr_to_code(v, syntax, remap) if not val_str or val_str.isspace(): continue kwargs_parts.append(f"{k}={val_str}") @@ -55,96 +102,166 @@ def expr_to_code(expr: Any) -> str: 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: + args_str = ", ".join(expr_to_code(arg, syntax, remap) for arg in expr.args) + if expr.kwargs: + raise ValueError("Named arguments are not supported in C++ syntax") + return f"{expr.func_qualname}({args_str})" - elif isinstance(expr, Tuple): + +@check_syntax_type +def _tuple(expr, syntax, remap): + if syntax == TargetSyntax.PYTHON: if len(expr.elements) == 0: return "()" elif len(expr.elements) == 1: - return f"({expr_to_code(expr.elements[0])},)" - return f"({', '.join(expr_to_code(e) for e in expr.elements)})" + return f"({expr_to_code(expr.elements[0], syntax, remap)},)" + return f"({', '.join(expr_to_code(e, syntax, remap) for e in expr.elements)})" + elif syntax == TargetSyntax.CPP: + if len(expr.elements) == 0: + return "{}" + elif len(expr.elements) == 1: + return f"{{{expr_to_code(expr.elements[0], syntax, remap)}}}" + return f"{{{', '.join(expr_to_code(e, syntax, remap) for e in expr.elements)}}}" - elif isinstance(expr, List): + +@check_syntax_type +def _list(expr, syntax, remap): + if syntax == TargetSyntax.PYTHON: if not expr.elements: return "[]" - elements = [expr_to_code(e) for e in expr.elements] + elements = [expr_to_code(e, syntax, remap) for e in expr.elements] elements_str = ", ".join(elements) return f"[{elements_str}]" + elif syntax == TargetSyntax.CPP: + if len(expr.elements) == 0: + return "{}" + return f"{{{', '.join(expr_to_code(e, syntax, remap) for e in expr.elements)}}}" + + +def _list_comp(expr, syntax, remap): + if syntax == TargetSyntax.PYTHON: + return f"[{expr_to_code(expr.element, syntax, remap)} for {expr.target, syntax, remap} in {expr_to_code(expr.iter, syntax, remap)}]" + + raise ValueError("List comprehensions are only supported for Python target syntax") + + +def expr_to_code( + expr: Any, syntax: TargetSyntax = TargetSyntax.PYTHON, remap: dict[str, str] = None +) -> str: + """Convert an expression model back to source code.""" + if expr is None: + return _empty(syntax) + + # Names and constants are language-agnostic (apart from symbol remaps) + if isinstance(expr, Name): + return _name(expr, remap) + + elif isinstance(expr, Constant): + return _constant(expr) + + elif isinstance(expr, UnaryOp): + return _unary_op(expr, syntax, remap) + + elif isinstance(expr, BinOp): + return _binary_op(expr, syntax, remap) + + elif isinstance(expr, RangeCall): + return _range_call(expr, syntax, remap) + + elif isinstance(expr, CallModel): + return _call_model(expr, syntax, remap) + + elif isinstance(expr, Tuple): + return _tuple(expr, syntax, remap) + + elif isinstance(expr, List): + return _list(expr, syntax, remap) elif isinstance(expr, ListComp): - return f"[{expr_to_code(expr.element)} for {expr.target} in {expr_to_code(expr.iter)}]" + return _list_comp(expr, syntax, remap) else: raise ValueError(f"Unsupported expression type: {type(expr)}") -def stmt_to_code(stmt: Any) -> str: - """Convert a statement model back to Python 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)}" - - elif isinstance(stmt, AugAssign): - op_map = { - "Add": "+=", - "Sub": "-=", - "Mult": "*=", - "Div": "/=", - } - op_str = op_map.get(stmt.op, f"{stmt.op}=") - return f"{stmt.target} {op_str} {expr_to_code(stmt.value)}" - - elif isinstance(stmt, IfElse): - code = [f"if {expr_to_code(stmt.condition)}:"] - code.append(_indent("\n".join(stmt_to_code(s) for s in stmt.body))) - if stmt.orelse: - code.append("else:") - code.append(_indent("\n".join(stmt_to_code(s) for s in stmt.orelse))) - return "\n".join(code) - - elif isinstance(stmt, ForLoop): - code = [f"for {stmt.target} in {expr_to_code(stmt.iter)}:"] - code.append(_indent("\n".join(stmt_to_code(s) for s in stmt.body))) - return "\n".join(code) - - elif isinstance(stmt, Return): - return f"return {expr_to_code(stmt.value)}" - - elif 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])}" - else: - # Multiple expressions - values = ", ".join(expr_to_code(v) for v in stmt.values) - return f"{targets} = {values}" +def stmt_to_code( + stmt: Any, syntax: TargetSyntax = TargetSyntax.PYTHON, remap: dict[str, str] = None +) -> str: + 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)}" - else: - raise ValueError(f"Unsupported statement type: {type(stmt)}") - - -def model_to_function(func: Function) -> str: - """Convert a Function model back to Python code.""" - # Build the function signature - args_with_defaults = [] - for arg in func.args: - if arg in func.defaults: - default_val = func.defaults[arg] - if isinstance(default_val, int | float | bool | str): - args_with_defaults.append(f"{arg}={default_val}") + elif isinstance(stmt, AugAssign): + op_map = { + "Add": "+=", + "Sub": "-=", + "Mult": "*=", + "Div": "/=", + } + 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): + 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: + code.append("else:") + code.append(_indent("\n".join(stmt_to_code(s, syntax, remap) for s in stmt.orelse))) + return "\n".join(code) + + elif 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): + return f"return {expr_to_code(stmt.value, syntax, remap)}" + + elif 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: - args_with_defaults.append(f"{arg}={expr_to_code(default_val)}") + # Multiple expressions + values = ", ".join(expr_to_code(v, syntax, remap) for v in stmt.values) + return f"{targets} = {values}" else: - args_with_defaults.append(arg) + raise ValueError(f"Unsupported statement type: {type(stmt)}") + + raise NotImplementedError("Statement translation is not available for other syntax types yet") + + +def model_to_function( + func: Function, syntax: TargetSyntax = TargetSyntax.PYTHON, remap: dict[str, str] = None +) -> str: + 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: + default_val = func.defaults[arg] + if isinstance(default_val, (int, float, str, bool)): + args_with_defaults.append(f"{arg}={default_val}") + else: + args_with_defaults.append(f"{arg}={expr_to_code(default_val, syntax, remap)}") + else: + args_with_defaults.append(arg) + + signature = f"def {func.name}({', '.join(args_with_defaults)}):" - signature = f"def {func.name}({', '.join(args_with_defaults)}):" + # Convert the function body + body_lines = [] + for stmt in func.body: + line = stmt_to_code(stmt) + body_lines.append(line) - # Convert the function body - body_lines = [] - for stmt in func.body: - line = stmt_to_code(stmt) - body_lines.append(line) + body = "\n".join(body_lines) if body_lines else "pass" + return f"{signature}\n{_indent(body)}" - body = "\n".join(body_lines) if body_lines else "pass" - return f"{signature}\n{_indent(body)}" + raise NotImplementedError("Function translation is not available for other syntax types yet") diff --git a/flow360/component/simulation/blueprint/codegen/parser.py b/flow360/component/simulation/blueprint/codegen/parser.py index 9b7f1d1a9..82900dbbb 100644 --- a/flow360/component/simulation/blueprint/codegen/parser.py +++ b/flow360/component/simulation/blueprint/codegen/parser.py @@ -231,7 +231,7 @@ def function_to_model( return Function(name=name, args=args, body=body, defaults=defaults) -def expression_to_model( +def expr_to_model( source: str, ctx: EvaluationContext, ) -> Expression: diff --git a/flow360/component/simulation/blueprint/flow360/symbols.py b/flow360/component/simulation/blueprint/flow360/symbols.py index 8fe597574..6d986aaa2 100644 --- a/flow360/component/simulation/blueprint/flow360/symbols.py +++ b/flow360/component/simulation/blueprint/flow360/symbols.py @@ -29,6 +29,16 @@ def _import_flow360(name: str) -> Any: return u + if name == "control": + from flow360 import control + + return control + + if name == "solution": + from flow360 import solution + + return solution + def _import_numpy(name: str) -> Any: import numpy as np @@ -37,10 +47,60 @@ def _import_numpy(name: str) -> Any: 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.solver_builtins": { - "prefix": "fl.", + "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", diff --git a/flow360/component/simulation/blueprint/utils/operators.py b/flow360/component/simulation/blueprint/utils/operators.py index 249f15546..b72a7317f 100644 --- a/flow360/component/simulation/blueprint/utils/operators.py +++ b/flow360/component/simulation/blueprint/utils/operators.py @@ -2,6 +2,8 @@ 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 new file mode 100644 index 000000000..bbeaf35e7 --- /dev/null +++ b/flow360/component/simulation/blueprint/utils/types.py @@ -0,0 +1,7 @@ +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/services.py b/flow360/component/simulation/services.py index f32093079..273536b4a 100644 --- a/flow360/component/simulation/services.py +++ b/flow360/component/simulation/services.py @@ -11,6 +11,7 @@ from unyt import unyt_array, unyt_quantity from unyt.exceptions import UnitParseError +# Required for correct global scope initialization from flow360.component.simulation.exposed_units import supported_units_by_front_end from flow360.component.simulation.framework.multi_constructor_model_base import ( parse_model_dict, @@ -23,7 +24,9 @@ from flow360.component.simulation.models.volume_models import ( # pylint: disable=unused-import BETDisk, ) -from flow360.component.simulation.operating_condition.operating_condition import ( # pylint: disable=unused-import + +# pylint: disable=unused-import +from flow360.component.simulation.operating_condition.operating_condition import ( AerospaceCondition, GenericReferenceCondition, ThermalState, @@ -37,7 +40,6 @@ ) # Required for correct global scope initialization -from flow360.component.simulation.solver_builtins import * from flow360.component.simulation.translator.solver_translator import get_solver_json from flow360.component.simulation.translator.surface_meshing_translator import ( get_surface_meshing_json, @@ -65,6 +67,9 @@ from flow360.exceptions import Flow360RuntimeError, Flow360TranslationError from flow360.version import __version__ +# Required for correct global scope initialization + + unit_system_map = { "SI": SI_unit_system, "CGS": CGS_unit_system, diff --git a/flow360/component/simulation/solver_builtins.py b/flow360/component/simulation/solver_builtins.py deleted file mode 100644 index e4fa38e31..000000000 --- a/flow360/component/simulation/solver_builtins.py +++ /dev/null @@ -1,95 +0,0 @@ -from flow360.component.simulation.user_code import SolverVariable - -mut = SolverVariable(name="fl.mut", value=float("NaN")) # Turbulent viscosity -mu = SolverVariable(name="fl.mu", value=float("NaN")) # Laminar viscosity -solutionNavierStokes = SolverVariable( - name="fl.solutionNavierStokes", value=float("NaN") -) # Solution for N-S equation in conservative form -residualNavierStokes = SolverVariable( - name="fl.residualNavierStokes", value=float("NaN") -) # Residual for N-S equation in conservative form -solutionTurbulence = SolverVariable( - name="fl.solutionTurbulence", value=float("NaN") -) # Solution for turbulence model -residualTurbulence = SolverVariable( - name="fl.residualTurbulence", value=float("NaN") -) # Residual for turbulence model -kOmega = SolverVariable( - name="fl.kOmega", value=float("NaN") -) # Effectively solutionTurbulence when using SST model -nuHat = SolverVariable( - name="fl.nuHat", value=float("NaN") -) # Effectively solutionTurbulence when using SA model -solutionTransition = SolverVariable( - name="fl.solutionTransition", value=float("NaN") -) # Solution for transition model -residualTransition = SolverVariable( - name="fl.residualTransition", value=float("NaN") -) # Residual for transition model -solutionHeatSolver = SolverVariable( - name="fl.solutionHeatSolver", value=float("NaN") -) # Solution for heat equation -residualHeatSolver = SolverVariable( - name="fl.residualHeatSolver", value=float("NaN") -) # Residual for heat equation -coordinate = SolverVariable(name="fl.coordinate", value=float("NaN")) # Grid coordinates - -physicalStep = SolverVariable( - name="fl.physicalStep", value=float("NaN") -) # Physical time step, starting from 0 -pseudoStep = SolverVariable( - name="fl.pseudoStep", value=float("NaN") -) # Pseudo time step within physical time step -timeStepSize = SolverVariable(name="fl.timeStepSize", value=float("NaN")) # Physical time step size -alphaAngle = SolverVariable( - name="fl.alphaAngle", value=float("NaN") -) # Alpha angle specified in freestream -betaAngle = SolverVariable( - name="fl.betaAngle", value=float("NaN") -) # Beta angle specified in freestream -pressureFreestream = SolverVariable( - name="fl.pressureFreestream", value=float("NaN") -) # Freestream reference pressure (1.0/1.4) -momentLengthX = SolverVariable( - name="fl.momentLengthX", value=float("NaN") -) # X component of momentLength -momentLengthY = SolverVariable( - name="fl.momentLengthY", value=float("NaN") -) # Y component of momentLength -momentLengthZ = SolverVariable( - name="fl.momentLengthZ", value=float("NaN") -) # Z component of momentLength -momentCenterX = SolverVariable( - name="fl.momentCenterX", value=float("NaN") -) # X component of momentCenter -momentCenterY = SolverVariable( - name="fl.momentCenterY", value=float("NaN") -) # Y component of momentCenter -momentCenterZ = SolverVariable( - name="fl.momentCenterZ", value=float("NaN") -) # Z component of momentCenter - -bet_thrust = SolverVariable(name="fl.bet_thrust", value=float("NaN")) # Thrust force for BET disk -bet_torque = SolverVariable(name="fl.bet_torque", value=float("NaN")) # Torque for BET disk -bet_omega = SolverVariable(name="fl.bet_omega", value=float("NaN")) # Rotation speed for BET disk -CD = SolverVariable(name="fl.CD", value=float("NaN")) # Drag coefficient on patch -CL = SolverVariable(name="fl.CL", value=float("NaN")) # Lift coefficient on patch -forceX = SolverVariable(name="fl.forceX", value=float("NaN")) # Total force in X direction -forceY = SolverVariable(name="fl.forceY", value=float("NaN")) # Total force in Y direction -forceZ = SolverVariable(name="fl.forceZ", value=float("NaN")) # Total force in Z direction -momentX = SolverVariable(name="fl.momentX", value=float("NaN")) # Total moment in X direction -momentY = SolverVariable(name="fl.momentY", value=float("NaN")) # Total moment in Y direction -momentZ = SolverVariable(name="fl.momentZ", value=float("NaN")) # Total moment in Z direction -nodeNormals = SolverVariable(name="fl.nodeNormals", value=float("NaN")) # Normal vector of patch -theta = SolverVariable(name="fl.theta", value=float("NaN")) # Rotation angle of volume zone -omega = SolverVariable(name="fl.omega", value=float("NaN")) # Rotation speed of volume zone -omegaDot = SolverVariable( - name="fl.omegaDot", value=float("NaN") -) # Rotation acceleration of volume zone -wallFunctionMetric = SolverVariable( - name="fl.wallFunctionMetric", value=float("NaN") -) # Wall model quality indicator -wallShearStress = SolverVariable( - name="fl.wallShearStress", value=float("NaN") -) # Wall viscous shear stress -yPlus = SolverVariable(name="fl.yPlus", value=float("NaN")) # Non-dimensional wall distance diff --git a/flow360/component/simulation/user_code.py b/flow360/component/simulation/user_code.py index 8f7b58bad..6f7aeaa8a 100644 --- a/flow360/component/simulation/user_code.py +++ b/flow360/component/simulation/user_code.py @@ -9,14 +9,17 @@ from typing_extensions import Self from unyt import Unit, unyt_array -from flow360.component.simulation.blueprint import Evaluable, expression_to_model +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.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() def _is_number_string(s: str) -> bool: @@ -72,14 +75,12 @@ def _convert_argument(value): class SerializedValueOrExpression(Flow360BaseModel): - type_name: Union[Literal["number"], Literal["expression"]] = pd.Field(None, alias="typeName") + 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) expression: Optional[str] = pd.Field(None) - evaluated_value: Optional[Union[Number, Iterable[Number]]] = pd.Field( - None, alias="evaluatedValue" - ) - evaluated_units: Optional[str] = pd.Field(None, alias="evaluatedUnits") + evaluated_value: Optional[Union[Number, Iterable[Number]]] = pd.Field(None) + evaluated_units: Optional[str] = pd.Field(None) class Variable(Flow360BaseModel): @@ -215,10 +216,15 @@ def update_context(cls, value): class SolverVariable(Variable): + solver_name: Optional[str] = pd.Field(None) + @pd.model_validator(mode="after") @classmethod def update_context(cls, value): _global_ctx.set(value.name, value.value) + _solver_variables[value.name] = ( + value.solver_name if value.solver_name is not None else value.name + ) return value @@ -269,7 +275,7 @@ def _validate_expression(cls, value) -> Self: ) raise pd.ValidationError.from_exception_data("expression type error", [details]) try: - _ = expression_to_model(expression, _global_ctx) + expr_to_model(expression, _global_ctx) except SyntaxError as s_err: raise _handle_syntax_error(s_err, expression) except ValueError as v_err: @@ -283,20 +289,24 @@ def evaluate( ) -> Union[float, list[float], unyt_array]: if context is None: context = _global_ctx - - expr = expression_to_model(self.expression, context) + expr = expr_to_model(self.expression, context) result = expr.evaluate(context, strict) - return result def user_variables(self): - expr = expression_to_model(self.expression, _global_ctx) + expr = expr_to_model(self.expression, _global_ctx) 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] + def to_solver_code(self): + expr = expr_to_model(self.expression, _global_ctx) + source = expr_to_code(expr, TargetSyntax.CPP, _solver_variables) + # TODO: What do we do with dimensioned expressions? We need to replace all units by their conversion factors. + return source + def __hash__(self): return hash(self.expression) @@ -453,7 +463,7 @@ def _deserialize(value) -> Self: def _serializer(value, info) -> dict: if isinstance(value, Expression): - serialized = SerializedValueOrExpression(typeName="expression") + serialized = SerializedValueOrExpression(type_name="expression") serialized.expression = value.expression @@ -470,7 +480,7 @@ def _serializer(value, info) -> dict: serialized.evaluated_units = str(evaluated.units.expr) else: - serialized = SerializedValueOrExpression(typeName="number") + serialized = SerializedValueOrExpression(type_name="number") if isinstance(value, Number): serialized.value = value elif isinstance(value, unyt_array): diff --git a/flow360/component/simulation/variables/__init__.py b/flow360/component/simulation/variables/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/flow360/component/simulation/variables/control_variables.py b/flow360/component/simulation/variables/control_variables.py new file mode 100644 index 000000000..e9e30c7e3 --- /dev/null +++ b/flow360/component/simulation/variables/control_variables.py @@ -0,0 +1,48 @@ +from flow360.component.simulation.user_code import SolverVariable + +MachRef = SolverVariable( + name="control.MachRef", value=float("NaN") +) # Reference mach specified by the user +Tref = SolverVariable(name="control.Tref", value=float("NaN")) # Temperature specified by the user +t = SolverVariable(name="control.t", value=float("NaN")) # Physical time +physicalStep = SolverVariable( + name="control.physicalStep", value=float("NaN") +) # Physical time step, starting from 0 +pseudoStep = SolverVariable( + name="control.pseudoStep", value=float("NaN") +) # Pseudo time step within physical time step +timeStepSize = SolverVariable( + name="control.timeStepSize", value=float("NaN") +) # Physical time step size +alphaAngle = SolverVariable( + name="control.alphaAngle", value=float("NaN") +) # Alpha angle specified in freestream +betaAngle = SolverVariable( + name="control.betaAngle", value=float("NaN") +) # Beta angle specified in freestream +pressureFreestream = SolverVariable( + name="control.pressureFreestream", value=float("NaN") +) # Freestream reference pressure (1.0/1.4) +momentLengthX = SolverVariable( + name="control.momentLengthX", value=float("NaN") +) # X component of momentLength +momentLengthY = SolverVariable( + name="control.momentLengthY", value=float("NaN") +) # Y component of momentLength +momentLengthZ = SolverVariable( + name="control.momentLengthZ", value=float("NaN") +) # Z component of momentLength +momentCenterX = SolverVariable( + name="control.momentCenterX", value=float("NaN") +) # X component of momentCenter +momentCenterY = SolverVariable( + name="control.momentCenterY", value=float("NaN") +) # Y component of momentCenter +momentCenterZ = SolverVariable( + name="control.momentCenterZ", value=float("NaN") +) # Z component of momentCenter +theta = SolverVariable(name="control.theta", value=float("NaN")) # Rotation angle of volume zone +omega = SolverVariable(name="control.omega", value=float("NaN")) # Rotation speed of volume zone +omegaDot = SolverVariable( + name="control.omegaDot", value=float("NaN") +) # Rotation acceleration of volume zone diff --git a/flow360/component/simulation/variables/solution_variables.py b/flow360/component/simulation/variables/solution_variables.py new file mode 100644 index 000000000..07237b7c8 --- /dev/null +++ b/flow360/component/simulation/variables/solution_variables.py @@ -0,0 +1,61 @@ +from flow360.component.simulation.user_code import SolverVariable + +mut = SolverVariable(name="solution.mut", value=float("NaN")) # Turbulent viscosity +mu = SolverVariable(name="solution.mu", value=float("NaN")) # Laminar viscosity +solutionNavierStokes = SolverVariable( + name="solution.solutionNavierStokes", value=float("NaN") +) # Solution for N-S equation in conservative form +residualNavierStokes = SolverVariable( + name="solution.residualNavierStokes", value=float("NaN") +) # Residual for N-S equation in conservative form +solutionTurbulence = SolverVariable( + name="solution.solutionTurbulence", value=float("NaN") +) # Solution for turbulence model +residualTurbulence = SolverVariable( + name="solution.residualTurbulence", value=float("NaN") +) # Residual for turbulence model +kOmega = SolverVariable( + name="solution.kOmega", value=float("NaN") +) # Effectively solutionTurbulence when using SST model +nuHat = SolverVariable( + name="solution.nuHat", value=float("NaN") +) # Effectively solutionTurbulence when using SA model +solutionTransition = SolverVariable( + name="solution.solutionTransition", value=float("NaN") +) # Solution for transition model +residualTransition = SolverVariable( + name="solution.residualTransition", value=float("NaN") +) # Residual for transition model +solutionHeatSolver = SolverVariable( + name="solution.solutionHeatSolver", value=float("NaN") +) # Solution for heat equation +residualHeatSolver = SolverVariable( + name="solution.residualHeatSolver", value=float("NaN") +) # Residual for heat equation +coordinate = SolverVariable(name="solution.coordinate", value=float("NaN")) # Grid coordinates + +bet_thrust = SolverVariable( + name="solution.bet_thrust", value=float("NaN") +) # Thrust force for BET disk +bet_torque = SolverVariable(name="solution.bet_torque", value=float("NaN")) # Torque for BET disk +bet_omega = SolverVariable( + name="solution.bet_omega", value=float("NaN") +) # Rotation speed for BET disk +CD = SolverVariable(name="solution.CD", value=float("NaN")) # Drag coefficient on patch +CL = SolverVariable(name="solution.CL", value=float("NaN")) # Lift coefficient on patch +forceX = SolverVariable(name="solution.forceX", value=float("NaN")) # Total force in X direction +forceY = SolverVariable(name="solution.forceY", value=float("NaN")) # Total force in Y direction +forceZ = SolverVariable(name="solution.forceZ", value=float("NaN")) # Total force in Z direction +momentX = SolverVariable(name="solution.momentX", value=float("NaN")) # Total moment in X direction +momentY = SolverVariable(name="solution.momentY", value=float("NaN")) # Total moment in Y direction +momentZ = SolverVariable(name="solution.momentZ", value=float("NaN")) # Total moment in Z direction +nodeNormals = SolverVariable( + name="solution.nodeNormals", value=float("NaN") +) # Normal vector of patch +wallFunctionMetric = SolverVariable( + name="solution.wallFunctionMetric", value=float("NaN") +) # Wall model quality indicator +wallShearStress = SolverVariable( + name="solution.wallShearStress", value=float("NaN") +) # Wall viscous shear stress +yPlus = SolverVariable(name="solution.yPlus", value=float("NaN")) # Non-dimensional wall distance diff --git a/tests/simulation/test_expressions.py b/tests/simulation/test_expressions.py index f4de08c74..cda44c298 100644 --- a/tests/simulation/test_expressions.py +++ b/tests/simulation/test_expressions.py @@ -4,8 +4,7 @@ import pydantic as pd import pytest -import flow360 as fl -from flow360 import u +from flow360 import control, solution, u from flow360.component.simulation.framework.base_model import Flow360BaseModel from flow360.component.simulation.unit_system import ( AbsoluteTemperatureType, @@ -352,9 +351,9 @@ class TestModel(Flow360BaseModel): x = UserVariable(name="x", value=4) - model = TestModel(field=x * u.m + fl.kOmega * u.cm) + model = TestModel(field=x * u.m + solution.kOmega * u.cm) - assert str(model.field) == "x * u.m + (fl.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): @@ -582,3 +581,20 @@ class TestModel(Flow360BaseModel): assert "line" in validation_errors[0]["ctx"] assert "column" in validation_errors[0]["ctx"] assert validation_errors[0]["ctx"]["column"] == 11 + + +def test_solver_translation(): + class TestModel(Flow360BaseModel): + field: ValueOrExpression[float] = pd.Field() + + x = UserVariable(name="x", value=4) + + model = TestModel(field=(x // 3) ** 2) + + assert isinstance(model.field, Expression) + assert model.field.evaluate() == 1 + assert str(model.field) == "(x // 3) ** 2" + + solver_code = model.field.to_solver_code() + + assert solver_code == "pow(floor(x / 3), 2)" From ea3e9fa58a5f733b26d199dc2009fcd4d34fd122 Mon Sep 17 00:00:00 2001 From: Andrzej Krupka <156919532+andrzej-krupka@users.noreply.github.com> Date: Wed, 14 May 2025 17:45:22 +0200 Subject: [PATCH 11/34] Add dependency cycle checking and add non-dimensioned array handling to expression validation service (#1013) Co-authored-by: Andrzej Krupka --- flow360/component/simulation/services.py | 16 ++++++---- flow360/component/simulation/user_code.py | 36 +++++++++++++++++++---- tests/simulation/test_expressions.py | 15 ++++++++++ 3 files changed, 57 insertions(+), 10 deletions(-) diff --git a/flow360/component/simulation/services.py b/flow360/component/simulation/services.py index 273536b4a..321025d3b 100644 --- a/flow360/component/simulation/services.py +++ b/flow360/component/simulation/services.py @@ -7,6 +7,7 @@ from numbers import Number from typing import Any, Collection, Dict, Literal, Optional, Tuple, Union +import numpy as np import pydantic as pd from unyt import unyt_array, unyt_quantity from unyt.exceptions import UnitParseError @@ -787,8 +788,6 @@ def validate_expression(variables: list[dict], expressions: list[str]): values = [] units = [] - loc = "" - # Populate variable scope for i in range(len(variables)): variable = variables[i] @@ -796,7 +795,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() + _ = variable.value.evaluate(strict=False) except (ValueError, KeyError, NameError, UnitParseError) as e: errors.append({"loc": loc, "msg": str(e)}) @@ -807,8 +806,10 @@ def validate_expression(variables: list[dict], expressions: list[str]): unit = None try: expression_object = Expression(expression=expression) - result = expression_object.evaluate() - if isinstance(result, Number): + result = expression_object.evaluate(strict=False) + if np.isnan(result): + pass + elif isinstance(result, Number): value = result elif isinstance(result, unyt_array): if result.size == 1: @@ -816,6 +817,11 @@ def validate_expression(variables: list[dict], expressions: list[str]): else: value = tuple(result.value.tolist()) unit = str(result.units.expr) + elif isinstance(result, np.ndarray): + if result.size == 1: + value = float(result[0]) + else: + value = tuple(result.tolist()) except (ValueError, KeyError, NameError, UnitParseError) as e: errors.append({"loc": loc, "msg": str(e)}) values.append(value) diff --git a/flow360/component/simulation/user_code.py b/flow360/component/simulation/user_code.py index 6f7aeaa8a..bd00ecbdb 100644 --- a/flow360/component/simulation/user_code.py +++ b/flow360/component/simulation/user_code.py @@ -1,10 +1,8 @@ from __future__ import annotations import re -from numbers import Number from typing import Generic, Iterable, Optional, TypeVar -import pydantic as pd from pydantic import BeforeValidator from typing_extensions import Self from unyt import Unit, unyt_array @@ -214,6 +212,28 @@ def update_context(cls, value): _user_variables.add(value.name) return value + @pd.model_validator(mode="after") + @classmethod + def check_dependencies(cls, value): + visited = set() + stack = [(value.name, [value.name])] + while stack: + (current_name, current_path) = stack.pop() + current_value = _global_ctx.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]: + path_string = " -> ".join(current_path + [current_path[0]]) + details = InitErrorDetails( + type="value_error", + ctx={"error": f"Cyclic dependency between variables {path_string}"}, + ) + raise pd.ValidationError.from_exception_data("Variable value error", [details]) + stack.extend( + [(name, current_path + [name]) for name in used_names if name not in visited] + ) + return value + class SolverVariable(Variable): solver_name: Optional[str] = pd.Field(None) @@ -273,7 +293,7 @@ def _validate_expression(cls, value) -> Self: details = InitErrorDetails( type="value_error", ctx={"error": f"Invalid type {type(value)}"} ) - raise pd.ValidationError.from_exception_data("expression type error", [details]) + raise pd.ValidationError.from_exception_data("Expression type error", [details]) try: expr_to_model(expression, _global_ctx) except SyntaxError as s_err: @@ -286,7 +306,7 @@ def _validate_expression(cls, value) -> Self: def evaluate( self, context: EvaluationContext = None, strict: bool = True - ) -> Union[float, list[float], unyt_array]: + ) -> Union[float, np.ndarray, unyt_array]: if context is None: context = _global_ctx expr = expr_to_model(self.expression, context) @@ -296,11 +316,17 @@ def evaluate( def user_variables(self): expr = expr_to_model(self.expression, _global_ctx) 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] + def user_variable_names(self): + expr = expr_to_model(self.expression, _global_ctx) + names = expr.used_names() + names = [name for name in names if name in _user_variables] + + return names + def to_solver_code(self): expr = expr_to_model(self.expression, _global_ctx) source = expr_to_code(expr, TargetSyntax.CPP, _solver_variables) diff --git a/tests/simulation/test_expressions.py b/tests/simulation/test_expressions.py index cda44c298..fdd4b9c98 100644 --- a/tests/simulation/test_expressions.py +++ b/tests/simulation/test_expressions.py @@ -598,3 +598,18 @@ class TestModel(Flow360BaseModel): solver_code = model.field.to_solver_code() assert solver_code == "pow(floor(x / 3), 2)" + + +def test_cyclic_dependencies(): + x = UserVariable(name="x", value=4) + y = UserVariable(name="y", value=x) + + # If we try to create a cyclic dependency we throw a validation error + # The error contains info about the cyclic dependency, so here its x -> y -> x + with pytest.raises(pd.ValidationError): + x.value = y + + x = UserVariable(name="x", value=4) + + with pytest.raises(pd.ValidationError): + x.value = x From fb136c17e3d2a093dd56d89361c2db41838f57b9 Mon Sep 17 00:00:00 2001 From: Andrzej Krupka <156919532+andrzej-krupka@users.noreply.github.com> Date: Tue, 20 May 2025 18:28:14 +0200 Subject: [PATCH 12/34] Validation service fixes, better error messages (#1030) * Validation service fixes * Fix aliasing issues, fix numpy interop with dimensioned variables * Apply PR feedback * Ready to merge --------- Co-authored-by: Andrzej Krupka Co-authored-by: benflexcompute --- .../simulation/framework/base_model.py | 2 +- flow360/component/simulation/services.py | 28 ++++++---- flow360/component/simulation/user_code.py | 54 +++++++++++++++++-- tests/simulation/test_expressions.py | 39 +++++++++++--- 4 files changed, 99 insertions(+), 24 deletions(-) diff --git a/flow360/component/simulation/framework/base_model.py b/flow360/component/simulation/framework/base_model.py index 06988b85d..3d74df39d 100644 --- a/flow360/component/simulation/framework/base_model.py +++ b/flow360/component/simulation/framework/base_model.py @@ -168,7 +168,7 @@ def __pydantic_init_subclass__(cls, **kwargs) -> None: # pylint: disable=fixme # TODO: Remove alias_generator since it is only for translator alias_generator=pd.AliasGenerator( - serialization_alias=snake_to_camel, + alias=snake_to_camel, ), ) diff --git a/flow360/component/simulation/services.py b/flow360/component/simulation/services.py index 321025d3b..4ca5d7550 100644 --- a/flow360/component/simulation/services.py +++ b/flow360/component/simulation/services.py @@ -2,15 +2,13 @@ # pylint: disable=duplicate-code import json -import re from enum import Enum from numbers import Number from typing import Any, Collection, Dict, Literal, Optional, Tuple, Union import numpy as np import pydantic as pd -from unyt import unyt_array, unyt_quantity -from unyt.exceptions import UnitParseError +from unyt import unyt_array # Required for correct global scope initialization from flow360.component.simulation.exposed_units import supported_units_by_front_end @@ -410,7 +408,9 @@ def clean_unrelated_setting_from_params_dict(params: dict, root_item_type: str) return params -def handle_generic_exception(err: Exception, validation_errors: Optional[list]) -> list: +def handle_generic_exception( + err: Exception, validation_errors: Optional[list], loc_prefix: Optional[list[str]] = None +) -> list: """ Handles generic exceptions during validation, adding to validation errors. @@ -420,6 +420,8 @@ def handle_generic_exception(err: Exception, validation_errors: Optional[list]) The exception caught during validation. validation_errors : list or None Current list of validation errors, may be None. + loc_prefix : list or None + Prefix of the location of the generic error to help locate the issue Returns ------- @@ -432,7 +434,7 @@ def handle_generic_exception(err: Exception, validation_errors: Optional[list]) validation_errors.append( { "type": err.__class__.__name__.lower().replace("error", "_error"), - "loc": ["unknown"], + "loc": ["unknown"] if loc_prefix is None else loc_prefix, "msg": str(err), "ctx": {}, } @@ -791,17 +793,19 @@ def validate_expression(variables: list[dict], expressions: list[str]): # Populate variable scope for i in range(len(variables)): variable = variables[i] - loc = f"variables/{i}" + loc_hint = ["variables", str(i)] try: variable = UserVariable(name=variable["name"], value=variable["value"]) if variable and isinstance(variable.value, Expression): _ = variable.value.evaluate(strict=False) - except (ValueError, KeyError, NameError, UnitParseError) as e: - errors.append({"loc": loc, "msg": str(e)}) + except pd.ValidationError as err: + errors.extend(err.errors()) + 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] - loc = f"expressions/{i}" + loc_hint = ["expressions", str(i)] value = None unit = None try: @@ -822,8 +826,10 @@ def validate_expression(variables: list[dict], expressions: list[str]): value = float(result[0]) else: value = tuple(result.tolist()) - except (ValueError, KeyError, NameError, UnitParseError) as e: - errors.append({"loc": loc, "msg": str(e)}) + except pd.ValidationError as err: + errors.extend(err.errors()) + except Exception as err: # pylint: disable=broad-exception-caught + handle_generic_exception(err, errors, loc_hint) values.append(value) units.append(unit) diff --git a/flow360/component/simulation/user_code.py b/flow360/component/simulation/user_code.py index bd00ecbdb..508c6037d 100644 --- a/flow360/component/simulation/user_code.py +++ b/flow360/component/simulation/user_code.py @@ -58,7 +58,7 @@ def _convert_argument(value): elif isinstance(value, unyt_array): unit = str(value.units) tokens = _split_keep_delimiters(unit, unit_delimiters) - arg = f"{str(value.value)} * " + arg = f"{_convert_argument(value.value)[0]} * " for token in tokens: if token not in unit_delimiters and not _is_number_string(token): token = f"u.{token}" @@ -66,7 +66,10 @@ def _convert_argument(value): else: arg += token elif isinstance(value, np.ndarray): - arg = f"np.array([{','.join([_convert_argument(item)[0] for item in value])}])" + if value.ndim == 0: + arg = str(value) + else: + arg = f"np.array([{','.join([_convert_argument(item)[0] for item in value])}])" else: raise ValueError(f"Incompatible argument of type {type(value)}") return arg, parenthesize @@ -81,9 +84,44 @@ class SerializedValueOrExpression(Flow360BaseModel): evaluated_units: Optional[str] = pd.Field(None) +# This is a wrapper to allow using ndarrays with pydantic models +class NdArray(np.ndarray): + def __repr__(self): + return f"NdArray(shape={self.shape}, dtype={self.dtype})" + + @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): + if isinstance(value, np.ndarray): + return value + raise ValueError(f"Cannot convert {type(value)} to NdArray") + + +# This is a wrapper to allow using unyt arrays with pydantic models +class UnytArray(unyt_array): + def __repr__(self): + return f"UnytArray({str(self)})" + + @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): + if isinstance(value, unyt_array): + return value + raise ValueError(f"Cannot convert {type(value)} to UnytArray") + + +AnyNumericType = Union[float, UnytArray, NdArray] + + class Variable(Flow360BaseModel): name: str = pd.Field() - value: ValueOrExpression[Any] = pd.Field() + value: ValueOrExpression[AnyNumericType] = pd.Field() model_config = pd.ConfigDict(validate_assignment=True, extra="allow") @@ -182,6 +220,9 @@ def __str__(self): def __repr__(self): return f"Variable({self.name} = {self.value})" + def __hash__(self): + return hash(self.name) + def sqrt(self): return Expression(expression=f"np.sqrt({self.expression})") @@ -288,7 +329,12 @@ def _validate_expression(cls, value) -> Self: elif isinstance(value, Variable): expression = str(value) elif isinstance(value, np.ndarray) and not isinstance(value, unyt_array): - expression = f"np.array([{','.join([_convert_argument(item)[0] for item in value])}])" + if value.ndim == 0: + expression = str(value) + else: + expression = ( + f"np.array([{','.join([_convert_argument(item)[0] for item in value])}])" + ) else: details = InitErrorDetails( type="value_error", ctx={"error": f"Invalid type {type(value)}"} diff --git a/tests/simulation/test_expressions.py b/tests/simulation/test_expressions.py index fdd4b9c98..f1a602c3d 100644 --- a/tests/simulation/test_expressions.py +++ b/tests/simulation/test_expressions.py @@ -303,10 +303,10 @@ class TestModel(Flow360BaseModel): direction: ValueOrExpression[LengthType.Direction] = pd.Field() moment: ValueOrExpression[LengthType.Moment] = pd.Field() - x = UserVariable(name="x", value=[1, 0, 0]) - y = UserVariable(name="y", value=[0, 0, 0]) - z = UserVariable(name="z", value=[1, 0, 0, 0]) - w = UserVariable(name="w", value=[1, 1, 1]) + x = UserVariable(name="x", value=np.array([1, 0, 0])) + y = UserVariable(name="y", value=np.array([0, 0, 0])) + z = UserVariable(name="z", value=np.array([1, 0, 0, 0])) + w = UserVariable(name="w", value=np.array([1, 1, 1])) model = TestModel( vector=y * u.m, axis=x * u.m, array=z * u.m, direction=x * u.m, moment=w * u.m @@ -409,8 +409,6 @@ class TestModel(Flow360BaseModel): def test_numpy_interop_scalars(): - # Disclaimer - doesn't fully work yet with dimensioned types... - class ScalarModel(Flow360BaseModel): scalar: ValueOrExpression[float] = pd.Field() @@ -481,8 +479,6 @@ class ScalarModel(Flow360BaseModel): def test_numpy_interop_vectors(): - # Disclaimer - doesn't fully work yet with dimensioned types... - Vec3 = tuple[float, float, float] class VectorModel(Flow360BaseModel): @@ -613,3 +609,30 @@ def test_cyclic_dependencies(): with pytest.raises(pd.ValidationError): x.value = x + + +def test_auto_alias(): + class TestModel(Flow360BaseModel): + field: ValueOrExpression[VelocityType] = pd.Field() + + x = UserVariable(name="x", value=4) + + unaliased = { + "type_name": "expression", + "expression": "(x * u.m) / u.s + (((4 * (x ** 2)) * u.m) / u.s)", + "evaluated_value": 68.0, + "evaluated_units": "m/s", + } + + aliased = { + "typeName": "expression", + "expression": "(x * u.m) / u.s + (((4 * (x ** 2)) * u.m) / u.s)", + "evaluatedValue": 68.0, + "evaluatedUnits": "m/s", + } + + 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)" From ec50929d974d908f4e1d83f0a32766969ebddf1d Mon Sep 17 00:00:00 2001 From: Andrzej Krupka <156919532+andrzej-krupka@users.noreply.github.com> Date: Tue, 20 May 2025 20:24:22 +0200 Subject: [PATCH 13/34] Added unit handling to solver code converter (#1049) * Validation service fixes * Fix aliasing issues, fix numpy interop with dimensioned variables * Apply PR feedback * Added unit stripping when converting to solver code * Add a general name preprocessing function instead of unit stripping * Finalize unit -> solver conversion function * Add user variable inlining for solver code generation --------- Co-authored-by: Andrzej Krupka Co-authored-by: benflexcompute --- .../simulation/blueprint/codegen/generator.py | 98 ++++++++++++------- flow360/component/simulation/user_code.py | 54 +++++++--- .../simulation/variables/control_variables.py | 4 +- tests/simulation/test_expressions.py | 77 ++++++++++++--- 4 files changed, 169 insertions(+), 64 deletions(-) diff --git a/flow360/component/simulation/blueprint/codegen/generator.py b/flow360/component/simulation/blueprint/codegen/generator.py index 2b06b6984..3f82a4c3d 100644 --- a/flow360/component/simulation/blueprint/codegen/generator.py +++ b/flow360/component/simulation/blueprint/codegen/generator.py @@ -1,5 +1,5 @@ import functools -from typing import Any +from typing import Any, Callable from ..core.expressions import ( BinOp, @@ -46,8 +46,10 @@ def _empty(syntax): @check_syntax_type -def _name(expr, remap): - return expr.id if expr.id not in remap else remap[expr.id] +def _name(expr, name_translator): + if name_translator: + return name_translator(expr.id) + return expr.id @check_syntax_type @@ -58,43 +60,54 @@ def _constant(expr): @check_syntax_type -def _unary_op(expr, syntax, remap): +def _unary_op(expr, syntax, name_translator): op_info = UNARY_OPERATORS[expr.op] - return f"{op_info.symbol}{expr_to_code(expr.operand, syntax, remap)}" + + arg = expr_to_code(expr.operand, syntax, name_translator) + + return f"{op_info.symbol}{arg}" @check_syntax_type -def _binary_op(expr, syntax, remap): +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) + if syntax == TargetSyntax.CPP: # Special case handling for operators not directly supported in CPP syntax, requires #include if expr.op == "FloorDiv": - return f"floor({expr_to_code(expr.left, syntax, remap)} / {expr_to_code(expr.right, syntax, remap)})" + return f"floor({left} / {right})" if expr.op == "Pow": - return f"pow({expr_to_code(expr.left, syntax, remap)}, {expr_to_code(expr.right, syntax, remap)})" + return f"pow({left}, {right})" if expr.op == "Is": - return f"&{expr_to_code(expr.left, syntax, remap)} == &{expr_to_code(expr.right, syntax, remap)}" + return f"&{left} == &{right}" op_info = BINARY_OPERATORS[expr.op] - return f"({expr_to_code(expr.left, syntax, remap)} {op_info.symbol} {expr_to_code(expr.right, syntax, remap)})" + return f"({left} {op_info.symbol} {right})" @check_syntax_type -def _range_call(expr, syntax, remap): +def _range_call(expr, syntax, name_translator): if syntax == TargetSyntax.PYTHON: - return f"range({expr_to_code(expr.arg, syntax, remap)})" + arg = expr_to_code(expr.arg, syntax, name_translator) + return f"range({arg})" raise ValueError("Range calls are only supported for Python target syntax") @check_syntax_type -def _call_model(expr, syntax, remap): +def _call_model(expr, syntax, name_translator): if syntax == TargetSyntax.PYTHON: - args_str = ", ".join(expr_to_code(arg, syntax, remap) for arg in expr.args) + args = [] + for arg in expr.args: + val_str = expr_to_code(arg, syntax, name_translator) + args.append(val_str) + args_str = ", ".join(args) kwargs_parts = [] for k, v in expr.kwargs.items(): if v is None: continue - val_str = expr_to_code(v, syntax, remap) + val_str = expr_to_code(v, syntax, name_translator) if not val_str or val_str.isspace(): continue kwargs_parts.append(f"{k}={val_str}") @@ -103,51 +116,64 @@ def _call_model(expr, syntax, remap): all_args = ", ".join(x for x in [args_str, kwargs_str] if x) return f"{expr.func_qualname}({all_args})" elif syntax == TargetSyntax.CPP: - args_str = ", ".join(expr_to_code(arg, syntax, remap) for arg in expr.args) + args = [] + for arg in expr.args: + val_str = expr_to_code(arg, syntax, name_translator) + args.append(val_str) + args_str = ", ".join(args) if expr.kwargs: raise ValueError("Named arguments are not supported in C++ syntax") return f"{expr.func_qualname}({args_str})" @check_syntax_type -def _tuple(expr, syntax, remap): +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: - return f"({expr_to_code(expr.elements[0], syntax, remap)},)" - return f"({', '.join(expr_to_code(e, syntax, remap) for e in expr.elements)})" + return f"({elements[0]},)" + return f"({', '.join(elements)})" elif syntax == TargetSyntax.CPP: if len(expr.elements) == 0: return "{}" elif len(expr.elements) == 1: - return f"{{{expr_to_code(expr.elements[0], syntax, remap)}}}" - return f"{{{', '.join(expr_to_code(e, syntax, remap) for e in expr.elements)}}}" + return f"{{{elements[0]}}}" + return f"{{{', '.join(elements)}}}" @check_syntax_type -def _list(expr, syntax, remap): +def _list(expr, syntax, name_translator): + elements = [expr_to_code(e, syntax, name_translator) for e in expr.elements] + if syntax == TargetSyntax.PYTHON: - if not expr.elements: + if len(expr.elements) == 0: return "[]" - elements = [expr_to_code(e, syntax, remap) for e in expr.elements] elements_str = ", ".join(elements) return f"[{elements_str}]" elif syntax == TargetSyntax.CPP: if len(expr.elements) == 0: return "{}" - return f"{{{', '.join(expr_to_code(e, syntax, remap) for e in expr.elements)}}}" + return f"{{{', '.join(elements)}}}" -def _list_comp(expr, syntax, remap): +def _list_comp(expr, syntax, name_translator): if syntax == TargetSyntax.PYTHON: - return f"[{expr_to_code(expr.element, syntax, remap)} for {expr.target, syntax, remap} in {expr_to_code(expr.iter, syntax, remap)}]" + element = expr_to_code(expr.element, syntax, name_translator) + target = expr_to_code(expr.target, syntax, name_translator) + iterator = expr_to_code(expr.iter, syntax, name_translator) + + return f"[{element} for {target} in {iterator}]" raise ValueError("List comprehensions are only supported for Python target syntax") def expr_to_code( - expr: Any, syntax: TargetSyntax = TargetSyntax.PYTHON, remap: dict[str, str] = None + expr: Any, + syntax: TargetSyntax = TargetSyntax.PYTHON, + name_translator: Callable[[str], str] = None, ) -> str: """Convert an expression model back to source code.""" if expr is None: @@ -155,31 +181,31 @@ def expr_to_code( # Names and constants are language-agnostic (apart from symbol remaps) if isinstance(expr, Name): - return _name(expr, remap) + return _name(expr, name_translator) elif isinstance(expr, Constant): return _constant(expr) elif isinstance(expr, UnaryOp): - return _unary_op(expr, syntax, remap) + return _unary_op(expr, syntax, name_translator) elif isinstance(expr, BinOp): - return _binary_op(expr, syntax, remap) + return _binary_op(expr, syntax, name_translator) elif isinstance(expr, RangeCall): - return _range_call(expr, syntax, remap) + return _range_call(expr, syntax, name_translator) elif isinstance(expr, CallModel): - return _call_model(expr, syntax, remap) + return _call_model(expr, syntax, name_translator) elif isinstance(expr, Tuple): - return _tuple(expr, syntax, remap) + return _tuple(expr, syntax, name_translator) elif isinstance(expr, List): - return _list(expr, syntax, remap) + return _list(expr, syntax, name_translator) elif isinstance(expr, ListComp): - return _list_comp(expr, syntax, remap) + return _list_comp(expr, syntax, name_translator) else: raise ValueError(f"Unsupported expression type: {type(expr)}") diff --git a/flow360/component/simulation/user_code.py b/flow360/component/simulation/user_code.py index 508c6037d..2d0a4242a 100644 --- a/flow360/component/simulation/user_code.py +++ b/flow360/component/simulation/user_code.py @@ -1,7 +1,7 @@ from __future__ import annotations import re -from typing import Generic, Iterable, Optional, TypeVar +from typing import ClassVar, Generic, Iterable, Optional, TypeVar from pydantic import BeforeValidator from typing_extensions import Self @@ -35,15 +35,10 @@ def _split_keep_delimiters(input: str, delimiters: list) -> list: return [part for part in result if part != ""] -def _convert_argument(value): - parenthesize = False +def _convert_numeric(value): + arg = None unit_delimiters = ["+", "-", "*", "/", "(", ")"] - if isinstance(value, Expression): - arg = value.expression - parenthesize = True - elif isinstance(value, Variable): - arg = value.name - elif isinstance(value, Number): + if isinstance(value, Number): arg = str(value) elif isinstance(value, Unit): unit = str(value) @@ -70,7 +65,19 @@ def _convert_argument(value): arg = str(value) else: arg = f"np.array([{','.join([_convert_argument(item)[0] for item in value])}])" - else: + return arg + + +def _convert_argument(value): + parenthesize = False + arg = _convert_numeric(value) + if isinstance(value, Expression): + arg = value.expression + parenthesize = True + elif isinstance(value, Variable): + arg = value.name + + if not arg: raise ValueError(f"Incompatible argument of type {type(value)}") return arg, parenthesize @@ -373,10 +380,30 @@ def user_variable_names(self): return names - def to_solver_code(self): + def to_solver_code(self, params): + def translate_symbol(name): + if name in _solver_variables: + return _solver_variables[name] + + if name in _user_variables: + value = _global_ctx.get(name) + if isinstance(value, Expression): + return f"{value.to_solver_code(params)}" + else: + return _convert_numeric(value) + + match = re.fullmatch("u\\.(.+)", name) + + if match: + unit_name = match.group(1) + unit = Unit(unit_name) + conversion_factor = params.convert_unit(1.0 * unit, "flow360").v + return str(conversion_factor) + + return name + expr = expr_to_model(self.expression, _global_ctx) - source = expr_to_code(expr, TargetSyntax.CPP, _solver_variables) - # TODO: What do we do with dimensioned expressions? We need to replace all units by their conversion factors. + source = expr_to_code(expr, TargetSyntax.CPP, translate_symbol) return source def __hash__(self): @@ -516,6 +543,7 @@ def _internal_validator(value: Expression): def _deserialize(value) -> Self: is_serialized = False + try: value = SerializedValueOrExpression.model_validate(value) is_serialized = True diff --git a/flow360/component/simulation/variables/control_variables.py b/flow360/component/simulation/variables/control_variables.py index e9e30c7e3..847db09b8 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 from flow360.component.simulation.user_code import SolverVariable +# TODO: This is an example to illustrate translator features, switch for correct values later... MachRef = SolverVariable( - name="control.MachRef", value=float("NaN") + name="control.MachRef", value=float("NaN") * u.m / u.s, solver_name="machRef" ) # Reference mach specified by the user Tref = SolverVariable(name="control.Tref", value=float("NaN")) # Temperature specified by the user t = SolverVariable(name="control.t", value=float("NaN")) # Physical time diff --git a/tests/simulation/test_expressions.py b/tests/simulation/test_expressions.py index f1a602c3d..fa2d54113 100644 --- a/tests/simulation/test_expressions.py +++ b/tests/simulation/test_expressions.py @@ -4,8 +4,21 @@ import pydantic as pd import pytest -from flow360 import control, solution, u +from flow360 import ( + HeatEquationInitialCondition, + LiquidOperatingCondition, + SimulationParams, + Solid, + Unsteady, + control, + solution, + u, +) from flow360.component.simulation.framework.base_model import Flow360BaseModel +from flow360.component.simulation.framework.param_utils import AssetCache +from flow360.component.simulation.models.material import Water, aluminum +from flow360.component.simulation.outputs.outputs import SurfaceOutput +from flow360.component.simulation.primitives import GenericVolume, Surface from flow360.component.simulation.unit_system import ( AbsoluteTemperatureType, AngleType, @@ -24,6 +37,7 @@ MomentType, PowerType, PressureType, + SI_unit_system, SpecificEnergyType, SpecificHeatCapacityType, ThermalConductivityType, @@ -580,20 +594,55 @@ class TestModel(Flow360BaseModel): def test_solver_translation(): - class TestModel(Flow360BaseModel): - field: ValueOrExpression[float] = pd.Field() - - x = UserVariable(name="x", value=4) - - model = TestModel(field=(x // 3) ** 2) - - assert isinstance(model.field, Expression) - assert model.field.evaluate() == 1 - assert str(model.field) == "(x // 3) ** 2" - - solver_code = model.field.to_solver_code() + timestepping_unsteady = Unsteady(steps=12, step_size=0.1 * u.s) + solid_model = Solid( + volumes=[GenericVolume(name="CHTSolid")], + material=aluminum, + volumetric_heat_source="0", + initial_condition=HeatEquationInitialCondition(temperature="10"), + ) + surface_output_with_residual_heat_solver = SurfaceOutput( + name="surface", + surfaces=[Surface(name="noSlipWall")], + write_single_file=True, + output_fields=["residualHeatSolver"], + ) + water = Water( + name="h2o", density=1000 * u.kg / u.m**3, dynamic_viscosity=0.001 * u.kg / u.m / u.s + ) + liquid_operating_condition = LiquidOperatingCondition( + velocity_magnitude=50 * u.m / u.s, + reference_velocity_magnitude=100 * u.m / u.s, + material=water, + ) - assert solver_code == "pow(floor(x / 3), 2)" + # Valid simulation params + with SI_unit_system: + params = SimulationParams( + models=[solid_model], + operating_condition=liquid_operating_condition, + time_stepping=timestepping_unsteady, + outputs=[surface_output_with_residual_heat_solver], + private_attribute_asset_cache=AssetCache(project_length_unit=2 * u.m), + ) + + x = UserVariable(name="x", value=4) + y = UserVariable(name="y", value=x + 1) + + # Showcased features: + expression = Expression.model_validate(x * u.m**2) + + # 1. Units are converted to flow360 unit system using the provided params (1m**2 -> 0.25 because of length unit) + # 2. User variables are inlined (for numeric value types) + assert expression.to_solver_code(params) == "(4.0 * pow(0.5, 2))" + + # 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))" + + # 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)" def test_cyclic_dependencies(): From 63be368b0bc32a1f4997ac43a47ace7d0819a0af Mon Sep 17 00:00:00 2001 From: Andrzej Krupka <156919532+andrzej-krupka@users.noreply.github.com> Date: Tue, 20 May 2025 21:25:10 +0200 Subject: [PATCH 14/34] Expressions fixes, demonstrating E2E capability for user-variable expressions (#1082) * Validation service fixes * Fix aliasing issues, fix numpy interop with dimensioned variables * Apply PR feedback * Added unit stripping when converting to solver code * Add a general name preprocessing function instead of unit stripping * Finalize unit -> solver conversion function * Add user variable inlining for solver code generation * Demonstrating E2E case with expressions * Fix variable initialization when loading from file * Removed non-release code * Fixed unit test --------- Co-authored-by: Andrzej Krupka Co-authored-by: benflexcompute --- flow360/__init__.py | 2 + flow360/component/simulation/entity_info.py | 2 +- .../component/simulation/simulation_params.py | 24 +- .../translator/solver_translator.py | 6 +- .../component/simulation/translator/utils.py | 33 + tests/simulation/data/variables.json | 1392 +++++++++++++++++ tests/simulation/test_expressions.py | 30 +- 7 files changed, 1481 insertions(+), 8 deletions(-) create mode 100644 tests/simulation/data/variables.json diff --git a/flow360/__init__.py b/flow360/__init__.py index 1fcb2cece..e6d17a9bf 100644 --- a/flow360/__init__.py +++ b/flow360/__init__.py @@ -145,6 +145,7 @@ SI_unit_system, imperial_unit_system, ) +from flow360.component.simulation.user_code import UserVariable from flow360.component.simulation.user_defined_dynamics.user_defined_dynamics import ( UserDefinedDynamic, ) @@ -275,4 +276,5 @@ "StreamlineOutput", "Transformation", "WallRotation", + "UserVariable", ] diff --git a/flow360/component/simulation/entity_info.py b/flow360/component/simulation/entity_info.py index fd891b432..b426984a6 100644 --- a/flow360/component/simulation/entity_info.py +++ b/flow360/component/simulation/entity_info.py @@ -385,7 +385,7 @@ def get_registry(self, internal_registry, **_) -> EntityRegistry: body_group_tag = self.body_group_tag internal_registry = self._group_entity_by_tag( - "body", self.body_group_tag, registry=internal_registry + "body", body_group_tag, registry=internal_registry ) return internal_registry diff --git a/flow360/component/simulation/simulation_params.py b/flow360/component/simulation/simulation_params.py index d14c1aea1..179b9a2ad 100644 --- a/flow360/component/simulation/simulation_params.py +++ b/flow360/component/simulation/simulation_params.py @@ -4,7 +4,7 @@ from __future__ import annotations -from typing import Annotated, List, Optional, Union +from typing import Annotated, Iterable, List, Optional, Union import pydantic as pd @@ -60,6 +60,7 @@ unit_system_manager, unyt_quantity, ) +from flow360.component.simulation.user_code import UserVariable from flow360.component.simulation.user_defined_dynamics.user_defined_dynamics import ( UserDefinedDynamic, ) @@ -194,6 +195,12 @@ 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) @@ -203,6 +210,13 @@ 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 @@ -366,6 +380,14 @@ 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 + @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"]: + UserVariable(name=variable_dict["name"], value=variable_dict["value"]) + return value + # pylint: disable=no-self-argument @pd.field_validator("models", mode="after") @classmethod diff --git a/flow360/component/simulation/translator/solver_translator.py b/flow360/component/simulation/translator/solver_translator.py index 6e37e25c3..137c9060f 100644 --- a/flow360/component/simulation/translator/solver_translator.py +++ b/flow360/component/simulation/translator/solver_translator.py @@ -74,6 +74,7 @@ convert_tuples_to_lists, get_global_setting_from_first_instance, has_instance_in_list, + inline_expressions_in_dict, preprocess_input, remove_units_in_dict, replace_dict_key, @@ -977,7 +978,10 @@ def get_solver_json( translated = {} ##:: Step 1: Get geometry: if input_params.reference_geometry: - geometry = remove_units_in_dict(dump_dict(input_params.reference_geometry)) + geometry = inline_expressions_in_dict( + dump_dict(input_params.reference_geometry), input_params + ) + geometry = remove_units_in_dict(geometry) translated["geometry"] = {} if input_params.reference_geometry.area is not None: translated["geometry"]["refArea"] = geometry["area"] diff --git a/flow360/component/simulation/translator/utils.py b/flow360/component/simulation/translator/utils.py index 89c557612..0f16496b8 100644 --- a/flow360/component/simulation/translator/utils.py +++ b/flow360/component/simulation/translator/utils.py @@ -7,6 +7,8 @@ from collections import OrderedDict from typing import Union +import numpy as np + from flow360.component.simulation.framework.entity_base import EntityBase, EntityList from flow360.component.simulation.framework.unique_list import UniqueItemList from flow360.component.simulation.primitives import ( @@ -15,6 +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.utils import is_exact_instance @@ -141,6 +144,36 @@ def remove_units_in_dict(input_dict): return input_dict +def inline_expressions_in_dict(input_dict, input_params): + if isinstance(input_dict, dict): + new_dict = {} + if "expression" in input_dict.keys(): + expression = Expression(expression=input_dict["expression"]) + evaluated = expression.evaluate(strict=False) + converted = input_params.convert_unit(evaluated, "flow360").v + new_dict = converted + return new_dict + for key, value in input_dict.items(): + # For number-type fields the schema should match dimensioned unit fields + # 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) + converted = input_params.convert_unit(evaluated, "flow360").v + if isinstance(converted, np.ndarray): + if converted.ndim == 0: + converted = float(converted) + else: + converted = converted.tolist() + new_dict[key] = converted + else: + new_dict[key] = inline_expressions_in_dict(value, input_params) + return new_dict + if isinstance(input_dict, list): + return [inline_expressions_in_dict(item, input_params) for item in input_dict] + return input_dict + + def has_instance_in_list(obj_list: list, class_type): """Check if a list contains an instance of a given type.""" if obj_list is not None: diff --git a/tests/simulation/data/variables.json b/tests/simulation/data/variables.json new file mode 100644 index 000000000..895c37d39 --- /dev/null +++ b/tests/simulation/data/variables.json @@ -0,0 +1,1392 @@ +{ + "version": "25.5.0b4", + "unit_system": { + "name": "SI" + }, + "meshing": { + "refinement_factor": 1, + "gap_treatment_strength": 0, + "defaults": { + "surface_edge_growth_rate": 1.2, + "boundary_layer_growth_rate": 1.2, + "boundary_layer_first_layer_thickness": { + "value": 0.001, + "units": "m" + }, + "planar_face_tolerance": 0.000001, + "surface_max_edge_length": { + "value": 1, + "units": "m" + }, + "curvature_resolution_angle": { + "value": 12, + "units": "degree" + } + }, + "refinements": [], + "volume_zones": [ + { + "type": "AutomatedFarfield", + "name": "Automated Farfield", + "method": "auto" + } + ] + }, + "reference_geometry": { + "moment_center": { + "value": [ + 0, + 0, + 0 + ], + "units": "m" + }, + "moment_length": { + "value": [ + 1, + 1, + 1 + ], + "units": "m" + }, + "area": { + "type_name": "expression", + "expression": "x * u.m**2", + "evaluated_value": 1, + "evaluated_units": "m**2" + } + }, + "operating_condition": { + "type_name": "AerospaceCondition", + "private_attribute_constructor": "default", + "private_attribute_input_cache": { + "alpha": { + "value": 5, + "units": "degree" + }, + "beta": { + "value": 0, + "units": "degree" + }, + "thermal_state": { + "type_name": "ThermalState", + "private_attribute_constructor": "default", + "private_attribute_input_cache": {}, + "temperature": { + "value": 288.15, + "units": "K" + }, + "density": { + "value": 1.225, + "units": "kg/m**3" + }, + "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" + } + } + } + } + }, + "alpha": { + "value": 5, + "units": "degree" + }, + "beta": { + "value": 0, + "units": "degree" + }, + "velocity_magnitude": { + "value": 100, + "units": "m/s" + }, + "thermal_state": { + "type_name": "ThermalState", + "private_attribute_constructor": "default", + "private_attribute_input_cache": {}, + "temperature": { + "value": 288.15, + "units": "K" + }, + "density": { + "value": 1.225, + "units": "kg/m**3" + }, + "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" + } + } + } + } + }, + "models": [ + { + "type": "Wall", + "entities": { + "stored_entities": [ + { + "private_attribute_registry_bucket_name": "SurfaceEntityType", + "private_attribute_entity_type_name": "Surface", + "private_attribute_id": "leftWing", + "name": "leftWing", + "private_attribute_tag_key": "groupName", + "private_attribute_sub_components": [ + "body00001_face00001", + "body00001_face00002", + "body00001_face00003", + "body00001_face00004" + ], + "private_attribute_potential_issues": [] + }, + { + "private_attribute_registry_bucket_name": "SurfaceEntityType", + "private_attribute_entity_type_name": "Surface", + "private_attribute_id": "fuselage", + "name": "fuselage", + "private_attribute_tag_key": "groupName", + "private_attribute_sub_components": [ + "body00001_face00005", + "body00001_face00006", + "body00001_face00007", + "body00001_face00008", + "body00001_face00009", + "body00001_face00010" + ], + "private_attribute_potential_issues": [] + }, + { + "private_attribute_registry_bucket_name": "SurfaceEntityType", + "private_attribute_entity_type_name": "Surface", + "private_attribute_id": "rightWing", + "name": "rightWing", + "private_attribute_tag_key": "groupName", + "private_attribute_sub_components": [ + "body00001_face00011", + "body00001_face00012", + "body00001_face00013", + "body00001_face00014" + ], + "private_attribute_potential_issues": [] + } + ] + }, + "name": "Wall", + "use_wall_function": false, + "heat_spec": { + "value": { + "value": 0, + "units": "W/m**2" + }, + "type_name": "HeatFlux" + }, + "roughness_height": { + "value": 0, + "units": "m" + } + }, + { + "type": "Freestream", + "entities": { + "stored_entities": [ + { + "private_attribute_registry_bucket_name": "SurfaceEntityType", + "private_attribute_entity_type_name": "GhostSphere", + "private_attribute_id": "farfield", + "name": "farfield", + "center": [ + 5.0007498695, + 0, + 0 + ], + "max_radius": 504.16453591327473 + } + ] + }, + "name": "Freestream" + }, + { + "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", + "rho": "rho", + "u": "u", + "v": "v", + "w": "w", + "p": "p" + }, + "type": "Fluid", + "navier_stokes_solver": { + "absolute_tolerance": 1e-10, + "relative_tolerance": 0, + "order_of_accuracy": 2, + "equation_evaluation_frequency": 1, + "linear_solver": { + "max_iterations": 30 + }, + "CFL_multiplier": 1, + "kappa_MUSCL": -1, + "numerical_dissipation_factor": 1, + "limit_velocity": false, + "limit_pressure_density": false, + "type_name": "Compressible", + "low_mach_preconditioner": false, + "update_jacobian_frequency": 4, + "max_force_jac_update_physical_steps": 0 + }, + "turbulence_model_solver": { + "absolute_tolerance": 1e-8, + "relative_tolerance": 0, + "order_of_accuracy": 2, + "equation_evaluation_frequency": 4, + "linear_solver": { + "max_iterations": 20 + }, + "CFL_multiplier": 2, + "type_name": "SpalartAllmaras", + "reconstruction_gradient_limiter": 0.5, + "quadratic_constitutive_relation": false, + "modeling_constants": { + "type_name": "SpalartAllmarasConsts", + "C_DES": 0.72, + "C_d": 8, + "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 + }, + "update_jacobian_frequency": 4, + "max_force_jac_update_physical_steps": 0, + "rotation_correction": false + }, + "transition_model_solver": { + "type_name": "None" + } + } + ], + "time_stepping": { + "type_name": "Steady", + "max_steps": 1000, + "CFL": { + "type": "adaptive", + "min": 0.1, + "max": 10000, + "max_relative_change": 1, + "convergence_limiting_factor": 0.25 + } + }, + "user_defined_fields": [], + "outputs": [ + { + "output_fields": { + "items": [ + "Cp", + "Cf", + "yPlus", + "CfVec" + ] + }, + "frequency": -1, + "frequency_offset": 0, + "output_format": "paraview", + "name": "Surface output", + "entities": { + "stored_entities": [ + { + "private_attribute_registry_bucket_name": "SurfaceEntityType", + "private_attribute_entity_type_name": "Surface", + "private_attribute_id": "leftWing", + "name": "leftWing", + "private_attribute_tag_key": "groupName", + "private_attribute_sub_components": [ + "body00001_face00001", + "body00001_face00002", + "body00001_face00003", + "body00001_face00004" + ], + "private_attribute_potential_issues": [] + }, + { + "private_attribute_registry_bucket_name": "SurfaceEntityType", + "private_attribute_entity_type_name": "Surface", + "private_attribute_id": "fuselage", + "name": "fuselage", + "private_attribute_tag_key": "groupName", + "private_attribute_sub_components": [ + "body00001_face00005", + "body00001_face00006", + "body00001_face00007", + "body00001_face00008", + "body00001_face00009", + "body00001_face00010" + ], + "private_attribute_potential_issues": [] + }, + { + "private_attribute_registry_bucket_name": "SurfaceEntityType", + "private_attribute_entity_type_name": "Surface", + "private_attribute_id": "rightWing", + "name": "rightWing", + "private_attribute_tag_key": "groupName", + "private_attribute_sub_components": [ + "body00001_face00011", + "body00001_face00012", + "body00001_face00013", + "body00001_face00014" + ], + "private_attribute_potential_issues": [] + } + ] + }, + "write_single_file": false, + "output_type": "SurfaceOutput" + } + ], + "private_attribute_asset_cache": { + "project_length_unit": { + "value": 1, + "units": "m" + }, + "project_entity_info": { + "draft_entities": [], + "ghost_entities": [ + { + "private_attribute_registry_bucket_name": "SurfaceEntityType", + "private_attribute_entity_type_name": "GhostSphere", + "private_attribute_id": "farfield", + "name": "farfield", + "center": [ + 5.0007498695, + 0, + 0 + ], + "max_radius": 504.16453591327473 + }, + { + "private_attribute_registry_bucket_name": "SurfaceEntityType", + "private_attribute_entity_type_name": "GhostCircularPlane", + "private_attribute_id": "symmetric-1", + "name": "symmetric-1", + "center": [ + 5.0007498695, + -5.0416453591327475, + 0 + ], + "max_radius": 10.083290718265495, + "normal_axis": [ + 0, + 1, + 0 + ] + }, + { + "private_attribute_registry_bucket_name": "SurfaceEntityType", + "private_attribute_entity_type_name": "GhostCircularPlane", + "private_attribute_id": "symmetric-2", + "name": "symmetric-2", + "center": [ + 5.0007498695, + 5.0416453591327475, + 0 + ], + "max_radius": 10.083290718265495, + "normal_axis": [ + 0, + 1, + 0 + ] + } + ], + "type_name": "GeometryEntityInfo", + "body_ids": [ + "body00001" + ], + "body_attribute_names": [ + "bodyId", + "groupByFile" + ], + "grouped_bodies": [ + [ + { + "private_attribute_registry_bucket_name": "GeometryBodyGroupEntityType", + "private_attribute_entity_type_name": "GeometryBodyGroup", + "private_attribute_id": "body00001", + "name": "body00001", + "private_attribute_tag_key": "bodyId", + "private_attribute_sub_components": [ + "body00001" + ], + "transformation": { + "type_name": "BodyGroupTransformation", + "origin": { + "value": [ + 0, + 0, + 0 + ], + "units": "m" + }, + "axis_of_rotation": [ + 1, + 0, + 0 + ], + "angle_of_rotation": { + "value": 0, + "units": "degree" + }, + "scale": [ + 1, + 1, + 1 + ], + "translation": { + "value": [ + 0, + 0, + 0 + ], + "units": "m" + } + } + } + ], + [ + { + "private_attribute_registry_bucket_name": "GeometryBodyGroupEntityType", + "private_attribute_entity_type_name": "GeometryBodyGroup", + "private_attribute_id": "geometry.csm", + "name": "geometry.csm", + "private_attribute_tag_key": "groupByFile", + "private_attribute_sub_components": [ + "body00001" + ], + "transformation": { + "type_name": "BodyGroupTransformation", + "origin": { + "value": [ + 0, + 0, + 0 + ], + "units": "m" + }, + "axis_of_rotation": [ + 1, + 0, + 0 + ], + "angle_of_rotation": { + "value": 0, + "units": "degree" + }, + "scale": [ + 1, + 1, + 1 + ], + "translation": { + "value": [ + 0, + 0, + 0 + ], + "units": "m" + } + } + } + ] + ], + "face_ids": [ + "body00001_face00001", + "body00001_face00002", + "body00001_face00003", + "body00001_face00004", + "body00001_face00005", + "body00001_face00006", + "body00001_face00007", + "body00001_face00008", + "body00001_face00009", + "body00001_face00010", + "body00001_face00011", + "body00001_face00012", + "body00001_face00013", + "body00001_face00014" + ], + "face_attribute_names": [ + "groupName", + "faceId" + ], + "grouped_faces": [ + [ + { + "private_attribute_registry_bucket_name": "SurfaceEntityType", + "private_attribute_entity_type_name": "Surface", + "private_attribute_id": "leftWing", + "name": "leftWing", + "private_attribute_tag_key": "groupName", + "private_attribute_sub_components": [ + "body00001_face00001", + "body00001_face00002", + "body00001_face00003", + "body00001_face00004" + ], + "private_attribute_potential_issues": [] + }, + { + "private_attribute_registry_bucket_name": "SurfaceEntityType", + "private_attribute_entity_type_name": "Surface", + "private_attribute_id": "fuselage", + "name": "fuselage", + "private_attribute_tag_key": "groupName", + "private_attribute_sub_components": [ + "body00001_face00005", + "body00001_face00006", + "body00001_face00007", + "body00001_face00008", + "body00001_face00009", + "body00001_face00010" + ], + "private_attribute_potential_issues": [] + }, + { + "private_attribute_registry_bucket_name": "SurfaceEntityType", + "private_attribute_entity_type_name": "Surface", + "private_attribute_id": "rightWing", + "name": "rightWing", + "private_attribute_tag_key": "groupName", + "private_attribute_sub_components": [ + "body00001_face00011", + "body00001_face00012", + "body00001_face00013", + "body00001_face00014" + ], + "private_attribute_potential_issues": [] + } + ], + [ + { + "private_attribute_registry_bucket_name": "SurfaceEntityType", + "private_attribute_entity_type_name": "Surface", + "private_attribute_id": "body00001_face00001", + "name": "body00001_face00001", + "private_attribute_tag_key": "faceId", + "private_attribute_sub_components": [ + "body00001_face00001" + ], + "private_attribute_potential_issues": [] + }, + { + "private_attribute_registry_bucket_name": "SurfaceEntityType", + "private_attribute_entity_type_name": "Surface", + "private_attribute_id": "body00001_face00002", + "name": "body00001_face00002", + "private_attribute_tag_key": "faceId", + "private_attribute_sub_components": [ + "body00001_face00002" + ], + "private_attribute_potential_issues": [] + }, + { + "private_attribute_registry_bucket_name": "SurfaceEntityType", + "private_attribute_entity_type_name": "Surface", + "private_attribute_id": "body00001_face00003", + "name": "body00001_face00003", + "private_attribute_tag_key": "faceId", + "private_attribute_sub_components": [ + "body00001_face00003" + ], + "private_attribute_potential_issues": [] + }, + { + "private_attribute_registry_bucket_name": "SurfaceEntityType", + "private_attribute_entity_type_name": "Surface", + "private_attribute_id": "body00001_face00004", + "name": "body00001_face00004", + "private_attribute_tag_key": "faceId", + "private_attribute_sub_components": [ + "body00001_face00004" + ], + "private_attribute_potential_issues": [] + }, + { + "private_attribute_registry_bucket_name": "SurfaceEntityType", + "private_attribute_entity_type_name": "Surface", + "private_attribute_id": "body00001_face00005", + "name": "body00001_face00005", + "private_attribute_tag_key": "faceId", + "private_attribute_sub_components": [ + "body00001_face00005" + ], + "private_attribute_potential_issues": [] + }, + { + "private_attribute_registry_bucket_name": "SurfaceEntityType", + "private_attribute_entity_type_name": "Surface", + "private_attribute_id": "body00001_face00006", + "name": "body00001_face00006", + "private_attribute_tag_key": "faceId", + "private_attribute_sub_components": [ + "body00001_face00006" + ], + "private_attribute_potential_issues": [] + }, + { + "private_attribute_registry_bucket_name": "SurfaceEntityType", + "private_attribute_entity_type_name": "Surface", + "private_attribute_id": "body00001_face00007", + "name": "body00001_face00007", + "private_attribute_tag_key": "faceId", + "private_attribute_sub_components": [ + "body00001_face00007" + ], + "private_attribute_potential_issues": [] + }, + { + "private_attribute_registry_bucket_name": "SurfaceEntityType", + "private_attribute_entity_type_name": "Surface", + "private_attribute_id": "body00001_face00008", + "name": "body00001_face00008", + "private_attribute_tag_key": "faceId", + "private_attribute_sub_components": [ + "body00001_face00008" + ], + "private_attribute_potential_issues": [] + }, + { + "private_attribute_registry_bucket_name": "SurfaceEntityType", + "private_attribute_entity_type_name": "Surface", + "private_attribute_id": "body00001_face00009", + "name": "body00001_face00009", + "private_attribute_tag_key": "faceId", + "private_attribute_sub_components": [ + "body00001_face00009" + ], + "private_attribute_potential_issues": [] + }, + { + "private_attribute_registry_bucket_name": "SurfaceEntityType", + "private_attribute_entity_type_name": "Surface", + "private_attribute_id": "body00001_face00010", + "name": "body00001_face00010", + "private_attribute_tag_key": "faceId", + "private_attribute_sub_components": [ + "body00001_face00010" + ], + "private_attribute_potential_issues": [] + }, + { + "private_attribute_registry_bucket_name": "SurfaceEntityType", + "private_attribute_entity_type_name": "Surface", + "private_attribute_id": "body00001_face00011", + "name": "body00001_face00011", + "private_attribute_tag_key": "faceId", + "private_attribute_sub_components": [ + "body00001_face00011" + ], + "private_attribute_potential_issues": [] + }, + { + "private_attribute_registry_bucket_name": "SurfaceEntityType", + "private_attribute_entity_type_name": "Surface", + "private_attribute_id": "body00001_face00012", + "name": "body00001_face00012", + "private_attribute_tag_key": "faceId", + "private_attribute_sub_components": [ + "body00001_face00012" + ], + "private_attribute_potential_issues": [] + }, + { + "private_attribute_registry_bucket_name": "SurfaceEntityType", + "private_attribute_entity_type_name": "Surface", + "private_attribute_id": "body00001_face00013", + "name": "body00001_face00013", + "private_attribute_tag_key": "faceId", + "private_attribute_sub_components": [ + "body00001_face00013" + ], + "private_attribute_potential_issues": [] + }, + { + "private_attribute_registry_bucket_name": "SurfaceEntityType", + "private_attribute_entity_type_name": "Surface", + "private_attribute_id": "body00001_face00014", + "name": "body00001_face00014", + "private_attribute_tag_key": "faceId", + "private_attribute_sub_components": [ + "body00001_face00014" + ], + "private_attribute_potential_issues": [] + } + ] + ], + "edge_ids": [ + "body00001_edge00001", + "body00001_edge00002", + "body00001_edge00004", + "body00001_edge00005", + "body00001_edge00006", + "body00001_edge00007", + "body00001_edge00008", + "body00001_edge00009", + "body00001_edge00010", + "body00001_edge00012", + "body00001_edge00013", + "body00001_edge00014", + "body00001_edge00015", + "body00001_edge00016", + "body00001_edge00017", + "body00001_edge00018", + "body00001_edge00019", + "body00001_edge00020", + "body00001_edge00021", + "body00001_edge00022", + "body00001_edge00023", + "body00001_edge00024", + "body00001_edge00025", + "body00001_edge00026", + "body00001_edge00027", + "body00001_edge00029", + "body00001_edge00030", + "body00001_edge00031", + "body00001_edge00032", + "body00001_edge00033" + ], + "edge_attribute_names": [ + "edgeName", + "edgeId" + ], + "grouped_edges": [ + [ + { + "private_attribute_registry_bucket_name": "EdgeEntityType", + "private_attribute_entity_type_name": "Edge", + "private_attribute_id": "trailingEdge", + "name": "trailingEdge", + "private_attribute_tag_key": "edgeName", + "private_attribute_sub_components": [ + "body00001_edge00001", + "body00001_edge00005", + "body00001_edge00026", + "body00001_edge00030" + ] + }, + { + "private_attribute_registry_bucket_name": "EdgeEntityType", + "private_attribute_entity_type_name": "Edge", + "private_attribute_id": "leadingEdge", + "name": "leadingEdge", + "private_attribute_tag_key": "edgeName", + "private_attribute_sub_components": [ + "body00001_edge00007", + "body00001_edge00032" + ] + }, + { + "private_attribute_registry_bucket_name": "EdgeEntityType", + "private_attribute_entity_type_name": "Edge", + "private_attribute_id": "body00001_edge00002", + "name": "body00001_edge00002", + "private_attribute_tag_key": "__standalone__", + "private_attribute_sub_components": [ + "body00001_edge00002" + ] + }, + { + "private_attribute_registry_bucket_name": "EdgeEntityType", + "private_attribute_entity_type_name": "Edge", + "private_attribute_id": "body00001_edge00004", + "name": "body00001_edge00004", + "private_attribute_tag_key": "__standalone__", + "private_attribute_sub_components": [ + "body00001_edge00004" + ] + }, + { + "private_attribute_registry_bucket_name": "EdgeEntityType", + "private_attribute_entity_type_name": "Edge", + "private_attribute_id": "body00001_edge00006", + "name": "body00001_edge00006", + "private_attribute_tag_key": "__standalone__", + "private_attribute_sub_components": [ + "body00001_edge00006" + ] + }, + { + "private_attribute_registry_bucket_name": "EdgeEntityType", + "private_attribute_entity_type_name": "Edge", + "private_attribute_id": "body00001_edge00008", + "name": "body00001_edge00008", + "private_attribute_tag_key": "__standalone__", + "private_attribute_sub_components": [ + "body00001_edge00008" + ] + }, + { + "private_attribute_registry_bucket_name": "EdgeEntityType", + "private_attribute_entity_type_name": "Edge", + "private_attribute_id": "body00001_edge00009", + "name": "body00001_edge00009", + "private_attribute_tag_key": "__standalone__", + "private_attribute_sub_components": [ + "body00001_edge00009" + ] + }, + { + "private_attribute_registry_bucket_name": "EdgeEntityType", + "private_attribute_entity_type_name": "Edge", + "private_attribute_id": "body00001_edge00010", + "name": "body00001_edge00010", + "private_attribute_tag_key": "__standalone__", + "private_attribute_sub_components": [ + "body00001_edge00010" + ] + }, + { + "private_attribute_registry_bucket_name": "EdgeEntityType", + "private_attribute_entity_type_name": "Edge", + "private_attribute_id": "body00001_edge00012", + "name": "body00001_edge00012", + "private_attribute_tag_key": "__standalone__", + "private_attribute_sub_components": [ + "body00001_edge00012" + ] + }, + { + "private_attribute_registry_bucket_name": "EdgeEntityType", + "private_attribute_entity_type_name": "Edge", + "private_attribute_id": "body00001_edge00013", + "name": "body00001_edge00013", + "private_attribute_tag_key": "__standalone__", + "private_attribute_sub_components": [ + "body00001_edge00013" + ] + }, + { + "private_attribute_registry_bucket_name": "EdgeEntityType", + "private_attribute_entity_type_name": "Edge", + "private_attribute_id": "body00001_edge00014", + "name": "body00001_edge00014", + "private_attribute_tag_key": "__standalone__", + "private_attribute_sub_components": [ + "body00001_edge00014" + ] + }, + { + "private_attribute_registry_bucket_name": "EdgeEntityType", + "private_attribute_entity_type_name": "Edge", + "private_attribute_id": "body00001_edge00015", + "name": "body00001_edge00015", + "private_attribute_tag_key": "__standalone__", + "private_attribute_sub_components": [ + "body00001_edge00015" + ] + }, + { + "private_attribute_registry_bucket_name": "EdgeEntityType", + "private_attribute_entity_type_name": "Edge", + "private_attribute_id": "body00001_edge00016", + "name": "body00001_edge00016", + "private_attribute_tag_key": "__standalone__", + "private_attribute_sub_components": [ + "body00001_edge00016" + ] + }, + { + "private_attribute_registry_bucket_name": "EdgeEntityType", + "private_attribute_entity_type_name": "Edge", + "private_attribute_id": "body00001_edge00017", + "name": "body00001_edge00017", + "private_attribute_tag_key": "__standalone__", + "private_attribute_sub_components": [ + "body00001_edge00017" + ] + }, + { + "private_attribute_registry_bucket_name": "EdgeEntityType", + "private_attribute_entity_type_name": "Edge", + "private_attribute_id": "body00001_edge00018", + "name": "body00001_edge00018", + "private_attribute_tag_key": "__standalone__", + "private_attribute_sub_components": [ + "body00001_edge00018" + ] + }, + { + "private_attribute_registry_bucket_name": "EdgeEntityType", + "private_attribute_entity_type_name": "Edge", + "private_attribute_id": "body00001_edge00019", + "name": "body00001_edge00019", + "private_attribute_tag_key": "__standalone__", + "private_attribute_sub_components": [ + "body00001_edge00019" + ] + }, + { + "private_attribute_registry_bucket_name": "EdgeEntityType", + "private_attribute_entity_type_name": "Edge", + "private_attribute_id": "body00001_edge00020", + "name": "body00001_edge00020", + "private_attribute_tag_key": "__standalone__", + "private_attribute_sub_components": [ + "body00001_edge00020" + ] + }, + { + "private_attribute_registry_bucket_name": "EdgeEntityType", + "private_attribute_entity_type_name": "Edge", + "private_attribute_id": "body00001_edge00021", + "name": "body00001_edge00021", + "private_attribute_tag_key": "__standalone__", + "private_attribute_sub_components": [ + "body00001_edge00021" + ] + }, + { + "private_attribute_registry_bucket_name": "EdgeEntityType", + "private_attribute_entity_type_name": "Edge", + "private_attribute_id": "body00001_edge00022", + "name": "body00001_edge00022", + "private_attribute_tag_key": "__standalone__", + "private_attribute_sub_components": [ + "body00001_edge00022" + ] + }, + { + "private_attribute_registry_bucket_name": "EdgeEntityType", + "private_attribute_entity_type_name": "Edge", + "private_attribute_id": "body00001_edge00023", + "name": "body00001_edge00023", + "private_attribute_tag_key": "__standalone__", + "private_attribute_sub_components": [ + "body00001_edge00023" + ] + }, + { + "private_attribute_registry_bucket_name": "EdgeEntityType", + "private_attribute_entity_type_name": "Edge", + "private_attribute_id": "body00001_edge00024", + "name": "body00001_edge00024", + "private_attribute_tag_key": "__standalone__", + "private_attribute_sub_components": [ + "body00001_edge00024" + ] + }, + { + "private_attribute_registry_bucket_name": "EdgeEntityType", + "private_attribute_entity_type_name": "Edge", + "private_attribute_id": "body00001_edge00025", + "name": "body00001_edge00025", + "private_attribute_tag_key": "__standalone__", + "private_attribute_sub_components": [ + "body00001_edge00025" + ] + }, + { + "private_attribute_registry_bucket_name": "EdgeEntityType", + "private_attribute_entity_type_name": "Edge", + "private_attribute_id": "body00001_edge00027", + "name": "body00001_edge00027", + "private_attribute_tag_key": "__standalone__", + "private_attribute_sub_components": [ + "body00001_edge00027" + ] + }, + { + "private_attribute_registry_bucket_name": "EdgeEntityType", + "private_attribute_entity_type_name": "Edge", + "private_attribute_id": "body00001_edge00029", + "name": "body00001_edge00029", + "private_attribute_tag_key": "__standalone__", + "private_attribute_sub_components": [ + "body00001_edge00029" + ] + }, + { + "private_attribute_registry_bucket_name": "EdgeEntityType", + "private_attribute_entity_type_name": "Edge", + "private_attribute_id": "body00001_edge00031", + "name": "body00001_edge00031", + "private_attribute_tag_key": "__standalone__", + "private_attribute_sub_components": [ + "body00001_edge00031" + ] + }, + { + "private_attribute_registry_bucket_name": "EdgeEntityType", + "private_attribute_entity_type_name": "Edge", + "private_attribute_id": "body00001_edge00033", + "name": "body00001_edge00033", + "private_attribute_tag_key": "__standalone__", + "private_attribute_sub_components": [ + "body00001_edge00033" + ] + } + ], + [ + { + "private_attribute_registry_bucket_name": "EdgeEntityType", + "private_attribute_entity_type_name": "Edge", + "private_attribute_id": "body00001_edge00001", + "name": "body00001_edge00001", + "private_attribute_tag_key": "edgeId", + "private_attribute_sub_components": [ + "body00001_edge00001" + ] + }, + { + "private_attribute_registry_bucket_name": "EdgeEntityType", + "private_attribute_entity_type_name": "Edge", + "private_attribute_id": "body00001_edge00002", + "name": "body00001_edge00002", + "private_attribute_tag_key": "edgeId", + "private_attribute_sub_components": [ + "body00001_edge00002" + ] + }, + { + "private_attribute_registry_bucket_name": "EdgeEntityType", + "private_attribute_entity_type_name": "Edge", + "private_attribute_id": "body00001_edge00004", + "name": "body00001_edge00004", + "private_attribute_tag_key": "edgeId", + "private_attribute_sub_components": [ + "body00001_edge00004" + ] + }, + { + "private_attribute_registry_bucket_name": "EdgeEntityType", + "private_attribute_entity_type_name": "Edge", + "private_attribute_id": "body00001_edge00005", + "name": "body00001_edge00005", + "private_attribute_tag_key": "edgeId", + "private_attribute_sub_components": [ + "body00001_edge00005" + ] + }, + { + "private_attribute_registry_bucket_name": "EdgeEntityType", + "private_attribute_entity_type_name": "Edge", + "private_attribute_id": "body00001_edge00006", + "name": "body00001_edge00006", + "private_attribute_tag_key": "edgeId", + "private_attribute_sub_components": [ + "body00001_edge00006" + ] + }, + { + "private_attribute_registry_bucket_name": "EdgeEntityType", + "private_attribute_entity_type_name": "Edge", + "private_attribute_id": "body00001_edge00007", + "name": "body00001_edge00007", + "private_attribute_tag_key": "edgeId", + "private_attribute_sub_components": [ + "body00001_edge00007" + ] + }, + { + "private_attribute_registry_bucket_name": "EdgeEntityType", + "private_attribute_entity_type_name": "Edge", + "private_attribute_id": "body00001_edge00008", + "name": "body00001_edge00008", + "private_attribute_tag_key": "edgeId", + "private_attribute_sub_components": [ + "body00001_edge00008" + ] + }, + { + "private_attribute_registry_bucket_name": "EdgeEntityType", + "private_attribute_entity_type_name": "Edge", + "private_attribute_id": "body00001_edge00009", + "name": "body00001_edge00009", + "private_attribute_tag_key": "edgeId", + "private_attribute_sub_components": [ + "body00001_edge00009" + ] + }, + { + "private_attribute_registry_bucket_name": "EdgeEntityType", + "private_attribute_entity_type_name": "Edge", + "private_attribute_id": "body00001_edge00010", + "name": "body00001_edge00010", + "private_attribute_tag_key": "edgeId", + "private_attribute_sub_components": [ + "body00001_edge00010" + ] + }, + { + "private_attribute_registry_bucket_name": "EdgeEntityType", + "private_attribute_entity_type_name": "Edge", + "private_attribute_id": "body00001_edge00012", + "name": "body00001_edge00012", + "private_attribute_tag_key": "edgeId", + "private_attribute_sub_components": [ + "body00001_edge00012" + ] + }, + { + "private_attribute_registry_bucket_name": "EdgeEntityType", + "private_attribute_entity_type_name": "Edge", + "private_attribute_id": "body00001_edge00013", + "name": "body00001_edge00013", + "private_attribute_tag_key": "edgeId", + "private_attribute_sub_components": [ + "body00001_edge00013" + ] + }, + { + "private_attribute_registry_bucket_name": "EdgeEntityType", + "private_attribute_entity_type_name": "Edge", + "private_attribute_id": "body00001_edge00014", + "name": "body00001_edge00014", + "private_attribute_tag_key": "edgeId", + "private_attribute_sub_components": [ + "body00001_edge00014" + ] + }, + { + "private_attribute_registry_bucket_name": "EdgeEntityType", + "private_attribute_entity_type_name": "Edge", + "private_attribute_id": "body00001_edge00015", + "name": "body00001_edge00015", + "private_attribute_tag_key": "edgeId", + "private_attribute_sub_components": [ + "body00001_edge00015" + ] + }, + { + "private_attribute_registry_bucket_name": "EdgeEntityType", + "private_attribute_entity_type_name": "Edge", + "private_attribute_id": "body00001_edge00016", + "name": "body00001_edge00016", + "private_attribute_tag_key": "edgeId", + "private_attribute_sub_components": [ + "body00001_edge00016" + ] + }, + { + "private_attribute_registry_bucket_name": "EdgeEntityType", + "private_attribute_entity_type_name": "Edge", + "private_attribute_id": "body00001_edge00017", + "name": "body00001_edge00017", + "private_attribute_tag_key": "edgeId", + "private_attribute_sub_components": [ + "body00001_edge00017" + ] + }, + { + "private_attribute_registry_bucket_name": "EdgeEntityType", + "private_attribute_entity_type_name": "Edge", + "private_attribute_id": "body00001_edge00018", + "name": "body00001_edge00018", + "private_attribute_tag_key": "edgeId", + "private_attribute_sub_components": [ + "body00001_edge00018" + ] + }, + { + "private_attribute_registry_bucket_name": "EdgeEntityType", + "private_attribute_entity_type_name": "Edge", + "private_attribute_id": "body00001_edge00019", + "name": "body00001_edge00019", + "private_attribute_tag_key": "edgeId", + "private_attribute_sub_components": [ + "body00001_edge00019" + ] + }, + { + "private_attribute_registry_bucket_name": "EdgeEntityType", + "private_attribute_entity_type_name": "Edge", + "private_attribute_id": "body00001_edge00020", + "name": "body00001_edge00020", + "private_attribute_tag_key": "edgeId", + "private_attribute_sub_components": [ + "body00001_edge00020" + ] + }, + { + "private_attribute_registry_bucket_name": "EdgeEntityType", + "private_attribute_entity_type_name": "Edge", + "private_attribute_id": "body00001_edge00021", + "name": "body00001_edge00021", + "private_attribute_tag_key": "edgeId", + "private_attribute_sub_components": [ + "body00001_edge00021" + ] + }, + { + "private_attribute_registry_bucket_name": "EdgeEntityType", + "private_attribute_entity_type_name": "Edge", + "private_attribute_id": "body00001_edge00022", + "name": "body00001_edge00022", + "private_attribute_tag_key": "edgeId", + "private_attribute_sub_components": [ + "body00001_edge00022" + ] + }, + { + "private_attribute_registry_bucket_name": "EdgeEntityType", + "private_attribute_entity_type_name": "Edge", + "private_attribute_id": "body00001_edge00023", + "name": "body00001_edge00023", + "private_attribute_tag_key": "edgeId", + "private_attribute_sub_components": [ + "body00001_edge00023" + ] + }, + { + "private_attribute_registry_bucket_name": "EdgeEntityType", + "private_attribute_entity_type_name": "Edge", + "private_attribute_id": "body00001_edge00024", + "name": "body00001_edge00024", + "private_attribute_tag_key": "edgeId", + "private_attribute_sub_components": [ + "body00001_edge00024" + ] + }, + { + "private_attribute_registry_bucket_name": "EdgeEntityType", + "private_attribute_entity_type_name": "Edge", + "private_attribute_id": "body00001_edge00025", + "name": "body00001_edge00025", + "private_attribute_tag_key": "edgeId", + "private_attribute_sub_components": [ + "body00001_edge00025" + ] + }, + { + "private_attribute_registry_bucket_name": "EdgeEntityType", + "private_attribute_entity_type_name": "Edge", + "private_attribute_id": "body00001_edge00026", + "name": "body00001_edge00026", + "private_attribute_tag_key": "edgeId", + "private_attribute_sub_components": [ + "body00001_edge00026" + ] + }, + { + "private_attribute_registry_bucket_name": "EdgeEntityType", + "private_attribute_entity_type_name": "Edge", + "private_attribute_id": "body00001_edge00027", + "name": "body00001_edge00027", + "private_attribute_tag_key": "edgeId", + "private_attribute_sub_components": [ + "body00001_edge00027" + ] + }, + { + "private_attribute_registry_bucket_name": "EdgeEntityType", + "private_attribute_entity_type_name": "Edge", + "private_attribute_id": "body00001_edge00029", + "name": "body00001_edge00029", + "private_attribute_tag_key": "edgeId", + "private_attribute_sub_components": [ + "body00001_edge00029" + ] + }, + { + "private_attribute_registry_bucket_name": "EdgeEntityType", + "private_attribute_entity_type_name": "Edge", + "private_attribute_id": "body00001_edge00030", + "name": "body00001_edge00030", + "private_attribute_tag_key": "edgeId", + "private_attribute_sub_components": [ + "body00001_edge00030" + ] + }, + { + "private_attribute_registry_bucket_name": "EdgeEntityType", + "private_attribute_entity_type_name": "Edge", + "private_attribute_id": "body00001_edge00031", + "name": "body00001_edge00031", + "private_attribute_tag_key": "edgeId", + "private_attribute_sub_components": [ + "body00001_edge00031" + ] + }, + { + "private_attribute_registry_bucket_name": "EdgeEntityType", + "private_attribute_entity_type_name": "Edge", + "private_attribute_id": "body00001_edge00032", + "name": "body00001_edge00032", + "private_attribute_tag_key": "edgeId", + "private_attribute_sub_components": [ + "body00001_edge00032" + ] + }, + { + "private_attribute_registry_bucket_name": "EdgeEntityType", + "private_attribute_entity_type_name": "Edge", + "private_attribute_id": "body00001_edge00033", + "name": "body00001_edge00033", + "private_attribute_tag_key": "edgeId", + "private_attribute_sub_components": [ + "body00001_edge00033" + ] + } + ] + ], + "body_group_tag": "groupByFile", + "face_group_tag": "groupName", + "edge_group_tag": "edgeName" + }, + "use_inhouse_mesher": false, + "use_geometry_AI": false, + "project_variables": [ + { + "name": "x", + "value": { + "type_name": "number", + "value": 1 + } + } + ] + } +} \ No newline at end of file diff --git a/tests/simulation/test_expressions.py b/tests/simulation/test_expressions.py index fa2d54113..b8beaa4b4 100644 --- a/tests/simulation/test_expressions.py +++ b/tests/simulation/test_expressions.py @@ -1,3 +1,4 @@ +import json from typing import List import numpy as np @@ -52,6 +53,11 @@ ) +@pytest.fixture(autouse=True) +def change_test_dir(request, monkeypatch): + monkeypatch.chdir(request.fspath.dirname) + + def test_variable_init(): class TestModel(Flow360BaseModel): field: List[UserVariable] = pd.Field() @@ -580,17 +586,18 @@ class TestModel(Flow360BaseModel): assert validation_errors[0]["ctx"]["column"] == 8 try: - model = TestModel(field="1 * 1 + (2") + TestModel(field="1 * 1 + (2") except pd.ValidationError as err: validation_errors = err.errors() - assert len(validation_errors) >= 1 + assert len(validation_errors) == 2 assert validation_errors[0]["type"] == "value_error" - assert "unexpected EOF" in validation_errors[0]["msg"] - assert "1 * 1 + (2" in validation_errors[0]["msg"] + 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"] == 11 + assert validation_errors[0]["ctx"]["column"] == 9 def test_solver_translation(): @@ -685,3 +692,16 @@ class TestModel(Flow360BaseModel): 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: + data = json.load(fh) + + with SI_unit_system: + params = SimulationParams.model_validate(data) + + evaluated = params.reference_geometry.area.evaluate() + + assert evaluated == 1.0 * u.m**2 From 2a1245369faccdb3f91bdf82dea5b4cb897abc0d Mon Sep 17 00:00:00 2001 From: Ben <106089368+benflexcompute@users.noreply.github.com> Date: Thu, 22 May 2025 21:53:30 -0400 Subject: [PATCH 15/34] Pylint Fix for `expression` branch (#1083) * wip * More fixes * Another batch of linter fixes * Another round of linter fixes * Fixed most unit test, now fixing extra error * Fixing all the unit tests, pylint etc * Fix unit test * Fix unit test --------- Co-authored-by: Andrzej Krupka --- .../simulation/blueprint/__init__.py | 10 +- .../simulation/blueprint/codegen/__init__.py | 4 - .../simulation/blueprint/core/__init__.py | 9 +- .../simulation/blueprint/core/context.py | 69 +++++++- .../simulation/blueprint/core/expressions.py | 66 ++++---- .../simulation/blueprint/core/function.py | 13 +- .../blueprint/{codegen => core}/generator.py | 120 +++++++------- .../blueprint/{codegen => core}/parser.py | 96 ++++++----- .../simulation/blueprint/core/resolver.py | 1 + .../simulation/blueprint/core/statements.py | 40 +++-- .../simulation/blueprint/core/types.py | 28 +++- .../simulation/blueprint/flow360/__init__.py | 2 + .../simulation/blueprint/flow360/symbols.py | 65 ++++---- .../simulation/blueprint/tidy3d/__init__.py | 1 - .../simulation/blueprint/tidy3d/symbols.py | 98 ----------- .../simulation/blueprint/utils/operators.py | 6 +- .../simulation/blueprint/utils/types.py | 7 - .../simulation/framework/param_utils.py | 3 +- flow360/component/simulation/primitives.py | 2 +- flow360/component/simulation/services.py | 9 +- .../component/simulation/simulation_params.py | 28 ++-- .../component/simulation/translator/utils.py | 10 +- flow360/component/simulation/user_code.py | 154 +++++++++++++----- flow360/component/simulation/utils.py | 31 ---- .../simulation/variables/control_variables.py | 6 +- .../variables/solution_variables.py | 2 + .../ref/simulation/service_init_geometry.json | 4 +- .../simulation/service_init_surface_mesh.json | 4 +- .../simulation/service_init_volume_mesh.json | 4 +- .../simulation/converter/ref/ref_monitor.json | 2 +- tests/simulation/test_expressions.py | 34 ++-- 31 files changed, 511 insertions(+), 417 deletions(-) delete mode 100644 flow360/component/simulation/blueprint/codegen/__init__.py rename flow360/component/simulation/blueprint/{codegen => core}/generator.py (77%) rename flow360/component/simulation/blueprint/{codegen => core}/parser.py (80%) delete mode 100644 flow360/component/simulation/blueprint/tidy3d/__init__.py delete mode 100644 flow360/component/simulation/blueprint/tidy3d/symbols.py delete mode 100644 flow360/component/simulation/blueprint/utils/types.py 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 From eda9dfa0582c7660077ddf8599fbb19eee3232e0 Mon Sep 17 00:00:00 2001 From: Andrzej Krupka <156919532+andrzej-krupka@users.noreply.github.com> Date: Thu, 29 May 2025 17:38:57 +0200 Subject: [PATCH 16/34] Rolled back to python list types, no numpy interop as of now because it is not compatible with solver translation (#1108) * Rolled back to python list types, no numpy interop as of now because it is not compatible with solver translation * Format --------- Co-authored-by: Andrzej Krupka Co-authored-by: benflexcompute --- flow360/component/simulation/user_code.py | 102 +----------- tests/simulation/test_expressions.py | 184 +++++++++++----------- 2 files changed, 99 insertions(+), 187 deletions(-) diff --git a/flow360/component/simulation/user_code.py b/flow360/component/simulation/user_code.py index 9cc8419b8..09b29b211 100644 --- a/flow360/component/simulation/user_code.py +++ b/flow360/component/simulation/user_code.py @@ -6,7 +6,6 @@ from numbers import Number from typing import Annotated, Any, Generic, Iterable, Literal, Optional, TypeVar, Union -import numpy as np import pydantic as pd from pydantic import BeforeValidator, Discriminator, PlainSerializer, Tag from pydantic_core import InitErrorDetails, core_schema @@ -57,18 +56,15 @@ def _convert_numeric(value): elif isinstance(value, unyt_array): unit = str(value.units) tokens = _split_keep_delimiters(unit, unit_delimiters) - arg = f"{_convert_argument(value.value)[0]} * " + arg = f"{_convert_argument(value.value.tolist())[0]} * " for token in tokens: if token not in unit_delimiters and not _is_number_string(token): token = f"u.{token}" arg += token else: arg += token - elif isinstance(value, np.ndarray): - if value.ndim == 0: - arg = str(value) - else: - arg = f"np.array([{','.join([_convert_argument(item)[0] for item in value])}])" + elif isinstance(value, list): + arg = f"[{','.join([_convert_argument(item)[0] for item in value])}]" return arg @@ -80,7 +76,6 @@ def _convert_argument(value): parenthesize = True elif isinstance(value, Variable): arg = value.name - if not arg: raise ValueError(f"Incompatible argument of type {type(value)}") return arg, parenthesize @@ -97,26 +92,6 @@ class SerializedValueOrExpression(Flow360BaseModel): evaluated_units: Optional[str] = pd.Field(None) -# 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") - - # This is a wrapper to allow using unyt arrays with pydantic models class UnytArray(unyt_array): """UnytArray wrapper to enable pydantic compatibility""" @@ -137,7 +112,7 @@ def validate(cls, value: Any): raise ValueError(f"Cannot convert {type(value)} to UnytArray") -AnyNumericType = Union[float, UnytArray, NdArray] +AnyNumericType = Union[float, UnytArray, list] class Variable(Flow360BaseModel): @@ -247,34 +222,6 @@ def __repr__(self): 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""" @@ -376,13 +323,8 @@ def _validate_expression(cls, value) -> Self: expression = str(value) elif isinstance(value, Variable): expression = str(value) - elif isinstance(value, np.ndarray) and not isinstance(value, unyt_array): - if value.ndim == 0: - expression = str(value) - else: - expression = ( - f"np.array([{','.join([_convert_argument(item)[0] for item in value])}])" - ) + elif isinstance(value, list): + expression = f"[{','.join([_convert_argument(item)[0] for item in value])}]" else: details = InitErrorDetails( type="value_error", ctx={"error": f"Invalid type {type(value)}"} @@ -400,7 +342,7 @@ def _validate_expression(cls, value) -> Self: def evaluate( self, context: EvaluationContext = None, strict: bool = True - ) -> Union[float, np.ndarray, unyt_array]: + ) -> Union[float, list, unyt_array]: """Evaluate this expression against the given context.""" if context is None: context = _global_ctx @@ -550,34 +492,6 @@ def __str__(self): 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})") - T = TypeVar("T") @@ -667,7 +581,7 @@ def _get_discriminator_value(v: Any) -> str: 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)): + if isinstance(v, (Number, unyt_array, list)): return "number" raise KeyError("Unknown expression input type: ", v, v.__class__.__name__) diff --git a/tests/simulation/test_expressions.py b/tests/simulation/test_expressions.py index ed72bc01d..f7dd60662 100644 --- a/tests/simulation/test_expressions.py +++ b/tests/simulation/test_expressions.py @@ -1,8 +1,6 @@ import json -import tempfile from typing import List -import numpy as np import pydantic as pd import pytest @@ -329,10 +327,10 @@ class TestModel(Flow360BaseModel): direction: ValueOrExpression[LengthType.Direction] = pd.Field() moment: ValueOrExpression[LengthType.Moment] = pd.Field() - x = UserVariable(name="x", value=np.array([1, 0, 0])) - y = UserVariable(name="y", value=np.array([0, 0, 0])) - z = UserVariable(name="z", value=np.array([1, 0, 0, 0])) - w = UserVariable(name="w", value=np.array([1, 1, 1])) + x = UserVariable(name="x", value=[1, 0, 0]) + y = UserVariable(name="y", value=[0, 0, 0]) + z = UserVariable(name="z", value=[1, 0, 0, 0]) + w = UserVariable(name="w", value=[1, 1, 1]) model = TestModel( vector=y * u.m, axis=x * u.m, array=z * u.m, direction=x * u.m, moment=w * u.m @@ -434,99 +432,99 @@ class TestModel(Flow360BaseModel): assert str(deserialized.field) == "4.0 m/s" -def test_numpy_interop_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_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() - x = UserVariable(name="x", value=np.array([2, 3, 4])) + x = UserVariable(name="x", value=[2, 3, 4]) model = ScalarModel(scalar=x[0] + x[1] + x[2] + 1) From 61beaf5397632319f521a209c8d60214af666401 Mon Sep 17 00:00:00 2001 From: Andrzej Krupka <156919532+andrzej-krupka@users.noreply.github.com> Date: Thu, 5 Jun 2025 17:15:48 +0200 Subject: [PATCH 17/34] Partial expression evaluation, example of a builtin function (#1115) * Cross in Pyhton mode works * WIP now needing for refactor to enable function on-demand import since current structure causes circular import * Some comments * Got symbolic evaluation to work but very HACKY * Refactored expressions module structure to be self-contained (still contains cyclic imports but only runtime, never at init...) * Partial evaluation before solver code translation * More fixes, simplify deserializer logic * Format * Fixed unit test as many as possible, only 1 left * Fixing most of the pylint issues * Fixed allow_inf_nan when evaluating expressions with solver variables * eagerly evaluation and also taking advantage of unyt pacakge * Small fixes * Fix invalid list initialization syntax in the C++ code generator * Added back the as_vector() implementation * Renamed raise_error * Remove extra evaluation call in cross, reduce unnecessary parentheses * some more small changes --------- Co-authored-by: benflexcompute Co-authored-by: Andrzej Krupka --- flow360/__init__.py | 9 +- .../simulation/blueprint/__init__.py | 10 +- .../simulation/blueprint/core/__init__.py | 142 ++++---- .../simulation/blueprint/core/context.py | 24 +- .../simulation/blueprint/core/expressions.py | 193 ++++++---- .../simulation/blueprint/core/function.py | 6 +- .../simulation/blueprint/core/generator.py | 85 +++-- .../simulation/blueprint/core/parser.py | 82 ++--- .../simulation/blueprint/core/resolver.py | 16 - .../simulation/blueprint/core/statements.py | 129 ++++--- .../simulation/blueprint/core/types.py | 12 +- .../simulation/blueprint/flow360/__init__.py | 3 - .../simulation/blueprint/flow360/symbols.py | 178 ---------- .../simulation/framework/param_utils.py | 2 +- .../component/simulation/framework/updater.py | 8 + flow360/component/simulation/primitives.py | 2 +- flow360/component/simulation/services.py | 6 +- .../component/simulation/simulation_params.py | 2 +- .../component/simulation/translator/utils.py | 6 +- flow360/component/simulation/unit_system.py | 35 +- .../{variables => user_code}/__init__.py | 0 .../simulation/user_code/core/__init__.py | 0 .../simulation/user_code/core/context.py | 152 ++++++++ .../{user_code.py => user_code/core/types.py} | 225 ++++++------ .../simulation/user_code/core/utils.py | 47 +++ .../user_code/functions/__init__.py | 0 .../simulation/user_code/functions/math.py | 42 +++ .../user_code/variables/__init__.py | 0 .../variables/control.py} | 2 +- .../variables/solution.py} | 16 +- .../validation_simulation_params.py | 2 +- .../data/{variables.json => simulation.json} | 1 + tests/simulation/service/test_services_v2.py | 2 +- tests/simulation/test_expressions.py | 335 ++++++++++++------ 34 files changed, 1066 insertions(+), 708 deletions(-) delete mode 100644 flow360/component/simulation/blueprint/flow360/__init__.py delete mode 100644 flow360/component/simulation/blueprint/flow360/symbols.py rename flow360/component/simulation/{variables => user_code}/__init__.py (100%) create mode 100644 flow360/component/simulation/user_code/core/__init__.py create mode 100644 flow360/component/simulation/user_code/core/context.py rename flow360/component/simulation/{user_code.py => user_code/core/types.py} (77%) create mode 100644 flow360/component/simulation/user_code/core/utils.py create mode 100644 flow360/component/simulation/user_code/functions/__init__.py create mode 100644 flow360/component/simulation/user_code/functions/math.py create mode 100644 flow360/component/simulation/user_code/variables/__init__.py rename flow360/component/simulation/{variables/control_variables.py => user_code/variables/control.py} (96%) rename flow360/component/simulation/{variables/solution_variables.py => user_code/variables/solution.py} (88%) rename tests/simulation/data/{variables.json => simulation.json} (99%) 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 From b9233116f8af30f4bba7f95c24789af10bd8ca68 Mon Sep 17 00:00:00 2001 From: benflexcompute Date: Fri, 6 Jun 2025 02:04:55 +0000 Subject: [PATCH 18/34] Fixed merging --- flow360/component/simulation/user_code/core/types.py | 2 +- flow360/component/simulation/user_code/functions/math.py | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/flow360/component/simulation/user_code/core/types.py b/flow360/component/simulation/user_code/core/types.py index bd30325a0..06f948029 100644 --- a/flow360/component/simulation/user_code/core/types.py +++ b/flow360/component/simulation/user_code/core/types.py @@ -608,7 +608,7 @@ def _discriminator(v: Any) -> str: Union[ Annotated[expr_type, Tag("expression")], Annotated[typevar_values, Tag("number")] ], - Discriminator(_discriminator), + pd.Field(discriminator=Discriminator(_discriminator)), BeforeValidator(_deserialize), PlainSerializer(_serializer), ] diff --git a/flow360/component/simulation/user_code/functions/math.py b/flow360/component/simulation/user_code/functions/math.py index 719195593..3eedae4ea 100644 --- a/flow360/component/simulation/user_code/functions/math.py +++ b/flow360/component/simulation/user_code/functions/math.py @@ -4,7 +4,8 @@ from typing import Any, Union -from unyt import ucross, unyt_array, unyt_quantity +import numpy as np +from unyt import unyt_array, unyt_quantity from flow360.component.simulation.user_code.core.types import Expression @@ -30,7 +31,7 @@ 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) + return np.cross(left, right) # Otherwise result = [ From b700bbf122ce315ec6ea3479ec3da5d8bb888242 Mon Sep 17 00:00:00 2001 From: benflexcompute Date: Fri, 6 Jun 2025 02:11:38 +0000 Subject: [PATCH 19/34] Fixed V1 tests --- .../case-70489f25-d6b7-4a0b-81e1-2fa2e82fc57b/simulation.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/data/case-70489f25-d6b7-4a0b-81e1-2fa2e82fc57b/simulation.json b/tests/data/case-70489f25-d6b7-4a0b-81e1-2fa2e82fc57b/simulation.json index 6a7a81368..dcf666254 100644 --- a/tests/data/case-70489f25-d6b7-4a0b-81e1-2fa2e82fc57b/simulation.json +++ b/tests/data/case-70489f25-d6b7-4a0b-81e1-2fa2e82fc57b/simulation.json @@ -561,7 +561,8 @@ "reference_geometry": { "area": { "units": "m**2", - "value": 70685.83470577035 + "value": 70685.83470577035, + "type_name":"number" }, "moment_center": { "units": "m", From 4a1db3ebfe0967a09f1461e0dd91408214fa1692 Mon Sep 17 00:00:00 2001 From: Ben <106089368+benflexcompute@users.noreply.github.com> Date: Mon, 9 Jun 2025 16:48:48 -0400 Subject: [PATCH 20/34] [FL-729] [FLPY-7] Dimensioned Volume Output (#1012) * Added unit test, now pending fixing lint and todos * Ready for review * Fix for windows-1 * Format * Another attempt * Thank you Windows * should work now --- .../component/simulation/outputs/outputs.py | 3 +- .../translator/solver_translator.py | 150 ++++++++- .../component/simulation/translator/utils.py | 4 +- flow360/component/simulation/unit_system.py | 11 +- .../simulation/user_code/core/types.py | 126 ++++++-- .../user_code/variables/solution.py | 7 +- .../validation/validation_output.py | 4 +- tests/simulation/test_expressions.py | 301 +++++++++++++++++- 8 files changed, 544 insertions(+), 62 deletions(-) diff --git a/flow360/component/simulation/outputs/outputs.py b/flow360/component/simulation/outputs/outputs.py index 1fb58828d..665866366 100644 --- a/flow360/component/simulation/outputs/outputs.py +++ b/flow360/component/simulation/outputs/outputs.py @@ -37,6 +37,7 @@ Surface, ) from flow360.component.simulation.unit_system import LengthType +from flow360.component.simulation.user_code.core.types import UserVariable from flow360.component.simulation.validation.validation_context import ( ALL, CASE, @@ -260,7 +261,7 @@ class VolumeOutput(_AnimationAndFileFormatSettings): """ name: Optional[str] = pd.Field("Volume output", description="Name of the `VolumeOutput`.") - output_fields: UniqueItemList[Union[VolumeFieldNames, str]] = pd.Field( + output_fields: UniqueItemList[Union[VolumeFieldNames, str, UserVariable]] = pd.Field( description="List of output variables. Including :ref:`universal output variables`," " :ref:`variables specific to VolumeOutput`" " and :class:`UserDefinedField`." diff --git a/flow360/component/simulation/translator/solver_translator.py b/flow360/component/simulation/translator/solver_translator.py index d42aaca83..bc530cc2c 100644 --- a/flow360/component/simulation/translator/solver_translator.py +++ b/flow360/component/simulation/translator/solver_translator.py @@ -1,7 +1,11 @@ """Flow360 solver setting parameter translator.""" # pylint: disable=too-many-lines -from typing import Type, Union +from numbers import Number +from typing import Literal, Type, Union + +import numpy as np +import unyt as u from flow360.component.simulation.conversion import LIQUID_IMAGINARY_FREESTREAM_MACH from flow360.component.simulation.framework.entity_base import EntityList @@ -86,6 +90,7 @@ update_dict_recursively, ) from flow360.component.simulation.unit_system import LengthType +from flow360.component.simulation.user_code.core.types import Expression, UserVariable from flow360.component.simulation.utils import ( is_exact_instance, is_instance_of_type_in_union, @@ -237,11 +242,20 @@ def translate_output_fields( ], ): """Get output fields""" - return {"outputFields": append_component_to_output_fields(output_model.output_fields.items)} + output_fields = [] + + for item in append_component_to_output_fields(output_model.output_fields.items): + output_fields.append(item) + + for output_field in output_model.output_fields.items: + if isinstance(output_field, UserVariable): + output_fields.append(output_field.name) + + return {"outputFields": output_fields} def surface_probe_setting_translation_func(entity: SurfaceProbeOutput): - """Translate non-entitties part of SurfaceProbeOutput""" + """Translate non-entities part of SurfaceProbeOutput""" dict_with_merged_output_fields = monitor_translator(entity) dict_with_merged_output_fields["surfacePatches"] = [ surface.full_name for surface in entity.target_surfaces.stored_entities @@ -355,13 +369,22 @@ def translate_volume_output( is_average=volume_output_class is TimeAverageVolumeOutput, ) # Get outputFields + output_fields = [] + + output_fields = append_component_to_output_fields( + get_global_setting_from_first_instance( + output_params, volume_output_class, "output_fields" + ).model_dump()["items"] + ) + + for output_field in get_global_setting_from_first_instance( + output_params, volume_output_class, "output_fields" + ).items: + if isinstance(output_field, UserVariable): + output_fields.append(output_field.name) volume_output.update( { - "outputFields": append_component_to_output_fields( - get_global_setting_from_first_instance( - output_params, volume_output_class, "output_fields" - ).model_dump()["items"] - ), + "outputFields": output_fields, } ) return volume_output @@ -512,7 +535,107 @@ def translate_acoustic_output(output_params: list): return None +def user_variable_to_udf(variable: UserVariable, input_params: SimulationParams): + # pylint:disable=too-many-statements + """Convert user variable to UDF""" + if not isinstance(variable.value, Expression): + # Likely number of unyt object + # We should add validator for this for output fields. + raise ValueError("Did not find expression in user variable") + + numerical_value = variable.value.evaluate(raise_on_non_evaluable=False, force_evaluate=True) + + is_constant = False + if isinstance(numerical_value, Number) and not np.isnan(numerical_value): # not NaN + is_constant = True + elif isinstance(numerical_value, u.unyt_quantity) and not np.isnan(numerical_value.value): + is_constant = True + elif isinstance(numerical_value, u.unyt_array) and not np.any(np.isnan(numerical_value.value)): + is_constant = True + + if is_constant: + raise ValueError("Constant value found in user variable.") + + def _compute_coefficient_and_offset(source_unit: u.Unit, target_unit: u.Unit): + y2 = (2 * target_unit).in_units(source_unit).value + y1 = (1 * target_unit).in_units(source_unit).value + x2 = 2 + x1 = 1 + + coefficient = (y2 - y1) / (x2 - x1) + offset = y1 / coefficient - x1 + + assert np.isclose( + 123, (coefficient * (123 + offset) * source_unit).in_units(target_unit).value + ) + assert np.isclose( + 12, (coefficient * (12 + offset) * source_unit).in_units(target_unit).value + ) + + return coefficient, offset + + def _get_output_unit(expression: Expression, input_params: SimulationParams): + if not expression.output_units: + # Derive the default output unit based on the value's dimensionality and current unit system + current_unit_system_name: Literal["SI", "Imperial", "CGS"] = ( + input_params.unit_system.name + ) + numerical_value = expression.evaluate(raise_on_non_evaluable=False, force_evaluate=True) + if not isinstance(numerical_value, (u.unyt_array, u.unyt_quantity)): + # Pure dimensionless constant + return None + if current_unit_system_name == "SI": + return numerical_value.in_base("mks").units + if current_unit_system_name == "Imperial": + return numerical_value.in_base("imperial").units + if current_unit_system_name == "CGS": + return numerical_value.in_base("cgs").units + + return u.Unit(expression.output_units) + + expression: Expression = variable.value + + requested_unit: Union[u.Unit, None] = _get_output_unit(expression, input_params) + if requested_unit is None: + # Number constant output requested + coefficient = 1 + offset = 0 + else: + flow360_unit_system = input_params.flow360_unit_system + # Note: Effectively assuming that all the solver vars uses radians and also the expressions expect radians + flow360_unit_system["angle"] = u.rad # pylint:disable=no-member + flow360_unit = flow360_unit_system[requested_unit.dimensions] + coefficient, offset = _compute_coefficient_and_offset( + source_unit=requested_unit, target_unit=flow360_unit + ) + + if expression.length == 1: + expression = expression.evaluate(raise_on_non_evaluable=False, force_evaluate=False) + if offset != 0: + expression = (expression + offset) * coefficient + else: + expression = expression * coefficient + expression = expression.to_solver_code(params=input_params) + return UserDefinedField( + name=variable.name, expression=f"{variable.name} = " + expression + ";" + ) + + # Vector output requested + expression = [ + expression[i].evaluate(raise_on_non_evaluable=False, force_evaluate=False) + for i in range(expression.length) + ] + if offset != 0: + expression = [(item + offset) * coefficient for item in expression] + else: + expression = [item * coefficient for item in expression] + expression = [item.to_solver_code(params=input_params) for item in expression] + expression = [f"{variable.name}[{i}] = " + item for i, item in enumerate(expression)] + return UserDefinedField(name=variable.name, expression="; ".join(expression) + ";") + + def process_output_fields_for_udf(input_params: SimulationParams): + # pylint:disable=too-many-branches """ Process all output fields from different output types and generate additional UserDefinedFields for dimensioned fields. @@ -550,6 +673,17 @@ def process_output_fields_for_udf(input_params: SimulationParams): if udf_expression: generated_udfs.append(UserDefinedField(name=field_name, expression=udf_expression)) + if input_params.outputs: + # UserVariable handling: + user_variable_udfs = set() + for output in input_params.outputs: + if not hasattr(output, "output_fields") or not output.output_fields: + continue + for output_field in output.output_fields.items: + if not isinstance(output_field, UserVariable): + continue + user_variable_udfs.add(user_variable_to_udf(output_field, input_params)) + return generated_udfs diff --git a/flow360/component/simulation/translator/utils.py b/flow360/component/simulation/translator/utils.py index efc39cad2..d47c52107 100644 --- a/flow360/component/simulation/translator/utils.py +++ b/flow360/component/simulation/translator/utils.py @@ -133,7 +133,7 @@ def _is_unyt_or_unyt_like_obj(value): ) return new_dict for key, value in input_dict.items(): - if isinstance(value, dict) and _is_unyt_or_unyt_like_obj(input_dict): + if isinstance(value, dict) and _is_unyt_or_unyt_like_obj(value): if value["units"].startswith("flow360_") is False: raise ValueError( f"[Internal Error] Unit {value['units']} is not non-dimensionalized." @@ -148,7 +148,7 @@ def _is_unyt_or_unyt_like_obj(value): def inline_expressions_in_dict(input_dict, input_params): - """Inline all expressions in the provided dict to their evaluated values""" + """Inline all client-time evaluable 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/unit_system.py b/flow360/component/simulation/unit_system.py index e3fffc297..8a23fb35f 100644 --- a/flow360/component/simulation/unit_system.py +++ b/flow360/component/simulation/unit_system.py @@ -1621,7 +1621,16 @@ def defaults(self): def __getitem__(self, item): """to support [] access""" - return getattr(self, item) + try: + return getattr(self, item) + except TypeError: + # Allowing usage like [(mass)/(time)] + for attr_name, attr in vars(self).items(): + if not isinstance(attr, unyt_quantity): + continue + if attr.units.dimensions == item: + return getattr(self, attr_name) + raise AttributeError(f"'{item}' is not a valid attribute of {self.__class__.__name__}. ") def system_repr(self): """(mass, length, time, temperature) string representation of the system""" diff --git a/flow360/component/simulation/user_code/core/types.py b/flow360/component/simulation/user_code/core/types.py index 06f948029..97207f5ba 100644 --- a/flow360/component/simulation/user_code/core/types.py +++ b/flow360/component/simulation/user_code/core/types.py @@ -5,14 +5,14 @@ import ast import re from numbers import Number -from typing import Annotated, Any, Generic, Literal, Optional, TypeVar, Union +from typing import Annotated, Any, Generic, List, Literal, Optional, TypeVar, Union import numpy as np import pydantic as pd from pydantic import BeforeValidator, Discriminator, PlainSerializer, Tag from pydantic_core import InitErrorDetails, core_schema from typing_extensions import Self -from unyt import Unit, unyt_array +from unyt import Unit, unyt_array, unyt_quantity from flow360.component.simulation.blueprint import Evaluable, expr_to_model from flow360.component.simulation.blueprint.core import EvaluationContext, expr_to_code @@ -104,12 +104,13 @@ 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) + type_name: Literal["number", "expression"] = pd.Field() 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, list[Number]]] = pd.Field(None) + evaluated_value: Union[Optional[Number], list[Optional[Number]]] = pd.Field(None) evaluated_units: Optional[str] = pd.Field(None) + output_units: Optional[str] = pd.Field(None, description="See definition in `Expression`.") # This is a wrapper to allow using unyt arrays with pydantic models @@ -242,24 +243,28 @@ def __repr__(self): def __hash__(self): return hash(self.name) + def __eq__(self, other): + # NaN-compatible equal operator for unit test support + if not isinstance(other, Variable): + return False + return self.model_dump_json() == other.model_dump_json() + class UserVariable(Variable): """Class representing a user-defined symbolic variable""" @pd.model_validator(mode="after") - @classmethod - def update_context(cls, value): + def update_context(self): """Auto updating context when new variable is declared""" - default_context.set(value.name, value.value) - _user_variables.add(value.name) - return value + default_context.set(self.name, self.value) + _user_variables.add(self.name) + return self @pd.model_validator(mode="after") - @classmethod - def check_dependencies(cls, value): + def check_dependencies(self): """Validator for ensuring no cyclic dependency.""" visited = set() - stack = [(value.name, [value.name])] + stack = [(self.name, [self.name])] while stack: (current_name, current_path) = stack.pop() current_value = default_context.get(current_name) @@ -275,7 +280,19 @@ def check_dependencies(cls, value): stack.extend( [(name, current_path + [name]) for name in used_names if name not in visited] ) - return value + return self + + def __hash__(self): + """ + Support for set and deduplicate. + Can be removed if not used directly in output_fields. + """ + return hash(self.model_dump_json()) + + def in_unit(self, new_unit: str = None): + """Requesting the output of the variable to be in the given (new_unit) units.""" + self.value.output_units = new_unit + return self class SolverVariable(Variable): @@ -284,13 +301,24 @@ class SolverVariable(Variable): solver_name: Optional[str] = pd.Field(None) @pd.model_validator(mode="after") - @classmethod - def update_context(cls, value): + def update_context(self): """Auto updating context when new variable is declared""" - default_context.set(value.name, value.value, SolverVariable) - if value.solver_name: - default_context.set_alias(value.name, value.solver_name) - return value + default_context.set(self.name, self.value, SolverVariable) + if self.solver_name: + default_context.set_alias(self.name, self.solver_name) + return self + + def in_unit(self, new_name: str, new_unit: str = None): + """ + + + Return a UserVariable that will generate results in the new_unit. + If new_unit is not specified then the unit will be determined by the unit system. + """ + + new_variable = UserVariable(name=new_name, value=Expression(expression=self.name)) + new_variable.value.output_units = new_unit # pylint:disable=assigning-non-slot + return new_variable class Expression(Flow360BaseModel, Evaluable): @@ -305,20 +333,31 @@ class Expression(Flow360BaseModel, Evaluable): """ expression: str = pd.Field("") + output_units: Optional[str] = pd.Field( + None, + description="String representation of what the requested units the evaluated expression should be " + "when `self` is used as an output field. By default the output units will be inferred from the unit " + "system associated with SimulationParams", + ) model_config = pd.ConfigDict(validate_assignment=True) @pd.model_validator(mode="before") @classmethod def _validate_expression(cls, value) -> Self: + output_units = None if isinstance(value, str): expression = value elif isinstance(value, dict) and "expression" in value.keys(): expression = value["expression"] + output_units = value.get("output_units") elif isinstance(value, Expression): expression = str(value) + output_units = value.output_units elif isinstance(value, Variable): expression = str(value) + if isinstance(value.value, Expression): + output_units = value.value.output_units elif isinstance(value, list): expression = f"[{','.join([_convert_argument(item)[0] for item in value])}]" else: @@ -337,7 +376,7 @@ def _validate_expression(cls, value) -> Self: details = InitErrorDetails(type="value_error", ctx={"error": v_err}) raise pd.ValidationError.from_exception_data("Expression value error", [details]) - return {"expression": expression} + return {"expression": expression, "output_units": output_units} def evaluate( self, @@ -523,6 +562,22 @@ def __str__(self): def __repr__(self): return f"Expression({self.expression})" + @property + def dimensionality(self): + """The physical dimensionality of the expression.""" + value = self.evaluate(raise_on_non_evaluable=False, force_evaluate=True) + assert isinstance(value, (unyt_array, unyt_quantity)) + return value.units.dimensions + + @property + def length(self): + """The number of elements in the expression.""" + value = self.evaluate(raise_on_non_evaluable=False, force_evaluate=True) + assert isinstance(value, (unyt_array, unyt_quantity, list)) + if isinstance(value, list): + return len(value) + return 1 if isinstance(value, unyt_quantity) else value.shape[0] + T = TypeVar("T") @@ -542,26 +597,26 @@ def _internal_validator(value: Expression): 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: # pylint:disable=broad-exception-caught - pass - - if is_serialized: if value.type_name == "number": if value.units is not None: + # unyt objects return unyt_array(value.value, value.units) return value.value if value.type_name == "expression": return expr_type(expression=value.expression) + except Exception: # pylint:disable=broad-exception-caught + pass + return value def _serializer(value, info) -> dict: if isinstance(value, Expression): - serialized = SerializedValueOrExpression(type_name="expression") + serialized = SerializedValueOrExpression( + type_name="expression", + output_units=value.output_units, + ) serialized.expression = value.expression @@ -570,19 +625,24 @@ def _serializer(value, info) -> dict: if isinstance(evaluated, Number): serialized.evaluated_value = evaluated elif isinstance(evaluated, unyt_array): - if evaluated.size == 1: - serialized.evaluated_value = float(evaluated.value) + serialized.evaluated_value = ( + float(evaluated.value) + if not np.isnan(evaluated.value) + else None # NaN-None handling + ) else: - serialized.evaluated_value = tuple(evaluated.value.tolist()) + serialized.evaluated_value = tuple( + item if not np.isnan(item) else None + for item in evaluated.value.tolist() + ) serialized.evaluated_units = str(evaluated.units.expr) else: serialized = SerializedValueOrExpression(type_name="number") - if isinstance(value, Number): + if isinstance(value, (Number, List)): serialized.value = value elif isinstance(value, unyt_array): - if value.size == 1: serialized.value = float(value.value) else: diff --git a/flow360/component/simulation/user_code/variables/solution.py b/flow360/component/simulation/user_code/variables/solution.py index 289dc7649..e535225e6 100644 --- a/flow360/component/simulation/user_code/variables/solution.py +++ b/flow360/component/simulation/user_code/variables/solution.py @@ -5,8 +5,11 @@ 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 +mut = SolverVariable( + name="solution.mut", value=float("NaN") * u.kg / u.m / u.s, solver_name="mut" +) # Turbulent viscosity +mu = SolverVariable(name="solution.mu", value=float("NaN") * u.kg / u.m / u.s) # Laminar viscosity + solutionNavierStokes = SolverVariable( name="solution.solutionNavierStokes", value=float("NaN") ) # Solution for N-S equation in conservative form diff --git a/flow360/component/simulation/validation/validation_output.py b/flow360/component/simulation/validation/validation_output.py index a63bfd534..6a0e60449 100644 --- a/flow360/component/simulation/validation/validation_output.py +++ b/flow360/component/simulation/validation/validation_output.py @@ -52,7 +52,7 @@ def extract_literal_values(annotation): allowed_items = natively_supported + additional_fields for item in output.output_fields.items: - if item not in allowed_items: + if isinstance(item, str) and item not in allowed_items and isinstance(item, str): raise ValueError( f"In `outputs`[{output_index}] {output.output_type}:, {item} is not a" f" valid output field name. Allowed fields are {allowed_items}." @@ -98,7 +98,7 @@ def _check_output_fields_valid_given_turbulence_model(params): if output.output_type in ("AeroAcousticOutput", "StreamlineOutput"): continue for item in output.output_fields.items: - if item in invalid_output_fields[turbulence_model]: + if isinstance(item, str) and item in invalid_output_fields[turbulence_model]: raise ValueError( f"In `outputs`[{output_index}] {output.output_type}:, {item} is not a valid" f" output field when using turbulence model: {turbulence_model}." diff --git a/tests/simulation/test_expressions.py b/tests/simulation/test_expressions.py index b3e24b5ff..077250ac0 100644 --- a/tests/simulation/test_expressions.py +++ b/tests/simulation/test_expressions.py @@ -1,10 +1,13 @@ import json -from typing import List +import re +from typing import Annotated, List +import numpy as np import pydantic as pd import pytest from flow360 import ( + AerospaceCondition, HeatEquationInitialCondition, LiquidOperatingCondition, SimulationParams, @@ -16,9 +19,16 @@ from flow360.component.simulation.framework.base_model import Flow360BaseModel from flow360.component.simulation.framework.param_utils import AssetCache from flow360.component.simulation.models.material import Water, aluminum -from flow360.component.simulation.outputs.outputs import SurfaceOutput -from flow360.component.simulation.primitives import GenericVolume, Surface +from flow360.component.simulation.outputs.outputs import SurfaceOutput, VolumeOutput +from flow360.component.simulation.primitives import ( + GenericVolume, + ReferenceGeometry, + Surface, +) from flow360.component.simulation.services import ValidationCalledBy, validate_model +from flow360.component.simulation.translator.solver_translator import ( + user_variable_to_udf, +) from flow360.component.simulation.unit_system import ( AbsoluteTemperatureType, AngleType, @@ -51,6 +61,7 @@ ValueOrExpression, ) from flow360.component.simulation.user_code.variables import control, solution +from tests.utils import to_file_from_file_test @pytest.fixture(autouse=True) @@ -58,6 +69,31 @@ def change_test_dir(request, monkeypatch): monkeypatch.chdir(request.fspath.dirname) +@pytest.fixture() +def constant_variable(): + return UserVariable(name="constant_variable", value=10) + + +@pytest.fixture() +def constant_array(): + return UserVariable(name="constant_array", value=[10, 20]) + + +@pytest.fixture() +def constant_unyt_quantity(): + return UserVariable(name="constant_unyt_quantity", value=10 * u.m) + + +@pytest.fixture() +def constant_unyt_array(): + return UserVariable(name="constant_unyt_array", value=[10, 20] * u.m) + + +@pytest.fixture() +def solution_variable(): + return UserVariable(name="solution_variable", value=solution.velocity) + + def test_variable_init(): class TestModel(Flow360BaseModel): field: List[UserVariable] = pd.Field() @@ -279,7 +315,7 @@ class TestModel(Flow360BaseModel): def test_constrained_scalar_type(): class TestModel(Flow360BaseModel): - field: ValueOrExpression[pd.confloat(ge=0)] = pd.Field() + field: ValueOrExpression[Annotated[float, pd.Field(strict=True, ge=0)]] = pd.Field() x = UserVariable(name="x", value=1) @@ -374,7 +410,13 @@ class TestModel(Flow360BaseModel): model.field.evaluate() -def test_serializer(): +def test_serializer( + constant_variable, + constant_array, + constant_unyt_quantity, + constant_unyt_array, + solution_variable, +): class TestModel(Flow360BaseModel): field: ValueOrExpression[VelocityType] = pd.Field() @@ -397,8 +439,78 @@ class TestModel(Flow360BaseModel): assert serialized["field"]["value"] == 4 assert serialized["field"]["units"] == "m/s" + assert constant_variable.model_dump() == { + "name": "constant_variable", + "value": { + "evaluated_units": None, + "evaluated_value": None, + "expression": None, + "output_units": None, + "type_name": "number", + "units": None, + "value": 10.0, + }, + } + + assert constant_array.model_dump() == { + "name": "constant_array", + "value": { + "evaluated_units": None, + "evaluated_value": None, + "expression": None, + "output_units": None, + "type_name": "number", + "units": None, + "value": [10, 20], + }, + } + assert constant_unyt_quantity.model_dump() == { + "name": "constant_unyt_quantity", + "value": { + "evaluated_units": None, + "evaluated_value": None, + "expression": None, + "output_units": None, + "type_name": "number", + "units": "m", + "value": 10.0, + }, + } + + assert constant_unyt_array.model_dump() == { + "name": "constant_unyt_array", + "value": { + "evaluated_units": None, + "evaluated_value": None, + "expression": None, + "output_units": None, + "type_name": "number", + "units": "m", + "value": [10, 20], + }, + } + + assert solution_variable.model_dump() == { + "name": "solution_variable", + "value": { + "evaluated_units": "m/s", + "evaluated_value": [None, None, None], + "expression": "solution.velocity", + "output_units": None, + "type_name": "expression", + "units": None, + "value": None, + }, + } + -def test_deserializer(): +def test_deserializer( + constant_unyt_quantity, + constant_unyt_array, + constant_variable, + constant_array, + solution_variable, +): class TestModel(Flow360BaseModel): field: ValueOrExpression[VelocityType] = pd.Field() @@ -421,6 +533,90 @@ class TestModel(Flow360BaseModel): assert str(deserialized.field) == "4.0 m/s" + # Constant unyt quantity + model = { + "name": "constant_unyt_quantity", + "value": { + "evaluated_units": None, + "evaluated_value": None, + "expression": None, + "output_units": None, + "type_name": "number", + "units": "m", + "value": 10.0, + }, + } + deserialized = UserVariable.model_validate(model) + assert deserialized == constant_unyt_quantity + + # Constant unyt array + model = { + "name": "constant_unyt_array", + "value": { + "evaluated_units": None, + "evaluated_value": None, + "expression": None, + "output_units": None, + "type_name": "number", + "units": "m", + "value": [10, 20], + }, + } + deserialized = UserVariable.model_validate(model) + assert deserialized == constant_unyt_array + + # Constant quantity + model = { + "name": "constant_variable", + "value": { + "evaluated_units": None, + "evaluated_value": None, + "expression": None, + "output_units": None, + "type_name": "number", + "units": None, + "value": 10.0, + }, + } + deserialized = UserVariable.model_validate(model) + assert deserialized == constant_variable + + # Constant array + model = { + "name": "constant_array", + "value": { + "evaluated_units": None, + "evaluated_value": None, + "expression": None, + "output_units": None, + "type_name": "number", + "units": None, + "value": [10, 20], + }, + } + deserialized = UserVariable.model_validate(model) + assert deserialized == constant_array + + # Solver variable (NaN-None handling) + model = { + "name": "solution_variable", + "value": { + "evaluated_units": "m/s", + "evaluated_value": [None, None, None], + "expression": "solution.velocity", + "output_units": None, + "type_name": "expression", + "units": None, + "value": None, + }, + } + deserialized = UserVariable.model_validate(model) + assert deserialized == solution_variable + assert all( + np.isnan(item) + for item in deserialized.value.evaluate(raise_on_non_evaluable=False, force_evaluate=True) + ) + def test_subscript_access(): class ScalarModel(Flow360BaseModel): @@ -448,7 +644,7 @@ class TestModel(Flow360BaseModel): x = UserVariable(name="x", value=4) try: - model = TestModel(field="1 + nonexisting * 1") + TestModel(field="1 + nonexisting * 1") except pd.ValidationError as err: validation_errors = err.errors() @@ -457,7 +653,7 @@ class TestModel(Flow360BaseModel): assert "Name 'nonexisting' is not defined" in validation_errors[0]["msg"] try: - model = TestModel(field="1 + x * 1") + TestModel(field="1 + x * 1") except pd.ValidationError as err: validation_errors = err.errors() @@ -466,7 +662,7 @@ class TestModel(Flow360BaseModel): assert "does not match (length)/(time) dimension" in validation_errors[0]["msg"] try: - model = TestModel(field="1 * 1 +") + TestModel(field="1 * 1 +") except pd.ValidationError as err: validation_errors = err.errors() @@ -479,7 +675,7 @@ class TestModel(Flow360BaseModel): assert validation_errors[0]["ctx"]["column"] == 8 try: - model = TestModel(field="1 * 1 +* 2") + TestModel(field="1 * 1 +* 2") except pd.ValidationError as err: validation_errors = err.errors() @@ -798,8 +994,8 @@ def test_expression_indexing(): 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]) + cross_result = math.cross(b, c) + expr = Expression.model_validate(cross_result[a]) assert ( str(expr) @@ -808,7 +1004,86 @@ def test_expression_indexing(): assert expr.evaluate() == 8 # Cannot simplify without non-statically evaluable index object (expression for example) - expr = Expression.model_validate(cross[1]) + expr = Expression.model_validate(cross_result[1]) assert str(expr) == "b[2] * c[0] - b[0] * c[2]" assert expr.evaluate() == 8 + + +def test_to_file_from_file_expression( + constant_variable, constant_array, constant_unyt_quantity, constant_unyt_array +): + with SI_unit_system: + params = SimulationParams( + reference_geometry=ReferenceGeometry( + area=10 * u.m**2, + ), + outputs=[ + VolumeOutput( + output_fields=[ + solution.mut.in_unit(new_name="mut_in_SI"), + constant_variable, + constant_array, + constant_unyt_quantity, + constant_unyt_array, + ] + ) + ], + ) + + to_file_from_file_test(params) + + +def assert_ignore_space(expected: str, actual: str): + """For expression comparison, ignore spaces""" + assert expected.replace(" ", "") == actual.replace(" ", "") + + +def test_udf_generator(): + with SI_unit_system: + params = SimulationParams( + operating_condition=AerospaceCondition( + velocity_magnitude=10 * u.m / u.s, + reference_velocity_magnitude=10 * u.m / u.s, + ), + private_attribute_asset_cache=AssetCache(project_length_unit=10 * u.m), + ) + # Scalar output + result = user_variable_to_udf( + solution.mut.in_unit(new_name="mut_in_Imperial", new_unit="ft**2/s"), input_params=params + ) + # velocity scale = 340.29400580821283 m/s, length scale = 10m, mut_scale = 3402.94 m**2/s -> 36628.94131344 *ft**2/s + pattern = r"mut_in_Imperial = \(mut \* ([\d\.]+)\);" + match = re.match(pattern, result.expression) + assert match is not None, f"Expression '{result.expression}' does not match expected pattern" + conversion_factor = float(match.group(1)) + # Note: Ranged since the value depends on the OS. + assert ( + 36628.941 < conversion_factor < 36628.942 + ), f"Conversion factor {conversion_factor} outside expected range" + + # Vector output + result = user_variable_to_udf( + solution.velocity.in_unit(new_name="velocity_in_SI", new_unit="m/s"), input_params=params + ) + # velocity scale = 340.29400580821283 m/s + pattern = r"velocity_in_SI\[0\] = \(solution\.velocity\[0\] \* ([\d\.]+)\); velocity_in_SI\[1\] = \(solution\.velocity\[1\] \* \1\); velocity_in_SI\[2\] = \(solution\.velocity\[2\] \* \1\);" + match = re.match(pattern, result.expression) + assert match is not None, f"Expression '{result.expression}' does not match expected pattern" + conversion_factor = float(match.group(1)) + assert ( + 340.294005 < conversion_factor < 340.2940064 + ), f"Conversion factor {conversion_factor} outside expected range" + + vel_cross_vec = UserVariable( + name="vel_cross_vec", value=math.cross(solution.velocity, [1, 2, 3] * u.cm) + ).in_unit(new_unit="m*ft/s/min") + result = user_variable_to_udf(vel_cross_vec, input_params=params) + # velocity scale = 340.29400580821283 m/s --> 22795277.63562985 m*ft/s/min + pattern = r"vel_cross_vec\[0\] = \(\(\(\(solution\.velocity\[1\] \* 3\) \* 0\.001\) - \(\(solution\.velocity\[2\] \* 2\) \* 0\.001\)\) \* ([\d\.]+)\); vel_cross_vec\[1\] = \(\(\(\(solution\.velocity\[2\] \* 1\) \* 0\.001\) - \(\(solution\.velocity\[0\] \* 3\) \* 0\.001\)\) \* \1\); vel_cross_vec\[2\] = \(\(\(\(solution\.velocity\[0\] \* 2\) \* 0\.001\) - \(\(solution\.velocity\[1\] \* 1\) \* 0\.001\)\) \* \1\);" + match = re.match(pattern, result.expression) + assert match is not None, f"Expression '{result.expression}' does not match expected pattern" + conversion_factor = float(match.group(1)) + assert ( + 22795277.635 < conversion_factor < 22795278 + 1e-8 + ), f"Conversion factor {conversion_factor} outside expected range" From a6138069fcccfeef815e4342d7a357d3adcbec69 Mon Sep 17 00:00:00 2001 From: Ben <106089368+benflexcompute@users.noreply.github.com> Date: Tue, 10 Jun 2025 11:07:16 -0400 Subject: [PATCH 21/34] Enabled all output types to use UserVariable (#1148) --- .../component/simulation/outputs/outputs.py | 14 +- .../translator/solver_translator.py | 16 +- .../simulation/user_code/core/types.py | 5 +- .../translator/test_output_translation.py | 283 ++++++++++-------- 4 files changed, 174 insertions(+), 144 deletions(-) diff --git a/flow360/component/simulation/outputs/outputs.py b/flow360/component/simulation/outputs/outputs.py index 665866366..3f9577d8c 100644 --- a/flow360/component/simulation/outputs/outputs.py +++ b/flow360/component/simulation/outputs/outputs.py @@ -194,7 +194,7 @@ class SurfaceOutput(_AnimationAndFileFormatSettings): + "Will choose the value of the last instance of this option of the same output type " + "(:class:`SurfaceOutput` or :class:`TimeAverageSurfaceOutput`) in the output list.", ) - output_fields: UniqueItemList[Union[SurfaceFieldNames, str]] = pd.Field( + output_fields: UniqueItemList[Union[SurfaceFieldNames, str, UserVariable]] = pd.Field( description="List of output variables. Including :ref:`universal output variables`," + " :ref:`variables specific to SurfaceOutput` and :class:`UserDefinedField`." ) @@ -329,7 +329,7 @@ class SliceOutput(_AnimationAndFileFormatSettings): alias="slices", description="List of output :class:`~flow360.Slice` entities.", ) - output_fields: UniqueItemList[Union[SliceFieldNames, str]] = pd.Field( + output_fields: UniqueItemList[Union[SliceFieldNames, str, UserVariable]] = pd.Field( description="List of output variables. Including :ref:`universal output variables`," " :ref:`variables specific to SliceOutput`" " and :class:`UserDefinedField`." @@ -415,7 +415,7 @@ class IsosurfaceOutput(_AnimationAndFileFormatSettings): alias="isosurfaces", description="List of :class:`~flow360.Isosurface` entities.", ) - output_fields: UniqueItemList[Union[CommonFieldNames, str]] = pd.Field( + output_fields: UniqueItemList[Union[CommonFieldNames, str, UserVariable]] = pd.Field( description="List of output variables. Including " ":ref:`universal output variables` and :class:`UserDefinedField`." ) @@ -452,7 +452,7 @@ class SurfaceIntegralOutput(_OutputBase): alias="surfaces", description="List of boundaries where the surface integral will be calculated.", ) - output_fields: UniqueItemList[str] = pd.Field( + output_fields: UniqueItemList[Union[str, UserVariable]] = pd.Field( description="List of output variables, only the :class:`UserDefinedField` is allowed." ) output_type: Literal["SurfaceIntegralOutput"] = pd.Field("SurfaceIntegralOutput", frozen=True) @@ -516,7 +516,7 @@ class ProbeOutput(_OutputBase): + "monitor group. :class:`~flow360.PointArray` is used to " + "define monitored points along a line.", ) - output_fields: UniqueItemList[Union[CommonFieldNames, str]] = pd.Field( + output_fields: UniqueItemList[Union[CommonFieldNames, str, UserVariable]] = pd.Field( description="List of output fields. Including :ref:`universal output variables`" " and :class:`UserDefinedField`." ) @@ -580,7 +580,7 @@ class SurfaceProbeOutput(Flow360BaseModel): + "entities belonging to this monitor group." ) - output_fields: UniqueItemList[Union[SurfaceFieldNames, str]] = pd.Field( + output_fields: UniqueItemList[Union[SurfaceFieldNames, str, UserVariable]] = pd.Field( description="List of output variables. Including :ref:`universal output variables`," " :ref:`variables specific to SurfaceOutput` and :class:`UserDefinedField`." ) @@ -609,7 +609,7 @@ class SurfaceSliceOutput(_AnimationAndFileFormatSettings): output_format: Literal["paraview"] = pd.Field(default="paraview") - output_fields: UniqueItemList[Union[SurfaceFieldNames, str]] = pd.Field( + output_fields: UniqueItemList[Union[SurfaceFieldNames, str, UserVariable]] = pd.Field( description="List of output variables. Including :ref:`universal output variables`," " :ref:`variables specific to SurfaceOutput` and :class:`UserDefinedField`." ) diff --git a/flow360/component/simulation/translator/solver_translator.py b/flow360/component/simulation/translator/solver_translator.py index bc530cc2c..e690b79cf 100644 --- a/flow360/component/simulation/translator/solver_translator.py +++ b/flow360/component/simulation/translator/solver_translator.py @@ -249,8 +249,10 @@ def translate_output_fields( for output_field in output_model.output_fields.items: if isinstance(output_field, UserVariable): + # Remove the UserVariable object and add its name output_fields.append(output_field.name) - + # Filter out the UserVariable Dicts + output_fields = [item for item in output_fields if isinstance(item, str)] return {"outputFields": output_fields} @@ -382,6 +384,8 @@ def translate_volume_output( ).items: if isinstance(output_field, UserVariable): output_fields.append(output_field.name) + # Filter out the UserVariable Dicts + output_fields = [item for item in output_fields if isinstance(item, str)] volume_output.update( { "outputFields": output_fields, @@ -673,18 +677,18 @@ def process_output_fields_for_udf(input_params: SimulationParams): if udf_expression: generated_udfs.append(UserDefinedField(name=field_name, expression=udf_expression)) + # UserVariable handling: + user_variable_udfs = {} if input_params.outputs: - # UserVariable handling: - user_variable_udfs = set() for output in input_params.outputs: if not hasattr(output, "output_fields") or not output.output_fields: continue for output_field in output.output_fields.items: if not isinstance(output_field, UserVariable): continue - user_variable_udfs.add(user_variable_to_udf(output_field, input_params)) - - return generated_udfs + udf_from_user_variable = user_variable_to_udf(output_field, input_params) + user_variable_udfs[udf_from_user_variable.name] = udf_from_user_variable + return generated_udfs + list(user_variable_udfs.values()) def translate_streamline_output(output_params: list): diff --git a/flow360/component/simulation/user_code/core/types.py b/flow360/component/simulation/user_code/core/types.py index 97207f5ba..5568db125 100644 --- a/flow360/component/simulation/user_code/core/types.py +++ b/flow360/component/simulation/user_code/core/types.py @@ -308,14 +308,15 @@ def update_context(self): default_context.set_alias(self.name, self.solver_name) return self - def in_unit(self, new_name: str, new_unit: str = None): + def in_unit(self, new_name: str, new_unit: Union[str, Unit] = None): """ Return a UserVariable that will generate results in the new_unit. If new_unit is not specified then the unit will be determined by the unit system. """ - + if isinstance(new_unit, Unit): + new_unit = str(new_unit) new_variable = UserVariable(name=new_name, value=Expression(expression=self.name)) new_variable.value.output_units = new_unit # pylint:disable=assigning-non-slot return new_variable diff --git a/tests/simulation/translator/test_output_translation.py b/tests/simulation/translator/test_output_translation.py index 605607b0e..03ccf2ecd 100644 --- a/tests/simulation/translator/test_output_translation.py +++ b/tests/simulation/translator/test_output_translation.py @@ -3,8 +3,11 @@ import pytest import flow360.component.simulation.units as u +from flow360.component.simulation.framework.updater_utils import compare_values +from flow360.component.simulation.models.material import Water from flow360.component.simulation.operating_condition.operating_condition import ( AerospaceCondition, + LiquidOperatingCondition, ) from flow360.component.simulation.outputs.output_entities import ( Point, @@ -39,16 +42,29 @@ translate_output, ) from flow360.component.simulation.unit_system import SI_unit_system +from flow360.component.simulation.user_code.variables import solution @pytest.fixture() -def volume_output_config(): +def vel_in_km_per_hr(): + return solution.velocity.in_unit(new_name="velocity_in_km_per_hr", new_unit=u.km / u.hr) + + +@pytest.fixture() +def volume_output_config(vel_in_km_per_hr): return ( VolumeOutput( frequency=1, frequency_offset=2, output_format="both", - output_fields=["primitiveVars", "betMetrics", "qcriterion", "velocity", "vorticity"], + output_fields=[ + "primitiveVars", + "betMetrics", + "qcriterion", + "velocity", + "vorticity", + vel_in_km_per_hr, + ], ), { "animationFrequency": 1, @@ -64,6 +80,7 @@ def volume_output_config(): "velocity_magnitude", "vorticity", "vorticityMagnitude", + "velocity_in_km_per_hr", ], "outputFormat": "paraview,tecplot", "startAverageIntegrationStep": -1, @@ -72,13 +89,20 @@ def volume_output_config(): @pytest.fixture() -def avg_volume_output_config(): +def avg_volume_output_config(vel_in_km_per_hr): return ( TimeAverageVolumeOutput( frequency=11, frequency_offset=12, output_format="both", - output_fields=["primitiveVars", "betMetrics", "qcriterion", "velocity", "vorticity"], + output_fields=[ + "primitiveVars", + "betMetrics", + "qcriterion", + "velocity", + "vorticity", + vel_in_km_per_hr, + ], start_step=1, ), { @@ -95,6 +119,7 @@ def avg_volume_output_config(): "velocity_magnitude", "vorticity", "vorticityMagnitude", + "velocity_in_km_per_hr", ], "outputFormat": "paraview,tecplot", "startAverageIntegrationStep": 1, @@ -103,7 +128,6 @@ def avg_volume_output_config(): def test_volume_output(volume_output_config, avg_volume_output_config): - import json ##:: volumeOutput only with SI_unit_system: @@ -145,21 +169,22 @@ def test_volume_output(volume_output_config, avg_volume_output_config): "velocity_magnitude", "vorticity", "vorticityMagnitude", + "velocity_in_km_per_hr", ], "outputFormat": "paraview,tecplot", "startAverageIntegrationStep": 1, } } - assert sorted(ref["volumeOutput"].items()) == sorted(translated["volumeOutput"].items()) + assert compare_values(ref["volumeOutput"], translated["volumeOutput"]) @pytest.fixture() -def surface_output_config(): +def surface_output_config(vel_in_km_per_hr): return ( [ SurfaceOutput( # Local entities=[Surface(name="surface1"), Surface(name="surface2")], - output_fields=["Cp"], + output_fields=["Cp", vel_in_km_per_hr], output_format="tecplot", frequency=123, frequency_offset=321, @@ -168,7 +193,7 @@ def surface_output_config(): entities=[Surface(name="surface11"), Surface(name="surface22")], frequency=123, frequency_offset=321, - output_fields=["T", "velocity", "vorticity"], + output_fields=["T", "velocity", "vorticity", vel_in_km_per_hr], output_format="tecplot", ), ], @@ -182,7 +207,7 @@ def surface_output_config(): "outputFormat": "tecplot", "startAverageIntegrationStep": -1, "surfaces": { - "surface1": {"outputFields": ["Cp"]}, + "surface1": {"outputFields": ["Cp", "velocity_in_km_per_hr"]}, "surface11": { "outputFields": [ "T", @@ -190,9 +215,10 @@ def surface_output_config(): "velocity_magnitude", "vorticity", "vorticityMagnitude", + "velocity_in_km_per_hr", ] }, - "surface2": {"outputFields": ["Cp"]}, + "surface2": {"outputFields": ["Cp", "velocity_in_km_per_hr"]}, "surface22": { "outputFields": [ "T", @@ -200,6 +226,7 @@ def surface_output_config(): "velocity_magnitude", "vorticity", "vorticityMagnitude", + "velocity_in_km_per_hr", ] }, }, @@ -209,15 +236,15 @@ def surface_output_config(): @pytest.fixture() -def avg_surface_output_config(): +def avg_surface_output_config(vel_in_km_per_hr): return [ TimeAverageSurfaceOutput( # Local entities=[Surface(name="surface1"), Surface(name="surface2")], - output_fields=["Cp"], + output_fields=["Cp", vel_in_km_per_hr], ), TimeAverageSurfaceOutput( # Local entities=[Surface(name="surface3")], - output_fields=["T"], + output_fields=["T", vel_in_km_per_hr], ), ] @@ -251,7 +278,7 @@ def test_surface_output( "outputFormat": "paraview", "startAverageIntegrationStep": -1, "surfaces": { - "surface1": {"outputFields": ["Cp"]}, + "surface1": {"outputFields": ["Cp", "velocity_in_km_per_hr"]}, "surface11": { "outputFields": [ "T", @@ -259,9 +286,10 @@ def test_surface_output( "velocity_magnitude", "vorticity", "vorticityMagnitude", + "velocity_in_km_per_hr", ] }, - "surface2": {"outputFields": ["Cp"]}, + "surface2": {"outputFields": ["Cp", "velocity_in_km_per_hr"]}, "surface22": { "outputFields": [ "T", @@ -269,9 +297,10 @@ def test_surface_output( "velocity_magnitude", "vorticity", "vorticityMagnitude", + "velocity_in_km_per_hr", ] }, - "surface3": {"outputFields": ["T"]}, + "surface3": {"outputFields": ["T", "velocity_in_km_per_hr"]}, }, "writeSingleFile": False, } @@ -279,7 +308,7 @@ def test_surface_output( @pytest.fixture() -def sliceoutput_config(): +def sliceoutput_config(vel_in_km_per_hr): return ( [ SliceOutput( # Local @@ -295,7 +324,13 @@ def sliceoutput_config(): origin=(0.12, 0.13, 0.14) * u.m, ), ], - output_fields=["Cp", "velocity", "vorticity", "vorticityMagnitude"], + output_fields=[ + "Cp", + "velocity", + "vorticity", + "vorticityMagnitude", + vel_in_km_per_hr, + ], frequency=33, frequency_offset=22, output_format="tecplot", @@ -316,7 +351,7 @@ def sliceoutput_config(): frequency=33, frequency_offset=22, output_format="tecplot", - output_fields=["T", "primitiveVars"], + output_fields=["T", "primitiveVars", vel_in_km_per_hr], ), ], { @@ -330,12 +365,12 @@ def sliceoutput_config(): "outputFormat": "tecplot", "slices": { "slice01": { - "outputFields": ["T", "primitiveVars"], + "outputFields": ["T", "primitiveVars", "velocity_in_km_per_hr"], "sliceNormal": [1.0, 0.0, 0.0], "sliceOrigin": [10.02, 10.03, 10.04], }, "slice02": { - "outputFields": ["T", "primitiveVars"], + "outputFields": ["T", "primitiveVars", "velocity_in_km_per_hr"], "sliceNormal": [0.6, 0.0, 0.8], "sliceOrigin": [6.12, 6.13, 6.14], }, @@ -346,6 +381,7 @@ def sliceoutput_config(): "velocity_magnitude", "vorticity", "vorticityMagnitude", + "velocity_in_km_per_hr", ], "sliceNormal": [0.0, 1.0, 0.0], "sliceOrigin": [0.02, 0.03, 0.04], @@ -357,6 +393,7 @@ def sliceoutput_config(): "velocity_magnitude", "vorticity", "vorticityMagnitude", + "velocity_in_km_per_hr", ], "sliceNormal": [0.6, 0.8, 0.0], "sliceOrigin": [0.12, 0.13, 0.14], @@ -380,7 +417,7 @@ def test_slice_output( @pytest.fixture() -def isosurface_output_config(): +def isosurface_output_config(vel_in_km_per_hr): return ( [ IsosurfaceOutput( # Local @@ -406,7 +443,7 @@ def isosurface_output_config(): field="vorticity_z", ), ], - output_fields=["Cp"], + output_fields=["Cp", vel_in_km_per_hr], frequency=332, frequency_offset=222, output_format="paraview", @@ -427,7 +464,7 @@ def isosurface_output_config(): frequency=332, frequency_offset=222, output_format="paraview", - output_fields=["T", "primitiveVars"], + output_fields=["T", "primitiveVars", vel_in_km_per_hr], ), ], { @@ -435,32 +472,32 @@ def isosurface_output_config(): "animationFrequencyOffset": 222, "isoSurfaces": { "isosurface 01": { - "outputFields": ["T", "primitiveVars"], + "outputFields": ["T", "primitiveVars", "velocity_in_km_per_hr"], "surfaceField": "nuHat", "surfaceFieldMagnitude": 0.0001, }, "isosurface 02": { - "outputFields": ["T", "primitiveVars"], + "outputFields": ["T", "primitiveVars", "velocity_in_km_per_hr"], "surfaceField": "qcriterion", "surfaceFieldMagnitude": 10000.0, }, "isosurface 10": { - "outputFields": ["Cp"], + "outputFields": ["Cp", "velocity_in_km_per_hr"], "surfaceField": "T", "surfaceFieldMagnitude": 0.0001, }, "isosurface 14": { - "outputFields": ["Cp"], + "outputFields": ["Cp", "velocity_in_km_per_hr"], "surfaceField": "qcriterion", "surfaceFieldMagnitude": 20.431, }, "isosurface 15": { - "outputFields": ["Cp"], + "outputFields": ["Cp", "velocity_in_km_per_hr"], "surfaceField": "velocity_x", "surfaceFieldMagnitude": 0.1, }, "isosurface 16": { - "outputFields": ["Cp"], + "outputFields": ["Cp", "velocity_in_km_per_hr"], "surfaceField": "vorticity_z", "surfaceFieldMagnitude": 0.2, }, @@ -486,7 +523,7 @@ def test_isosurface_output( @pytest.fixture() -def probe_output_config(): +def probe_output_config(vel_in_km_per_hr): return ( [ ProbeOutput( # Local @@ -501,7 +538,7 @@ def probe_output_config(): location=[0.0001, 0.02, 0.03] * u.m, ), ], - output_fields=["primitiveVars", "Cp"], + output_fields=["primitiveVars", "Cp", vel_in_km_per_hr], ), ProbeOutput( # Local name="prb 12", @@ -511,7 +548,7 @@ def probe_output_config(): location=[10, 10.02, 10.03] * u.cm, ), ], - output_fields=["primitiveVars", "Cp"], + output_fields=["primitiveVars", "Cp", vel_in_km_per_hr], ), TimeAverageProbeOutput( # Local name="prb average", @@ -521,7 +558,7 @@ def probe_output_config(): location=[10, 10.02, 10.03] * u.cm, ), ], - output_fields=["primitiveVars", "Cp", "T"], + output_fields=["primitiveVars", "Cp", "T", vel_in_km_per_hr], frequency=10, ), ], @@ -534,7 +571,7 @@ def probe_output_config(): "start": [[1e-2, 1.02e-2, 0.0003], [0.0001, 0.02, 0.03]], "end": [[1e-2, 1.02e-2, 0.0003], [0.0001, 0.02, 0.03]], "numberOfPoints": [1, 1], - "outputFields": ["primitiveVars", "Cp"], + "outputFields": ["primitiveVars", "Cp", "velocity_in_km_per_hr"], "type": "lineProbe", }, "prb 12": { @@ -544,7 +581,7 @@ def probe_output_config(): "start": [[10e-2, 10.02e-2, 10.03e-2]], "end": [[10e-2, 10.02e-2, 10.03e-2]], "numberOfPoints": [1], - "outputFields": ["primitiveVars", "Cp"], + "outputFields": ["primitiveVars", "Cp", "velocity_in_km_per_hr"], "type": "lineProbe", }, "prb average": { @@ -557,7 +594,7 @@ def probe_output_config(): "start": [[10e-2, 10.02e-2, 10.03e-2]], "end": [[10e-2, 10.02e-2, 10.03e-2]], "numberOfPoints": [1], - "outputFields": ["primitiveVars", "Cp", "T"], + "outputFields": ["primitiveVars", "Cp", "T", "velocity_in_km_per_hr"], "type": "lineProbe", }, }, @@ -567,7 +604,7 @@ def probe_output_config(): @pytest.fixture() -def probe_output_with_point_array(): +def probe_output_with_point_array(vel_in_km_per_hr): return ( [ ProbeOutput( @@ -586,7 +623,7 @@ def probe_output_with_point_array(): number_of_points=7, ), ], - output_fields=["primitiveVars", "Cp"], + output_fields=["primitiveVars", "Cp", vel_in_km_per_hr], ), ProbeOutput( name="prb point", @@ -600,7 +637,7 @@ def probe_output_with_point_array(): location=[0.0001, 0.02, 0.03] * u.m, ), ], - output_fields=["primitiveVars", "Cp"], + output_fields=["primitiveVars", "Cp", vel_in_km_per_hr], ), ProbeOutput( name="prb mix", @@ -616,7 +653,7 @@ def probe_output_with_point_array(): number_of_points=5, ), ], - output_fields=["primitiveVars", "Cp"], + output_fields=["primitiveVars", "Cp", vel_in_km_per_hr], ), ], { @@ -625,7 +662,7 @@ def probe_output_with_point_array(): "start": [[0.1, 0.2, 0.3], [0.1, 0.2, 0.3]], "end": [[1.1, 1.2, 1.3], [1.3, 1.5, 1.7]], "numberOfPoints": [5, 7], - "outputFields": ["primitiveVars", "Cp"], + "outputFields": ["primitiveVars", "Cp", "velocity_in_km_per_hr"], "animationFrequency": 1, "animationFrequencyOffset": 0, "computeTimeAverages": False, @@ -635,7 +672,7 @@ def probe_output_with_point_array(): "start": [[1e-2, 1.02e-2, 0.0003], [0.0001, 0.02, 0.03]], "end": [[1e-2, 1.02e-2, 0.0003], [0.0001, 0.02, 0.03]], "numberOfPoints": [1, 1], - "outputFields": ["primitiveVars", "Cp"], + "outputFields": ["primitiveVars", "Cp", "velocity_in_km_per_hr"], "animationFrequency": 1, "animationFrequencyOffset": 0, "computeTimeAverages": False, @@ -645,7 +682,7 @@ def probe_output_with_point_array(): "start": [[0.1, 0.2, 0.3], [1e-2, 1.02e-2, 0.0003]], "end": [[1.1, 1.2, 1.3], [1e-2, 1.02e-2, 0.0003]], "numberOfPoints": [5, 1], - "outputFields": ["primitiveVars", "Cp"], + "outputFields": ["primitiveVars", "Cp", "velocity_in_km_per_hr"], "animationFrequency": 1, "animationFrequencyOffset": 0, "computeTimeAverages": False, @@ -658,7 +695,7 @@ def probe_output_with_point_array(): @pytest.fixture() -def surface_integral_output_config(): +def surface_integral_output_config(vel_in_km_per_hr): return ( [ SurfaceIntegralOutput( # Local @@ -667,7 +704,7 @@ def surface_integral_output_config(): Surface(name="surface1", private_attribute_full_name="zoneName/surface1"), Surface(name="surface2"), ], - output_fields=["My_field_1"], + output_fields=["My_field_1", vel_in_km_per_hr], ), SurfaceIntegralOutput( name="prb 122", @@ -675,7 +712,7 @@ def surface_integral_output_config(): Surface(name="surface21"), Surface(name="surface22"), ], - output_fields=["My_field_2"], + output_fields=["My_field_2", vel_in_km_per_hr], ), # Local ], { @@ -684,7 +721,7 @@ def surface_integral_output_config(): "animationFrequency": 1, "animationFrequencyOffset": 0, "computeTimeAverages": False, - "outputFields": ["My_field_1"], + "outputFields": ["My_field_1", "velocity_in_km_per_hr"], "surfaces": ["zoneName/surface1", "surface2"], "type": "surfaceIntegral", }, @@ -692,7 +729,7 @@ def surface_integral_output_config(): "animationFrequency": 1, "animationFrequencyOffset": 0, "computeTimeAverages": False, - "outputFields": ["My_field_2"], + "outputFields": ["My_field_2", "velocity_in_km_per_hr"], "surfaces": ["surface21", "surface22"], "type": "surfaceIntegral", }, @@ -702,7 +739,7 @@ def surface_integral_output_config(): ) -def test_surface_probe_output(): +def test_surface_probe_output(vel_in_km_per_hr): param_with_ref = ( [ SurfaceProbeOutput( @@ -715,7 +752,7 @@ def test_surface_probe_output(): Surface(name="surface1", private_attribute_full_name="zoneA/surface1"), Surface(name="surface2", private_attribute_full_name="zoneA/surface2"), ], - output_fields=["Cp", "Cf"], + output_fields=["Cp", "Cf", vel_in_km_per_hr], ), TimeAverageSurfaceProbeOutput( name="SP-2", @@ -728,7 +765,7 @@ def test_surface_probe_output(): Surface(name="surface1", private_attribute_full_name="zoneB/surface1"), Surface(name="surface2", private_attribute_full_name="zoneB/surface2"), ], - output_fields=["Mach", "primitiveVars", "yPlus"], + output_fields=["Mach", "primitiveVars", "yPlus", vel_in_km_per_hr], ), SurfaceProbeOutput( name="SP-3", @@ -750,7 +787,7 @@ def test_surface_probe_output(): Surface(name="surface1", private_attribute_full_name="zoneC/surface1"), Surface(name="surface2", private_attribute_full_name="zoneC/surface2"), ], - output_fields=["Mach", "primitiveVars", "yPlus", "my_own_field"], + output_fields=["Mach", "primitiveVars", "yPlus", "my_own_field", vel_in_km_per_hr], ), ], { @@ -759,7 +796,7 @@ def test_surface_probe_output(): "animationFrequency": 1, "animationFrequencyOffset": 0, "computeTimeAverages": False, - "outputFields": ["Cp", "Cf"], + "outputFields": ["Cp", "Cf", "velocity_in_km_per_hr"], "surfacePatches": ["zoneA/surface1", "zoneA/surface2"], "start": [[1e-2, 1.02e-2, 0.0003], [2.0, 1.01, 0.03]], "end": [[1e-2, 1.02e-2, 0.0003], [2.0, 1.01, 0.03]], @@ -773,7 +810,7 @@ def test_surface_probe_output(): "animationFrequencyTimeAverageOffset": 0, "startAverageIntegrationStep": -1, "computeTimeAverages": True, - "outputFields": ["Mach", "primitiveVars", "yPlus"], + "outputFields": ["Mach", "primitiveVars", "yPlus", "velocity_in_km_per_hr"], "surfacePatches": ["zoneB/surface1", "zoneB/surface2"], "start": [ [1e-2, 1.02e-2, 0.0003], @@ -792,7 +829,13 @@ def test_surface_probe_output(): "animationFrequency": 1, "animationFrequencyOffset": 0, "computeTimeAverages": False, - "outputFields": ["Mach", "primitiveVars", "yPlus", "my_own_field"], + "outputFields": [ + "Mach", + "primitiveVars", + "yPlus", + "my_own_field", + "velocity_in_km_per_hr", + ], "surfacePatches": ["zoneC/surface1", "zoneC/surface2"], "start": [[0.1, 0.2, 0.3], [0.1, 0.2, 0.3]], "end": [[1.1, 1.2, 1.3], [1.3, 1.5, 1.7]], @@ -888,14 +931,14 @@ def test_monitor_output( "start": [[1e-2, 1.02e-2, 0.0003], [0.0001, 0.02, 0.03]], "end": [[1e-2, 1.02e-2, 0.0003], [0.0001, 0.02, 0.03]], "numberOfPoints": [1, 1], - "outputFields": ["primitiveVars", "Cp"], + "outputFields": ["primitiveVars", "Cp", "velocity_in_km_per_hr"], "type": "lineProbe", }, "prb 110": { "animationFrequency": 1, "animationFrequencyOffset": 0, "computeTimeAverages": False, - "outputFields": ["My_field_1"], + "outputFields": ["My_field_1", "velocity_in_km_per_hr"], "surfaces": ["zoneName/surface1", "surface2"], "type": "surfaceIntegral", }, @@ -906,14 +949,14 @@ def test_monitor_output( "start": [[10e-2, 10.02e-2, 10.03e-2]], "end": [[10e-2, 10.02e-2, 10.03e-2]], "numberOfPoints": [1], - "outputFields": ["primitiveVars", "Cp"], + "outputFields": ["primitiveVars", "Cp", "velocity_in_km_per_hr"], "type": "lineProbe", }, "prb 122": { "animationFrequency": 1, "animationFrequencyOffset": 0, "computeTimeAverages": False, - "outputFields": ["My_field_2"], + "outputFields": ["My_field_2", "velocity_in_km_per_hr"], "surfaces": ["surface21", "surface22"], "type": "surfaceIntegral", }, @@ -927,7 +970,7 @@ def test_monitor_output( "start": [[10e-2, 10.02e-2, 10.03e-2]], "end": [[10e-2, 10.02e-2, 10.03e-2]], "numberOfPoints": [1], - "outputFields": ["primitiveVars", "Cp", "T"], + "outputFields": ["primitiveVars", "Cp", "T", "velocity_in_km_per_hr"], "type": "lineProbe", }, }, @@ -1015,7 +1058,7 @@ def test_acoustic_output(aeroacoustic_output_config, aeroacoustic_output_permeab ) -def test_surface_slice_output(): +def test_surface_slice_output(vel_in_km_per_hr): param_with_ref = ( [ SurfaceSliceOutput( @@ -1028,7 +1071,7 @@ def test_surface_slice_output(): Surface(name="surface1", private_attribute_full_name="zoneA/surface1"), Surface(name="surface2", private_attribute_full_name="zoneA/surface2"), ], - output_fields=["Cp", "Cf", "primitiveVars"], + output_fields=["Cp", "Cf", "primitiveVars", vel_in_km_per_hr], frequency=2, ), SurfaceSliceOutput( @@ -1042,7 +1085,7 @@ def test_surface_slice_output(): Surface(name="surface1", private_attribute_full_name="zoneB/surface1"), Surface(name="surface2", private_attribute_full_name="zoneB/surface2"), ], - output_fields=["Mach", "primitiveVars", "yPlus"], + output_fields=["Mach", "primitiveVars", "yPlus", vel_in_km_per_hr], ), ], { @@ -1055,7 +1098,7 @@ def test_surface_slice_output(): "name": "S1", "sliceOrigin": [0.01, 0.0102, 0.0003], "sliceNormal": [0.0, 1.0, 0.0], - "outputFields": ["Cp", "Cf", "primitiveVars"], + "outputFields": ["Cp", "Cf", "primitiveVars", "velocity_in_km_per_hr"], "surfacePatches": ["zoneA/surface1", "zoneA/surface2"], "animationFrequency": 1, "animationFrequencyOffset": 0, @@ -1065,7 +1108,7 @@ def test_surface_slice_output(): "name": "S3", "sliceOrigin": [0.01, 0.0101, 0.0003], "sliceNormal": [0.0, 1.0, 0.0], - "outputFields": ["Cp", "Cf", "primitiveVars"], + "outputFields": ["Cp", "Cf", "primitiveVars", "velocity_in_km_per_hr"], "surfacePatches": ["zoneA/surface1", "zoneA/surface2"], "animationFrequency": 1, "animationFrequencyOffset": 0, @@ -1075,7 +1118,7 @@ def test_surface_slice_output(): "name": "P1", "sliceOrigin": [0.01, 0.0102, 0.0003], "sliceNormal": [0.0, 0.0, 1.0], - "outputFields": ["Mach", "primitiveVars", "yPlus"], + "outputFields": ["Mach", "primitiveVars", "yPlus", "velocity_in_km_per_hr"], "surfacePatches": ["zoneB/surface1", "zoneB/surface2"], "animationFrequency": 1, "animationFrequencyOffset": 0, @@ -1085,7 +1128,7 @@ def test_surface_slice_output(): "name": "P2", "sliceOrigin": [2.0, 1.01, 0.03], "sliceNormal": [0.0, 0.0, -1.0], - "outputFields": ["Mach", "primitiveVars", "yPlus"], + "outputFields": ["Mach", "primitiveVars", "yPlus", "velocity_in_km_per_hr"], "surfacePatches": ["zoneB/surface1", "zoneB/surface2"], "animationFrequency": 1, "animationFrequencyOffset": 0, @@ -1095,7 +1138,7 @@ def test_surface_slice_output(): "name": "P3", "sliceOrigin": [3.0, 1.02, 0.03], "sliceNormal": [0.0, 0.0, 1.0], - "outputFields": ["Mach", "primitiveVars", "yPlus"], + "outputFields": ["Mach", "primitiveVars", "yPlus", "velocity_in_km_per_hr"], "surfacePatches": ["zoneB/surface1", "zoneB/surface2"], "animationFrequency": 1, "animationFrequencyOffset": 0, @@ -1111,17 +1154,21 @@ def test_surface_slice_output(): translated = {"boundaries": {}} translated = translate_output(param, translated) - print(json.dumps(translated, indent=4)) assert sorted(param_with_ref[1].items()) == sorted(translated["surfaceSliceOutput"].items()) -def test_dimensioned_output_fields_translation(): +def test_dimensioned_output_fields_translation(vel_in_km_per_hr): """Test the translation of output fields from user-facing fields to solver fields.""" with SI_unit_system: + water = Water( + name="h2o", density=1000 * u.kg / u.m**3, dynamic_viscosity=0.001 * u.kg / u.m / u.s + ) param = SimulationParams( - operating_condition=AerospaceCondition( - velocity_magnitude=100.0 * u.m / u.s, + operating_condition=LiquidOperatingCondition( + velocity_magnitude=50 * u.m / u.s, + reference_velocity_magnitude=100 * u.m / u.s, + material=water, ), outputs=[ VolumeOutput( @@ -1137,6 +1184,7 @@ def test_dimensioned_output_fields_translation(): "velocity_z_m_per_s", "pressure", "pressure_pa", + vel_in_km_per_hr, ], ), SurfaceOutput( @@ -1176,6 +1224,7 @@ def test_dimensioned_output_fields_translation(): "velocity_z_m_per_s", "pressure", "pressure_pa", + "velocity_in_km_per_hr", ] expected_fields_s = [ @@ -1190,89 +1239,65 @@ def test_dimensioned_output_fields_translation(): ref = { "userDefinedFields": [ - {"name": "pressure", "expression": "pressure = primitiveVars[4];"}, + {"name": "my_field", "expression": "1+1"}, { - "name": "velocity_m_per_s", - "expression": "double velocity[3];" - "velocity[0] = primitiveVars[1];" - "velocity[1] = primitiveVars[2];" - "velocity[2] = primitiveVars[3];" - "velocity_m_per_s[0] = velocity[0] * 340.29400580821283;" - "velocity_m_per_s[1] = velocity[1] * 340.29400580821283;" - "velocity_m_per_s[2] = velocity[2] * 340.29400580821283;", - }, - { - "name": "wall_shear_stress_magnitude", - "expression": "wall_shear_stress_magnitude = magnitude(wallShearStress);", + "name": "pressure", + "expression": "double gamma = 1.4;pressure = (usingLiquidAsMaterial) ? (primitiveVars[4] - 1.0 / gamma) * (velocityScale * velocityScale) : primitiveVars[4];", }, { - "name": "velocity_magnitude", - "expression": "double velocity[3]" - "velocity[0] = primitiveVars[1]" - "velocity[1] = primitiveVars[2]" - "velocity[2] = primitiveVars[3]" - "velocity_magnitude = magnitude(velocity)", + "name": "pressure_pa", + "expression": "double pressure;double gamma = 1.4;pressure = (usingLiquidAsMaterial) ? (primitiveVars[4] - 1.0 / gamma) * (velocityScale * velocityScale) : primitiveVars[4];pressure_pa = pressure * 999999999.9999999;", }, { "name": "velocity", - "expression": "velocity[0] = primitiveVars[1]" - "velocity[1] = primitiveVars[2]" - "velocity[2] = primitiveVars[3]", + "expression": "velocity[0] = primitiveVars[1] * velocityScale;velocity[1] = primitiveVars[2] * velocityScale;velocity[2] = primitiveVars[3] * velocityScale;", }, { - "name": "wall_shear_stress_magnitude_pa", - "expression": "double wall_shear_stress_magnitude" - "wall_shear_stress_magnitude = magnitude(wallShearStress)" - "wall_shear_stress_magnitude_pa = wall_shear_stress_magnitude * 141855.012726525", + "name": "velocity_in_km_per_hr", + "expression": "velocity_in_km_per_hr[0] = (solution.velocity[0] * 3600.0); velocity_in_km_per_hr[1] = (solution.velocity[1] * 3600.0); velocity_in_km_per_hr[2] = (solution.velocity[2] * 3600.0);", }, { - "name": "velocity_y_m_per_s", - "expression": "double velocity_y" - "velocity_y = primitiveVars[2]" - "velocity_y_m_per_s = velocity_y * 340.29400580821283", + "name": "velocity_m_per_s", + "expression": "double velocity[3];velocity[0] = primitiveVars[1] * velocityScale;velocity[1] = primitiveVars[2] * velocityScale;velocity[2] = primitiveVars[3] * velocityScale;velocity_m_per_s[0] = velocity[0] * 1000.0;velocity_m_per_s[1] = velocity[1] * 1000.0;velocity_m_per_s[2] = velocity[2] * 1000.0;", }, { - "name": "velocity_x_m_per_s", - "expression": "double velocity_x" - "velocity_x = primitiveVars[1]" - "velocity_x_m_per_s = velocity_x * 340.29400580821283", + "name": "velocity_magnitude", + "expression": "double velocity[3];velocity[0] = primitiveVars[1];velocity[1] = primitiveVars[2];velocity[2] = primitiveVars[3];velocity_magnitude = magnitude(velocity) * velocityScale;", }, { "name": "velocity_magnitude_m_per_s", - "expression": "double velocity_magnitude" - "double velocity[3]" - "velocity[0] = primitiveVars[1]" - "velocity[1] = primitiveVars[2]" - "velocity[2] = primitiveVars[3]" - "velocity_magnitude = magnitude(velocity)" - "velocity_magnitude_m_per_s = velocity_magnitude * 340.29400580821283", + "expression": "double velocity_magnitude;double velocity[3];velocity[0] = primitiveVars[1];velocity[1] = primitiveVars[2];velocity[2] = primitiveVars[3];velocity_magnitude = magnitude(velocity) * velocityScale;velocity_magnitude_m_per_s = velocity_magnitude * 1000.0;", }, { - "name": "pressure_pa", - "expression": "double pressure" - "pressure = primitiveVars[4]" - "pressure_pa = pressure * 141855.012726525", + "name": "velocity_x_m_per_s", + "expression": "double velocity_x;velocity_x = primitiveVars[1] * velocityScale;velocity_x_m_per_s = velocity_x * 1000.0;", }, { - "name": "velocity_z_m_per_s", - "expression": "double velocity_z" - "velocity_z = primitiveVars[3]" - "velocity_z_m_per_s = velocity_z * 340.29400580821283", + "name": "velocity_y_m_per_s", + "expression": "double velocity_y;velocity_y = primitiveVars[2] * velocityScale;velocity_y_m_per_s = velocity_y * 1000.0;", }, { - "name": "my_field", - "expression": "1+1", + "name": "velocity_z_m_per_s", + "expression": "double velocity_z;velocity_z = primitiveVars[3] * velocityScale;velocity_z_m_per_s = velocity_z * 1000.0;", }, { - "expression": "vorticity_y = gradPrimitive[1][2] - gradPrimitive[3][0];", "name": "vorticity_y", + "expression": "vorticity_y = (gradPrimitive[1][2] - gradPrimitive[3][0]) * velocityScale;", + }, + { + "name": "wall_shear_stress_magnitude", + "expression": "wall_shear_stress_magnitude = magnitude(wallShearStress) * (velocityScale * velocityScale);", + }, + { + "name": "wall_shear_stress_magnitude_pa", + "expression": "double wall_shear_stress_magnitude;wall_shear_stress_magnitude = magnitude(wallShearStress) * (velocityScale * velocityScale);wall_shear_stress_magnitude_pa = wall_shear_stress_magnitude * 999999999.9999999;", }, ] } - solver_user_defined_fields = {} - solver_user_defined_fields["userDefinedFields"] = solver_json["userDefinedFields"] - assert sorted(solver_user_defined_fields) == sorted(ref) + translated_udfs = sorted(solver_json["userDefinedFields"], key=lambda x: x["name"]) + ref_udfs = sorted(ref["userDefinedFields"], key=lambda x: x["name"]) + assert compare_values(translated_udfs, ref_udfs) @pytest.fixture() From 7f2eb02bfad62bb110204485e8101f8b0198cdef Mon Sep 17 00:00:00 2001 From: Ben <106089368+benflexcompute@users.noreply.github.com> Date: Tue, 10 Jun 2025 20:50:19 -0400 Subject: [PATCH 22/34] Added unit test for project_variables and also simplified the translation unit test (#1151) * Added unit test for project_variables and also simplified the translation unit test * Removed print * Fix windows test --- .../simulation/framework/param_utils.py | 4 +- .../translator/solver_translator.py | 15 ++--- tests/simulation/test_expressions.py | 67 +++++++++++-------- 3 files changed, 46 insertions(+), 40 deletions(-) diff --git a/flow360/component/simulation/framework/param_utils.py b/flow360/component/simulation/framework/param_utils.py index 7f252019e..a74bb24ec 100644 --- a/flow360/component/simulation/framework/param_utils.py +++ b/flow360/component/simulation/framework/param_utils.py @@ -39,7 +39,9 @@ class AssetCache(Flow360BaseModel): use_geometry_AI: bool = pd.Field( False, description="Flag whether user requested the use of GAI." ) - project_variables: Optional[List[UserVariable]] = pd.Field(None) + project_variables: Optional[List[UserVariable]] = pd.Field( + None, description="List of user variables that are used in all the `Expression` instances." + ) @property def boundaries(self): diff --git a/flow360/component/simulation/translator/solver_translator.py b/flow360/component/simulation/translator/solver_translator.py index e690b79cf..e74e1046d 100644 --- a/flow360/component/simulation/translator/solver_translator.py +++ b/flow360/component/simulation/translator/solver_translator.py @@ -561,21 +561,14 @@ def user_variable_to_udf(variable: UserVariable, input_params: SimulationParams) raise ValueError("Constant value found in user variable.") def _compute_coefficient_and_offset(source_unit: u.Unit, target_unit: u.Unit): - y2 = (2 * target_unit).in_units(source_unit).value - y1 = (1 * target_unit).in_units(source_unit).value - x2 = 2 - x1 = 1 + y2 = (2.0 * target_unit).in_units(source_unit).value + y1 = (1.0 * target_unit).in_units(source_unit).value + x2 = 2.0 + x1 = 1.0 coefficient = (y2 - y1) / (x2 - x1) offset = y1 / coefficient - x1 - assert np.isclose( - 123, (coefficient * (123 + offset) * source_unit).in_units(target_unit).value - ) - assert np.isclose( - 12, (coefficient * (12 + offset) * source_unit).in_units(target_unit).value - ) - return coefficient, offset def _get_output_unit(expression: Expression, input_params: SimulationParams): diff --git a/tests/simulation/test_expressions.py b/tests/simulation/test_expressions.py index 077250ac0..65190ca54 100644 --- a/tests/simulation/test_expressions.py +++ b/tests/simulation/test_expressions.py @@ -1042,48 +1042,59 @@ def assert_ignore_space(expected: str, actual: str): def test_udf_generator(): with SI_unit_system: params = SimulationParams( - operating_condition=AerospaceCondition( - velocity_magnitude=10 * u.m / u.s, - reference_velocity_magnitude=10 * u.m / u.s, + operating_condition=LiquidOperatingCondition( + velocity_magnitude=5 * u.m / u.s, ), private_attribute_asset_cache=AssetCache(project_length_unit=10 * u.m), ) # Scalar output result = user_variable_to_udf( - solution.mut.in_unit(new_name="mut_in_Imperial", new_unit="ft**2/s"), input_params=params + solution.mut.in_unit(new_name="mut_in_km", new_unit="km**2/s"), input_params=params ) - # velocity scale = 340.29400580821283 m/s, length scale = 10m, mut_scale = 3402.94 m**2/s -> 36628.94131344 *ft**2/s - pattern = r"mut_in_Imperial = \(mut \* ([\d\.]+)\);" - match = re.match(pattern, result.expression) - assert match is not None, f"Expression '{result.expression}' does not match expected pattern" - conversion_factor = float(match.group(1)) - # Note: Ranged since the value depends on the OS. - assert ( - 36628.941 < conversion_factor < 36628.942 - ), f"Conversion factor {conversion_factor} outside expected range" + # velocity scale = 100 m/s, length scale = 10m, mut_scale = 1000 m**2/s -> 0.01 *km**2/s + assert result.expression == "mut_in_km = (mut * 0.001);" # Vector output result = user_variable_to_udf( solution.velocity.in_unit(new_name="velocity_in_SI", new_unit="m/s"), input_params=params ) - # velocity scale = 340.29400580821283 m/s - pattern = r"velocity_in_SI\[0\] = \(solution\.velocity\[0\] \* ([\d\.]+)\); velocity_in_SI\[1\] = \(solution\.velocity\[1\] \* \1\); velocity_in_SI\[2\] = \(solution\.velocity\[2\] \* \1\);" - match = re.match(pattern, result.expression) - assert match is not None, f"Expression '{result.expression}' does not match expected pattern" - conversion_factor = float(match.group(1)) + # velocity scale = 100 m/s, assert ( - 340.294005 < conversion_factor < 340.2940064 - ), f"Conversion factor {conversion_factor} outside expected range" + result.expression + == "velocity_in_SI[0] = (solution.velocity[0] * 100.0); velocity_in_SI[1] = (solution.velocity[1] * 100.0); velocity_in_SI[2] = (solution.velocity[2] * 100.0);" + ) vel_cross_vec = UserVariable( name="vel_cross_vec", value=math.cross(solution.velocity, [1, 2, 3] * u.cm) - ).in_unit(new_unit="m*ft/s/min") + ).in_unit(new_unit="m*km/s/s") result = user_variable_to_udf(vel_cross_vec, input_params=params) - # velocity scale = 340.29400580821283 m/s --> 22795277.63562985 m*ft/s/min - pattern = r"vel_cross_vec\[0\] = \(\(\(\(solution\.velocity\[1\] \* 3\) \* 0\.001\) - \(\(solution\.velocity\[2\] \* 2\) \* 0\.001\)\) \* ([\d\.]+)\); vel_cross_vec\[1\] = \(\(\(\(solution\.velocity\[2\] \* 1\) \* 0\.001\) - \(\(solution\.velocity\[0\] \* 3\) \* 0\.001\)\) \* \1\); vel_cross_vec\[2\] = \(\(\(\(solution\.velocity\[0\] \* 2\) \* 0\.001\) - \(\(solution\.velocity\[1\] \* 1\) \* 0\.001\)\) \* \1\);" - match = re.match(pattern, result.expression) - assert match is not None, f"Expression '{result.expression}' does not match expected pattern" - conversion_factor = float(match.group(1)) assert ( - 22795277.635 < conversion_factor < 22795278 + 1e-8 - ), f"Conversion factor {conversion_factor} outside expected range" + result.expression + == "vel_cross_vec[0] = ((((solution.velocity[1] * 3) * 0.001) - ((solution.velocity[2] * 2) * 0.001)) * 10.0); vel_cross_vec[1] = ((((solution.velocity[2] * 1) * 0.001) - ((solution.velocity[0] * 3) * 0.001)) * 10.0); vel_cross_vec[2] = ((((solution.velocity[0] * 2) * 0.001) - ((solution.velocity[1] * 1) * 0.001)) * 10.0);" + ) + + # DOES NOT WORK + # vel_plus_vec = UserVariable( + # name="vel_cross_vec", value=solution.velocity + [1, 2, 3] * u.cm / u.ms + # ).in_unit(new_unit="cm/s") + # result = user_variable_to_udf(vel_plus_vec, input_params=params) + # print("4>>> result.expression", result.expression) + + +def test_project_variables(): + aaa = UserVariable(name="aaa", value=solution.velocity + 12 * u.m / u.s) + with SI_unit_system: + params = SimulationParams( + operating_condition=AerospaceCondition( + velocity_magnitude=10 * u.m / u.s, + reference_velocity_magnitude=10 * u.m / u.s, + ), + outputs=[ + VolumeOutput( + output_fields=[ + UserVariable(name="bbb", value=aaa + 14 * u.m / u.s), + ] + ) + ], + ) + assert params.private_attribute_asset_cache.project_variables == [aaa] From e02336c125fa46f45b65bfa0c44b696246e47c50 Mon Sep 17 00:00:00 2001 From: Ben <106089368+benflexcompute@users.noreply.github.com> Date: Wed, 11 Jun 2025 16:10:54 -0400 Subject: [PATCH 23/34] Added util function to get the unit from expression (#1157) * Added util function to get the unit from expression * removed print * Added catch for input_params is None * fix lint --- .../translator/solver_translator.py | 23 +------- .../simulation/user_code/core/types.py | 52 ++++++++++++++++++- tests/simulation/test_expressions.py | 5 ++ 3 files changed, 58 insertions(+), 22 deletions(-) diff --git a/flow360/component/simulation/translator/solver_translator.py b/flow360/component/simulation/translator/solver_translator.py index e74e1046d..05adbe4d7 100644 --- a/flow360/component/simulation/translator/solver_translator.py +++ b/flow360/component/simulation/translator/solver_translator.py @@ -2,7 +2,7 @@ # pylint: disable=too-many-lines from numbers import Number -from typing import Literal, Type, Union +from typing import Type, Union import numpy as np import unyt as u @@ -571,28 +571,9 @@ def _compute_coefficient_and_offset(source_unit: u.Unit, target_unit: u.Unit): return coefficient, offset - def _get_output_unit(expression: Expression, input_params: SimulationParams): - if not expression.output_units: - # Derive the default output unit based on the value's dimensionality and current unit system - current_unit_system_name: Literal["SI", "Imperial", "CGS"] = ( - input_params.unit_system.name - ) - numerical_value = expression.evaluate(raise_on_non_evaluable=False, force_evaluate=True) - if not isinstance(numerical_value, (u.unyt_array, u.unyt_quantity)): - # Pure dimensionless constant - return None - if current_unit_system_name == "SI": - return numerical_value.in_base("mks").units - if current_unit_system_name == "Imperial": - return numerical_value.in_base("imperial").units - if current_unit_system_name == "CGS": - return numerical_value.in_base("cgs").units - - return u.Unit(expression.output_units) - expression: Expression = variable.value - requested_unit: Union[u.Unit, None] = _get_output_unit(expression, input_params) + requested_unit: Union[u.Unit, None] = expression.get_output_units(input_params=input_params) if requested_unit is None: # Number constant output requested coefficient = 1 diff --git a/flow360/component/simulation/user_code/core/types.py b/flow360/component/simulation/user_code/core/types.py index 5568db125..1fa225014 100644 --- a/flow360/component/simulation/user_code/core/types.py +++ b/flow360/component/simulation/user_code/core/types.py @@ -9,6 +9,7 @@ import numpy as np import pydantic as pd +import unyt as u from pydantic import BeforeValidator, Discriminator, PlainSerializer, Tag from pydantic_core import InitErrorDetails, core_schema from typing_extensions import Self @@ -289,8 +290,10 @@ def __hash__(self): """ return hash(self.model_dump_json()) - def in_unit(self, new_unit: str = None): + def in_unit(self, new_unit: Union[str, Unit] = None): """Requesting the output of the variable to be in the given (new_unit) units.""" + if isinstance(new_unit, Unit): + new_unit = str(new_unit) self.value.output_units = new_unit return self @@ -579,6 +582,53 @@ def length(self): return len(value) return 1 if isinstance(value, unyt_quantity) else value.shape[0] + def get_output_units(self, input_params=None): + """ + Get the output units of the expression. + + - If self.output_units is None, derive the default output unit based on the + value's dimensionality and current unit system. + + - If self.output_units is valid u.Unit string, deserialize it and return it. + + - If self.output_units is valid unit system name, derive the default output + unit based on the value's dimensionality and the **given** unit system. + + - If expression is a number constant, return None. + + - Else raise ValueError. + """ + + def get_unit_from_unit_system(expression: Expression, unit_system_name: str): + """Derive the default output unit based on the value's dimensionality and current unit system""" + numerical_value = expression.evaluate(raise_on_non_evaluable=False, force_evaluate=True) + if not isinstance(numerical_value, (u.unyt_array, u.unyt_quantity, list)): + # Pure dimensionless constant + return None + if isinstance(numerical_value, list): + numerical_value = numerical_value[0] + + if unit_system_name in ("SI", "SI_unit_system"): + return numerical_value.in_base("mks").units + if unit_system_name in ("Imperial", "Imperial_unit_system"): + return numerical_value.in_base("imperial").units + if unit_system_name in ("CGS", "CGS_unit_system"): + return numerical_value.in_base("cgs").units + raise ValueError(f"[Internal] Invalid unit system: {unit_system_name}") + + try: + return u.Unit(self.output_units) + except u.exceptions.UnitParseError as e: + if input_params is None: + raise ValueError( + "[Internal] input_params required when output_units is not valid u.Unit string" + ) from e + if not self.output_units: + unit_system_name: Literal["SI", "Imperial", "CGS"] = input_params.unit_system.name + else: + unit_system_name = self.output_units + return get_unit_from_unit_system(self, unit_system_name) + T = TypeVar("T") diff --git a/tests/simulation/test_expressions.py b/tests/simulation/test_expressions.py index 65190ca54..7cd460c36 100644 --- a/tests/simulation/test_expressions.py +++ b/tests/simulation/test_expressions.py @@ -1073,6 +1073,11 @@ def test_udf_generator(): == "vel_cross_vec[0] = ((((solution.velocity[1] * 3) * 0.001) - ((solution.velocity[2] * 2) * 0.001)) * 10.0); vel_cross_vec[1] = ((((solution.velocity[2] * 1) * 0.001) - ((solution.velocity[0] * 3) * 0.001)) * 10.0); vel_cross_vec[2] = ((((solution.velocity[0] * 2) * 0.001) - ((solution.velocity[1] * 1) * 0.001)) * 10.0);" ) + vel_cross_vec = UserVariable( + name="vel_cross_vec", value=math.cross(solution.velocity, [1, 2, 3] * u.cm) + ).in_unit(new_unit="CGS_unit_system") + assert vel_cross_vec.value.get_output_units(input_params=params) == u.cm**2 / u.s + # DOES NOT WORK # vel_plus_vec = UserVariable( # name="vel_cross_vec", value=solution.velocity + [1, 2, 3] * u.cm / u.ms From 18904e251b7260a44d896f4946aaf10600b263ab Mon Sep 17 00:00:00 2001 From: Ben <106089368+benflexcompute@users.noreply.github.com> Date: Wed, 11 Jun 2025 22:30:32 -0400 Subject: [PATCH 24/34] Disables vector arithmetics for variables (#1158) --- flow360/component/simulation/services.py | 10 ++++-- .../simulation/user_code/core/types.py | 33 +++++++++++++++++++ tests/simulation/test_expressions.py | 31 ++++++++++++----- 3 files changed, 64 insertions(+), 10 deletions(-) diff --git a/flow360/component/simulation/services.py b/flow360/component/simulation/services.py index 779d75a3c..ce48d7ac7 100644 --- a/flow360/component/simulation/services.py +++ b/flow360/component/simulation/services.py @@ -1,6 +1,6 @@ """Simulation services module.""" -# pylint: disable=duplicate-code +# pylint: disable=duplicate-code, too-many-lines import json import os from enum import Enum @@ -825,7 +825,10 @@ def validate_expression(variables: list[dict], expressions: list[str]): try: expression_object = Expression(expression=expression) result = expression_object.evaluate(raise_on_non_evaluable=False) - if np.isnan(result): + if isinstance(result, (list, np.ndarray)): + if np.isnan(result).all(): + pass + elif isinstance(result, Number) and np.isnan(result): pass elif isinstance(result, Number): value = result @@ -840,6 +843,9 @@ def validate_expression(variables: list[dict], expressions: list[str]): value = float(result[0]) else: value = tuple(result.tolist()) + + # Test symbolically + expression_object.evaluate(raise_on_non_evaluable=False, force_evaluate=False) except pd.ValidationError as err: errors.extend(err.errors()) except Exception as err: # pylint: disable=broad-exception-caught diff --git a/flow360/component/simulation/user_code/core/types.py b/flow360/component/simulation/user_code/core/types.py index 1fa225014..8c8899e51 100644 --- a/flow360/component/simulation/user_code/core/types.py +++ b/flow360/component/simulation/user_code/core/types.py @@ -137,6 +137,25 @@ def validate(cls, value: Any): AnyNumericType = Union[float, UnytArray, list] +def check_vector_arithmetic(func): + """Decorator to check if vector arithmetic is being attempted and raise an error if so.""" + + def wrapper(self, other): + vector_arithmetic = False + if isinstance(other, unyt_array) and other.shape != (): + vector_arithmetic = True + elif isinstance(other, list): + vector_arithmetic = True + if vector_arithmetic: + raise ValueError( + f"Vector operation ({func.__name__} between {self.name} and {other}) not " + "supported for variables. Please write expression for each component." + ) + return func(self, other) + + return wrapper + + class Variable(Flow360BaseModel): """Base class representing a symbolic variable""" @@ -145,16 +164,19 @@ class Variable(Flow360BaseModel): model_config = pd.ConfigDict(validate_assignment=True, extra="allow") + @check_vector_arithmetic def __add__(self, other): (arg, parenthesize) = _convert_argument(other) str_arg = arg if not parenthesize else f"({arg})" return Expression(expression=f"{self.name} + {str_arg}") + @check_vector_arithmetic def __sub__(self, other): (arg, parenthesize) = _convert_argument(other) str_arg = arg if not parenthesize else f"({arg})" return Expression(expression=f"{self.name} - {str_arg}") + @check_vector_arithmetic def __mul__(self, other): if isinstance(other, Number) and other == 0: return Expression(expression="0") @@ -163,21 +185,25 @@ def __mul__(self, other): str_arg = arg if not parenthesize else f"({arg})" return Expression(expression=f"{self.name} * {str_arg}") + @check_vector_arithmetic def __truediv__(self, other): (arg, parenthesize) = _convert_argument(other) str_arg = arg if not parenthesize else f"({arg})" return Expression(expression=f"{self.name} / {str_arg}") + @check_vector_arithmetic def __floordiv__(self, other): (arg, parenthesize) = _convert_argument(other) str_arg = arg if not parenthesize else f"({arg})" return Expression(expression=f"{self.name} // {str_arg}") + @check_vector_arithmetic def __mod__(self, other): (arg, parenthesize) = _convert_argument(other) str_arg = arg if not parenthesize else f"({arg})" return Expression(expression=f"{self.name} % {str_arg}") + @check_vector_arithmetic def __pow__(self, other): (arg, parenthesize) = _convert_argument(other) str_arg = arg if not parenthesize else f"({arg})" @@ -192,16 +218,19 @@ def __pos__(self): def __abs__(self): return Expression(expression=f"abs({self.name})") + @check_vector_arithmetic def __radd__(self, other): (arg, parenthesize) = _convert_argument(other) str_arg = arg if not parenthesize else f"({arg})" return Expression(expression=f"{str_arg} + {self.name}") + @check_vector_arithmetic def __rsub__(self, other): (arg, parenthesize) = _convert_argument(other) str_arg = arg if not parenthesize else f"({arg})" return Expression(expression=f"{str_arg} - {self.name}") + @check_vector_arithmetic def __rmul__(self, other): if isinstance(other, Number) and other == 0: return Expression(expression="0") @@ -210,21 +239,25 @@ def __rmul__(self, other): str_arg = arg if not parenthesize else f"({arg})" return Expression(expression=f"{str_arg} * {self.name}") + @check_vector_arithmetic def __rtruediv__(self, other): (arg, parenthesize) = _convert_argument(other) str_arg = arg if not parenthesize else f"({arg})" return Expression(expression=f"{str_arg} / {self.name}") + @check_vector_arithmetic def __rfloordiv__(self, other): (arg, parenthesize) = _convert_argument(other) str_arg = arg if not parenthesize else f"({arg})" return Expression(expression=f"{str_arg} // {self.name}") + @check_vector_arithmetic def __rmod__(self, other): (arg, parenthesize) = _convert_argument(other) str_arg = arg if not parenthesize else f"({arg})" return Expression(expression=f"{str_arg} % {self.name}") + @check_vector_arithmetic def __rpow__(self, other): (arg, parenthesize) = _convert_argument(other) str_arg = arg if not parenthesize else f"({arg})" diff --git a/tests/simulation/test_expressions.py b/tests/simulation/test_expressions.py index 7cd460c36..926b5f041 100644 --- a/tests/simulation/test_expressions.py +++ b/tests/simulation/test_expressions.py @@ -25,7 +25,11 @@ ReferenceGeometry, Surface, ) -from flow360.component.simulation.services import ValidationCalledBy, validate_model +from flow360.component.simulation.services import ( + ValidationCalledBy, + validate_expression, + validate_model, +) from flow360.component.simulation.translator.solver_translator import ( user_variable_to_udf, ) @@ -701,6 +705,24 @@ class TestModel(Flow360BaseModel): 11, ) # Python 3.9 report error on col 11, error message is also different + with pytest.raises( + ValueError, + match=re.escape( + "Vector operation (__add__ between solution.velocity and [1 2 3] cm/ms) not supported for variables. Please write expression for each component." + ), + ): + UserVariable(name="x", value=solution.velocity + [1, 2, 3] * u.cm / u.ms) + + errors, _, _ = validate_expression( + variables=[], expressions=["solution.velocity + [1, 2, 3] * u.cm / u.ms"] + ) + assert len(errors) == 1 + assert errors[0]["type"] == "value_error" + assert ( + "Vector operation (__add__ between solution.velocity and [1 2 3] cm/ms) not supported for variables. Please write expression for each component." + in errors[0]["msg"] + ) + def test_solver_translation(): timestepping_unsteady = Unsteady(steps=12, step_size=0.1 * u.s) @@ -1078,13 +1100,6 @@ def test_udf_generator(): ).in_unit(new_unit="CGS_unit_system") assert vel_cross_vec.value.get_output_units(input_params=params) == u.cm**2 / u.s - # DOES NOT WORK - # vel_plus_vec = UserVariable( - # name="vel_cross_vec", value=solution.velocity + [1, 2, 3] * u.cm / u.ms - # ).in_unit(new_unit="cm/s") - # result = user_variable_to_udf(vel_plus_vec, input_params=params) - # print("4>>> result.expression", result.expression) - def test_project_variables(): aaa = UserVariable(name="aaa", value=solution.velocity + 12 * u.m / u.s) From 6a337afa526c08dd3ee1526459400af335c6c3ec Mon Sep 17 00:00:00 2001 From: Angran Date: Thu, 12 Jun 2025 15:10:20 -0400 Subject: [PATCH 25/34] List all solver variables (#1150) * Initial implementation * More variable finished * Fix unit test * Fix pylint * Fix the issue that solver name is not used * Move prepending code to translator * Fix prepending code * Fix deserialize issue that output units is not loaded * self review of prepending code * typo fix * Address comments * fix grad pressure's prepending code * Added support for solver variable in output_fields and also fixed a bug when translating Mach (#1160) --------- Co-authored-by: Ben <106089368+benflexcompute@users.noreply.github.com> --- .../simulation/blueprint/core/generator.py | 2 +- .../component/simulation/outputs/outputs.py | 79 ++-- .../translator/solver_translator.py | 107 ++++- .../simulation/user_code/core/context.py | 59 +-- .../simulation/user_code/core/types.py | 20 +- .../simulation/user_code/variables/control.py | 47 ++- .../user_code/variables/solution.py | 385 +++++++++++++++--- tests/simulation/test_expressions.py | 27 +- .../ref/Flow360_expression_udf.json | 113 +++++ .../translator/test_output_translation.py | 2 +- .../translator/test_solver_translator.py | 22 + 11 files changed, 710 insertions(+), 153 deletions(-) create mode 100644 tests/simulation/translator/ref/Flow360_expression_udf.json diff --git a/flow360/component/simulation/blueprint/core/generator.py b/flow360/component/simulation/blueprint/core/generator.py index d986be49d..f3731e5a4 100644 --- a/flow360/component/simulation/blueprint/core/generator.py +++ b/flow360/component/simulation/blueprint/core/generator.py @@ -178,7 +178,7 @@ def _list_comp(expr, syntax, name_translator): def _subscript(expr, syntax, name_translator): # pylint:disable=unused-argument - return f"{expr.value.id}[{expr.slice.value}]" + return f"{name_translator(expr.value.id)}[{expr.slice.value}]" def expr_to_code( diff --git a/flow360/component/simulation/outputs/outputs.py b/flow360/component/simulation/outputs/outputs.py index 3f9577d8c..c4ec80024 100644 --- a/flow360/component/simulation/outputs/outputs.py +++ b/flow360/component/simulation/outputs/outputs.py @@ -37,7 +37,10 @@ Surface, ) from flow360.component.simulation.unit_system import LengthType -from flow360.component.simulation.user_code.core.types import UserVariable +from flow360.component.simulation.user_code.core.types import ( + SolverVariable, + UserVariable, +) from flow360.component.simulation.validation.validation_context import ( ALL, CASE, @@ -120,6 +123,18 @@ def _validate_non_liquid_output_fields(cls, value: UniqueItemList): ) return value + @pd.field_validator("output_fields", mode="after") + @classmethod + def _convert_solver_variables_as_user_variables(cls, value: UniqueItemList): + for i, output_item in enumerate(value.items): + if isinstance(output_item, SolverVariable): + # Strip out any prefix before a dot in the name + name = ( + output_item.name.split(".")[-1] if "." in output_item.name else output_item.name + ) + value.items[i] = UserVariable(name=name, value=output_item) + return value + class _AnimationSettings(_OutputBase): """ @@ -194,9 +209,11 @@ class SurfaceOutput(_AnimationAndFileFormatSettings): + "Will choose the value of the last instance of this option of the same output type " + "(:class:`SurfaceOutput` or :class:`TimeAverageSurfaceOutput`) in the output list.", ) - output_fields: UniqueItemList[Union[SurfaceFieldNames, str, UserVariable]] = pd.Field( - description="List of output variables. Including :ref:`universal output variables`," - + " :ref:`variables specific to SurfaceOutput` and :class:`UserDefinedField`." + output_fields: UniqueItemList[Union[SurfaceFieldNames, str, UserVariable, SolverVariable]] = ( + pd.Field( + description="List of output variables. Including :ref:`universal output variables`," + + " :ref:`variables specific to SurfaceOutput` and :class:`UserDefinedField`." + ) ) output_type: Literal["SurfaceOutput"] = pd.Field("SurfaceOutput", frozen=True) @@ -261,10 +278,12 @@ class VolumeOutput(_AnimationAndFileFormatSettings): """ name: Optional[str] = pd.Field("Volume output", description="Name of the `VolumeOutput`.") - output_fields: UniqueItemList[Union[VolumeFieldNames, str, UserVariable]] = pd.Field( - description="List of output variables. Including :ref:`universal output variables`," - " :ref:`variables specific to VolumeOutput`" - " and :class:`UserDefinedField`." + output_fields: UniqueItemList[Union[VolumeFieldNames, str, UserVariable, SolverVariable]] = ( + pd.Field( + description="List of output variables. Including :ref:`universal output variables`," + " :ref:`variables specific to VolumeOutput`" + " and :class:`UserDefinedField`." + ) ) output_type: Literal["VolumeOutput"] = pd.Field("VolumeOutput", frozen=True) @@ -329,10 +348,12 @@ class SliceOutput(_AnimationAndFileFormatSettings): alias="slices", description="List of output :class:`~flow360.Slice` entities.", ) - output_fields: UniqueItemList[Union[SliceFieldNames, str, UserVariable]] = pd.Field( - description="List of output variables. Including :ref:`universal output variables`," - " :ref:`variables specific to SliceOutput`" - " and :class:`UserDefinedField`." + output_fields: UniqueItemList[Union[SliceFieldNames, str, UserVariable, SolverVariable]] = ( + pd.Field( + description="List of output variables. Including :ref:`universal output variables`," + " :ref:`variables specific to SliceOutput`" + " and :class:`UserDefinedField`." + ) ) output_type: Literal["SliceOutput"] = pd.Field("SliceOutput", frozen=True) @@ -415,9 +436,11 @@ class IsosurfaceOutput(_AnimationAndFileFormatSettings): alias="isosurfaces", description="List of :class:`~flow360.Isosurface` entities.", ) - output_fields: UniqueItemList[Union[CommonFieldNames, str, UserVariable]] = pd.Field( - description="List of output variables. Including " - ":ref:`universal output variables` and :class:`UserDefinedField`." + output_fields: UniqueItemList[Union[CommonFieldNames, str, UserVariable, SolverVariable]] = ( + pd.Field( + description="List of output variables. Including " + ":ref:`universal output variables` and :class:`UserDefinedField`." + ) ) output_type: Literal["IsosurfaceOutput"] = pd.Field("IsosurfaceOutput", frozen=True) @@ -452,7 +475,7 @@ class SurfaceIntegralOutput(_OutputBase): alias="surfaces", description="List of boundaries where the surface integral will be calculated.", ) - output_fields: UniqueItemList[Union[str, UserVariable]] = pd.Field( + output_fields: UniqueItemList[Union[str, UserVariable, SolverVariable]] = pd.Field( description="List of output variables, only the :class:`UserDefinedField` is allowed." ) output_type: Literal["SurfaceIntegralOutput"] = pd.Field("SurfaceIntegralOutput", frozen=True) @@ -516,9 +539,11 @@ class ProbeOutput(_OutputBase): + "monitor group. :class:`~flow360.PointArray` is used to " + "define monitored points along a line.", ) - output_fields: UniqueItemList[Union[CommonFieldNames, str, UserVariable]] = pd.Field( - description="List of output fields. Including :ref:`universal output variables`" - " and :class:`UserDefinedField`." + output_fields: UniqueItemList[Union[CommonFieldNames, str, UserVariable, SolverVariable]] = ( + pd.Field( + description="List of output fields. Including :ref:`universal output variables`" + " and :class:`UserDefinedField`." + ) ) output_type: Literal["ProbeOutput"] = pd.Field("ProbeOutput", frozen=True) @@ -580,9 +605,11 @@ class SurfaceProbeOutput(Flow360BaseModel): + "entities belonging to this monitor group." ) - output_fields: UniqueItemList[Union[SurfaceFieldNames, str, UserVariable]] = pd.Field( - description="List of output variables. Including :ref:`universal output variables`," - " :ref:`variables specific to SurfaceOutput` and :class:`UserDefinedField`." + output_fields: UniqueItemList[Union[SurfaceFieldNames, str, UserVariable, SolverVariable]] = ( + pd.Field( + description="List of output variables. Including :ref:`universal output variables`," + " :ref:`variables specific to SurfaceOutput` and :class:`UserDefinedField`." + ) ) output_type: Literal["SurfaceProbeOutput"] = pd.Field("SurfaceProbeOutput", frozen=True) @@ -609,9 +636,11 @@ class SurfaceSliceOutput(_AnimationAndFileFormatSettings): output_format: Literal["paraview"] = pd.Field(default="paraview") - output_fields: UniqueItemList[Union[SurfaceFieldNames, str, UserVariable]] = pd.Field( - description="List of output variables. Including :ref:`universal output variables`," - " :ref:`variables specific to SurfaceOutput` and :class:`UserDefinedField`." + output_fields: UniqueItemList[Union[SurfaceFieldNames, str, UserVariable, SolverVariable]] = ( + pd.Field( + description="List of output variables. Including :ref:`universal output variables`," + " :ref:`variables specific to SurfaceOutput` and :class:`UserDefinedField`." + ) ) output_type: Literal["SurfaceSliceOutput"] = pd.Field("SurfaceSliceOutput", frozen=True) diff --git a/flow360/component/simulation/translator/solver_translator.py b/flow360/component/simulation/translator/solver_translator.py index 05adbe4d7..7ae353b14 100644 --- a/flow360/component/simulation/translator/solver_translator.py +++ b/flow360/component/simulation/translator/solver_translator.py @@ -97,6 +97,95 @@ ) from flow360.exceptions import Flow360TranslationError +udf_prepending_code = { + "solution.Cp": "double Cp = (primitiveVars[4] - pressureFreestream) / (0.5 * MachRef * MachRef);", + "solution.Cpt": "double MachUser = sqrt(primitiveVars[1] * primitiveVars[1] + " + + "primitiveVars[2] * primitiveVars[2] + primitiveVars[3] * primitiveVars[3])" + + "/sqrt(1.4 * primitiveVars[4] / primitiveVars[0]);" + + "double Cpt = (1.4 * primitiveVars[4] * pow(1.0 + (1.4 - 1.0) / 2. * MachUser * MachUser," + + "1.4 / (1.4 - 1.0)) - pow(1.0 + (1.4 - 1.0) / 2. * MachRef * MachRef," + + "1.4 / (1.4 - 1.0))) / (0.5 * 1.4 * MachRef * MachRef);", + "solution.grad_density": "double gradDensity[3] = {gradPrimitive[0][0], " + + "gradPrimitive[0][1], gradPrimitive[0][2]};", + "solution.grad_velocity_x": "double gradVelocityX[3] = {gradPrimitive[1][0], " + + " gradPrimitive[1][1], gradPrimitive[1][2]};", + "solution.grad_velocity_y": "double gradVelocityY[3] = {gradPrimitive[2][0]," + + "gradPrimitive[2][1], gradPrimitive[2][2]};", + "solution.grad_velocity_z": "double gradVelocityZ[3] = {gradPrimitive[3][0], " + + "gradPrimitive[3][1], gradPrimitive[3][2]};", + "solution.grad_pressure": "double gradPressure[3] = {gradPrimitive[4][0], " + + "gradPrimitive[4][1], gradPrimitive[4][2]};", + "solution.Mach": "double Mach = usingLiquidAsMaterial ? 0 : " + + "sqrt(primitiveVars[1] * primitiveVars[1] + " + + "primitiveVars[2] * primitiveVars[2] + " + + "primitiveVars[3] * primitiveVars[3]) / " + + "sqrt(1.4 * primitiveVars[4] / primitiveVars[0]);", + "solution.mut_ratio": "double mutRatio;mutRatio = mut / mu;", + "solution.velocity": "double velocity[3];" + + "velocity[0] = primitiveVars[1] * velocityScale;" + + "velocity[1] = primitiveVars[2] * velocityScale;" + + "velocity[2] = primitiveVars[3] * velocityScale;", + "solution.qcriterion": "double qcriterion;" + + "double ux = gradPrimitive[1][0];" + + "double uy = gradPrimitive[1][1];" + + "double uz = gradPrimitive[1][2];" + + "double vx = gradPrimitive[2][0];" + + "double vy = gradPrimitive[2][1];" + + "double vz = gradPrimitive[2][2];" + + "double wx = gradPrimitive[3][0];" + + "double wy = gradPrimitive[3][1];" + + "double wz = gradPrimitive[3][2];" + + "double str11 = ux;" + + "double str22 = vy;" + + "double str33 = wz;" + + "double str12 = 0.5 * (uy + vx);" + + "double str13 = 0.5 * (uz + wx);" + + "double str23 = 0.5 * (vz + wy);" + + "double str_norm = str11 * str11 + str22 * str22 + str33 * str33 + " + + "2 * (str12 * str12) + 2 * (str13 * str13) + 2 * (str23 * str23);" + + "double omg12 = 0.5 * (uy - vx);" + + "double omg13 = 0.5 * (uz - wx);" + + "double omg23 = 0.5 * (vz - wy);" + + "double omg_norm = 2 * (omg12 * omg12) + 2 * (omg13 * omg13) + 2 * (omg23 * omg23);" + + "qcriterion = 0.5 * (omg_norm - str_norm) * (velocityScale * velocityScale);", + "solution.entropy": "double entropy;entropy = log(primitiveVars[4] / (1.0 / 1.4) / pow(primitiveVars[0], 1.4));", + "solution.temperature": f"double epsilon = {np.finfo(np.float64).eps};" + "double temperature = (primitiveVars[0] < epsilon && HeatEquation_solution != nullptr) ? " + "HeatEquation_solution[0] : primitiveVars[4] / (primitiveVars[0] * (1.0 / 1.4));", + "solution.vorticity": "double vorticity[3];" + + "vorticity[0] = (gradPrimitive[3][1] - gradPrimitive[2][2]) * velocityScale;" + + "vorticity[1] = (gradPrimitive[1][2] - gradPrimitive[3][0]) * velocityScale;" + + "vorticity[2] = (gradPrimitive[2][0] - gradPrimitive[1][1]) * velocityScale;", + "solution.CfVec": "double CfVec[3];" + + "for (int i = 0; i < 3; i++)" + + "{CfVec[i] = wallShearStress[i] / (0.5 * MachRef * MachRef);}", + "solution.Cf": "double Cf;Cf = magnitude(wallShearStress) / (0.5 * MachRef * MachRef);", + "solution.node_forces_per_unit_area": "double nodeForcesPerUnitArea[3];" + + "double normalMag = magnitude(nodeNormals);" + + "for (int i = 0; i < 3; i++){nodeForcesPerUnitArea[i] = " + + "((primitiveVars[4] - pressureFreestream) * nodeNormals[i] / normalMag + wallViscousStress[i])" + + " * (velocityScale * velocityScale);}", + "solution.heat_transfer_coefficient_static_temperature": "double temperature = " + + "primitiveVars[4] / (primitiveVars[0] * 1.0 / 1.4);" + + f"double temperatureSafeDivide; double epsilon = {np.finfo(np.float64).eps};" + + "temperatureSafeDivide = (temperature - 1.0 < 0) ? " + + "temperature - 1.0 - epsilon : " + + "temperature - 1.0 + epsilon;" + + "double heatTransferCoefficientStaticTemperature = " + + "abs(temperature - 1.0) > epsilon ? " + + "- heatFlux / temperatureSafeDivide : 1.0 / epsilon;", + "solution.heat_transfer_coefficient_total_temperature": "double temperature = " + + "primitiveVars[4] / (primitiveVars[0] * 1.0 / 1.4);" + + "double temperatureTotal = 1.0 + (1.4 - 1.0) / 2.0 * MachRef * MachRef;" + + f"double temperatureSafeDivide; double epsilon = {np.finfo(np.float64).eps};" + + "temperatureSafeDivide = (temperature - temperatureTotal < 0) ? " + + "temperature - temperatureTotal - epsilon : " + + "temperature - temperatureTotal + epsilon;" + + "double heatTransferCoefficientTotalTemperature = " + + "abs(temperature - temperatureTotal) > epsilon ? " + + "temperatureTotal = - heatFlux / temperatureSafeDivide : 1.0 / epsilon;", +} + def dump_dict(input_params): """Dumping param/model to dictionary.""" @@ -587,7 +676,15 @@ def _compute_coefficient_and_offset(source_unit: u.Unit, target_unit: u.Unit): source_unit=requested_unit, target_unit=flow360_unit ) - if expression.length == 1: + expression_length = expression.length + prepending_code = [ + udf_prepending_code[name] + for name in expression.solver_variable_names() + if udf_prepending_code.get(name) + ] + prepending_code = "".join(prepending_code) + + if expression_length == 1: expression = expression.evaluate(raise_on_non_evaluable=False, force_evaluate=False) if offset != 0: expression = (expression + offset) * coefficient @@ -595,13 +692,13 @@ def _compute_coefficient_and_offset(source_unit: u.Unit, target_unit: u.Unit): expression = expression * coefficient expression = expression.to_solver_code(params=input_params) return UserDefinedField( - name=variable.name, expression=f"{variable.name} = " + expression + ";" + name=variable.name, expression=f"{prepending_code}{variable.name} = " + expression + ";" ) # Vector output requested expression = [ expression[i].evaluate(raise_on_non_evaluable=False, force_evaluate=False) - for i in range(expression.length) + for i in range(expression_length) ] if offset != 0: expression = [(item + offset) * coefficient for item in expression] @@ -609,7 +706,9 @@ def _compute_coefficient_and_offset(source_unit: u.Unit, target_unit: u.Unit): expression = [item * coefficient for item in expression] expression = [item.to_solver_code(params=input_params) for item in expression] expression = [f"{variable.name}[{i}] = " + item for i, item in enumerate(expression)] - return UserDefinedField(name=variable.name, expression="; ".join(expression) + ";") + return UserDefinedField( + name=variable.name, expression=prepending_code + "; ".join(expression) + ";" + ) def process_output_fields_for_udf(input_params: SimulationParams): diff --git a/flow360/component/simulation/user_code/core/context.py b/flow360/component/simulation/user_code/core/context.py index 35f95a478..2846d7e85 100644 --- a/flow360/component/simulation/user_code/core/context.py +++ b/flow360/component/simulation/user_code/core/context.py @@ -82,35 +82,42 @@ def _import_solution(_) -> Any: "flow360.solution": { "prefix": "solution.", "callables": [ + # pylint: disable=fixme + # TODO: Auto-populate this list from the solution module + "coordinate", + "Cp", + "Cpt", + "grad_density", + "grad_velocity_x", + "grad_velocity_y", + "grad_velocity_z", + "grad_pressure", + "Mach", "mut", "mu", - "solutionNavierStokes", - "residualNavierStokes", - "solutionTurbulence", - "residualTurbulence", - "kOmega", - "nuHat", - "solutionTransition", - "residualTransition", - "solutionHeatSolver", - "residualHeatSolver", - "coordinate", + "mut_ratio", + "nu_hat", + "turbulence_kinetic_energy", + "specific_rate_of_dissipation", + "amplification_factor", + "turbulence_intermittency", + "density", "velocity", - "bet_thrust", - "bet_torque", - "bet_omega", - "CD", - "CL", - "forceX", - "forceY", - "forceZ", - "momentX", - "momentY", - "momentZ", - "nodeNormals", - "wallFunctionMetric", - "wallShearStress", - "yPlus", + "pressure", + "qcriterion", + "entropy", + "temperature", + "vorticity", + "wall_distance", + "CfVec", + "Cf", + "heatflux", + "node_normals", + "node_forces_per_unit_area", + "y_plus", + "wall_shear_stress", + "heat_transfer_coefficient_static_temperature", + "heat_transfer_coefficient_total_temperature", ], "evaluate": False, }, diff --git a/flow360/component/simulation/user_code/core/types.py b/flow360/component/simulation/user_code/core/types.py index 8c8899e51..66f71f0df 100644 --- a/flow360/component/simulation/user_code/core/types.py +++ b/flow360/component/simulation/user_code/core/types.py @@ -27,6 +27,7 @@ ) _user_variables: set[str] = set() +_solver_variables: set[str] = set() def __soft_fail_add__(self, other): @@ -335,11 +336,13 @@ class SolverVariable(Variable): """Class representing a pre-defined symbolic variable that cannot be evaluated at client runtime""" solver_name: Optional[str] = pd.Field(None) + variable_type: Literal["Volume", "Surface", "Scalar"] = pd.Field() @pd.model_validator(mode="after") def update_context(self): """Auto updating context when new variable is declared""" - default_context.set(self.name, self.value, SolverVariable) + default_context.set(self.name, self.value, Variable) + _solver_variables.add(self.name) if self.solver_name: default_context.set_alias(self.name, self.solver_name) return self @@ -459,6 +462,13 @@ def user_variable_names(self): return names + def solver_variable_names(self): + """Get list of solver variable names used in expression.""" + expr = expr_to_model(self.expression, default_context) + names = expr.used_names() + names = [name for name in names if name in _solver_variables] + return names + def to_solver_code(self, params): """Convert to solver readable code.""" @@ -610,10 +620,12 @@ def dimensionality(self): def length(self): """The number of elements in the expression.""" value = self.evaluate(raise_on_non_evaluable=False, force_evaluate=True) - assert isinstance(value, (unyt_array, unyt_quantity, list)) + assert isinstance( + value, (unyt_array, unyt_quantity, list, Number) + ), f"Unexpected evaluated result type: {type(value)}" if isinstance(value, list): return len(value) - return 1 if isinstance(value, unyt_quantity) else value.shape[0] + return 1 if isinstance(value, (unyt_quantity, Number)) else value.shape[0] def get_output_units(self, input_params=None): """ @@ -689,7 +701,7 @@ def _deserialize(value) -> Self: return unyt_array(value.value, value.units) return value.value if value.type_name == "expression": - return expr_type(expression=value.expression) + return expr_type(expression=value.expression, output_units=value.output_units) except Exception: # pylint:disable=broad-exception-caught pass diff --git a/flow360/component/simulation/user_code/variables/control.py b/flow360/component/simulation/user_code/variables/control.py index 4639a8972..cdafc6ad9 100644 --- a/flow360/component/simulation/user_code/variables/control.py +++ b/flow360/component/simulation/user_code/variables/control.py @@ -5,48 +5,59 @@ # pylint:disable=no-member MachRef = SolverVariable( - name="control.MachRef", value=float("NaN") * u.m / u.s, solver_name="machRef" + name="control.MachRef", + value=float("NaN"), + solver_name="machRef", + variable_type="Scalar", ) # Reference mach specified by the user -Tref = SolverVariable(name="control.Tref", value=float("NaN")) # Temperature specified by the user -t = SolverVariable(name="control.t", value=float("NaN")) # Physical time +Tref = SolverVariable( + name="control.Tref", value=float("NaN") * u.K, variable_type="Scalar" +) # Temperature specified by the user +t = SolverVariable( + name="control.t", value=float("NaN") * u.s, variable_type="Scalar" +) # Physical time physicalStep = SolverVariable( - name="control.physicalStep", value=float("NaN") + name="control.physicalStep", value=float("NaN"), variable_type="Scalar" ) # Physical time step, starting from 0 pseudoStep = SolverVariable( - name="control.pseudoStep", value=float("NaN") + name="control.pseudoStep", value=float("NaN"), variable_type="Scalar" ) # Pseudo time step within physical time step timeStepSize = SolverVariable( - name="control.timeStepSize", value=float("NaN") + name="control.timeStepSize", value=float("NaN") * u.s, variable_type="Scalar" ) # Physical time step size alphaAngle = SolverVariable( - name="control.alphaAngle", value=float("NaN") + name="control.alphaAngle", value=float("NaN") * u.rad, variable_type="Scalar" ) # Alpha angle specified in freestream betaAngle = SolverVariable( - name="control.betaAngle", value=float("NaN") + name="control.betaAngle", value=float("NaN") * u.rad, variable_type="Scalar" ) # Beta angle specified in freestream pressureFreestream = SolverVariable( - name="control.pressureFreestream", value=float("NaN") + name="control.pressureFreestream", value=float("NaN") * u.Pa, variable_type="Scalar" ) # Freestream reference pressure (1.0/1.4) momentLengthX = SolverVariable( - name="control.momentLengthX", value=float("NaN") + name="control.momentLengthX", value=float("NaN") * u.m, variable_type="Scalar" ) # X component of momentLength momentLengthY = SolverVariable( - name="control.momentLengthY", value=float("NaN") + name="control.momentLengthY", value=float("NaN") * u.m, variable_type="Scalar" ) # Y component of momentLength momentLengthZ = SolverVariable( - name="control.momentLengthZ", value=float("NaN") + name="control.momentLengthZ", value=float("NaN") * u.m, variable_type="Scalar" ) # Z component of momentLength momentCenterX = SolverVariable( - name="control.momentCenterX", value=float("NaN") + name="control.momentCenterX", value=float("NaN") * u.m, variable_type="Scalar" ) # X component of momentCenter momentCenterY = SolverVariable( - name="control.momentCenterY", value=float("NaN") + name="control.momentCenterY", value=float("NaN") * u.m, variable_type="Scalar" ) # Y component of momentCenter momentCenterZ = SolverVariable( - name="control.momentCenterZ", value=float("NaN") + name="control.momentCenterZ", value=float("NaN") * u.m, variable_type="Scalar" ) # Z component of momentCenter -theta = SolverVariable(name="control.theta", value=float("NaN")) # Rotation angle of volume zone -omega = SolverVariable(name="control.omega", value=float("NaN")) # Rotation speed of volume zone +theta = SolverVariable( + name="control.theta", value=float("NaN") * u.rad, variable_type="Scalar" +) # Rotation angle of volume zone +omega = SolverVariable( + name="control.omega", value=float("NaN") * u.rad, variable_type="Scalar" +) # Rotation speed of volume zone omegaDot = SolverVariable( - name="control.omegaDot", value=float("NaN") + name="control.omegaDot", value=float("NaN") * u.rad / u.s, variable_type="Scalar" ) # Rotation acceleration of volume zone diff --git a/flow360/component/simulation/user_code/variables/solution.py b/flow360/component/simulation/user_code/variables/solution.py index e535225e6..f155d65d2 100644 --- a/flow360/component/simulation/user_code/variables/solution.py +++ b/flow360/component/simulation/user_code/variables/solution.py @@ -4,75 +4,338 @@ from flow360.component.simulation.user_code.core.types import SolverVariable -# pylint:disable = no-member -mut = SolverVariable( - name="solution.mut", value=float("NaN") * u.kg / u.m / u.s, solver_name="mut" -) # Turbulent viscosity -mu = SolverVariable(name="solution.mu", value=float("NaN") * u.kg / u.m / u.s) # Laminar viscosity - -solutionNavierStokes = SolverVariable( - name="solution.solutionNavierStokes", value=float("NaN") -) # Solution for N-S equation in conservative form -residualNavierStokes = SolverVariable( - name="solution.residualNavierStokes", value=float("NaN") -) # Residual for N-S equation in conservative form -solutionTurbulence = SolverVariable( - name="solution.solutionTurbulence", value=float("NaN") -) # Solution for turbulence model -residualTurbulence = SolverVariable( - name="solution.residualTurbulence", value=float("NaN") -) # Residual for turbulence model -kOmega = SolverVariable( - name="solution.kOmega", value=float("NaN") -) # Effectively solutionTurbulence when using SST model -nuHat = SolverVariable( - name="solution.nuHat", value=float("NaN") -) # Effectively solutionTurbulence when using SA model -solutionTransition = SolverVariable( - name="solution.solutionTransition", value=float("NaN") -) # Solution for transition model -residualTransition = SolverVariable( - name="solution.residualTransition", value=float("NaN") -) # Residual for transition model -solutionHeatSolver = SolverVariable( - name="solution.solutionHeatSolver", value=float("NaN") -) # Solution for heat equation -residualHeatSolver = SolverVariable( - name="solution.residualHeatSolver", value=float("NaN") -) # Residual for heat equation +# pylint:disable = fixme +# TODO:Scalar type (needs further discussion on how to handle scalar values) +# bet_thrust = SolverVariable( +# name="solution.bet_thrust", value=float("NaN") +# ) # Thrust force for BET disk +# bet_torque = SolverVariable(name="solution.bet_torque", value=float("NaN")) # Torque for BET disk +# bet_omega = SolverVariable( +# name="solution.bet_omega", value=float("NaN") +# ) # Rotation speed for BET disk +# CD = SolverVariable(name="solution.CD", value=float("NaN")) # Drag coefficient on patch +# CL = SolverVariable(name="solution.CL", value=float("NaN")) # Lift coefficient on patch +# forceX = SolverVariable(name="solution.forceX", value=float("NaN")) # Total force in X direction +# forceY = SolverVariable(name="solution.forceY", value=float("NaN")) # Total force in Y direction +# forceZ = SolverVariable(name="solution.forceZ", value=float("NaN")) # Total force in Z direction +# momentX = SolverVariable(name="solution.momentX", value=float("NaN")) # Total moment in X direction +# momentY = SolverVariable(name="solution.momentY", value=float("NaN")) # Total moment in Y direction +# momentZ = SolverVariable(name="solution.momentZ", value=float("NaN")) # Total moment in Z direction + +# pylint:disable=no-member +# Common coordinate = SolverVariable( name="solution.coordinate", value=[float("NaN"), float("NaN"), float("NaN")] * u.m, + solver_name="nodePosition", + variable_type="Volume", ) # Grid coordinates +Cp = SolverVariable( + name="solution.Cp", + value=float("NaN"), + solver_name="Cp", + variable_type="Volume", +) +Cpt = SolverVariable( + name="solution.Cpt", + value=float("NaN"), + solver_name="Cpt", + variable_type="Volume", +) +grad_density = SolverVariable( + name="solution.grad_density", + value=[float("NaN"), float("NaN"), float("NaN")] * u.kg / u.m**4, + solver_name="gradDensity", + variable_type="Volume", +) +grad_velocity_x = SolverVariable( + name="solution.grad_velocity_x", + value=[float("NaN"), float("NaN"), float("NaN")] / u.s, + solver_name="gradVelocityX", + variable_type="Volume", +) +grad_velocity_y = SolverVariable( + name="solution.grad_velocity_y", + value=[float("NaN"), float("NaN"), float("NaN")] / u.s, + solver_name="gradVelocityY", + variable_type="Volume", +) +grad_velocity_z = SolverVariable( + name="solution.grad_velocity_z", + value=[float("NaN"), float("NaN"), float("NaN")] / u.s, + solver_name="gradVelocityZ", + variable_type="Volume", +) +grad_pressure = SolverVariable( + name="solution.grad_pressure", + value=[float("NaN"), float("NaN"), float("NaN")] * u.Pa / u.m, + solver_name="gradPressure", + variable_type="Volume", +) + +Mach = SolverVariable( + name="solution.Mach", + value=float("NaN"), + solver_name="Mach", + variable_type="Volume", +) +mut = SolverVariable( + name="solution.mut", + value=float("NaN") * u.kg / u.m / u.s, + solver_name="mut", + variable_type="Volume", +) # Turbulent viscosity +mu = SolverVariable( + name="solution.mu", + value=float("NaN") * u.kg / u.m / u.s, + solver_name="mu", + variable_type="Volume", +) # Laminar viscosity +mut_ratio = SolverVariable( + name="solution.mut_ratio", + value=float("NaN"), + solver_name="mutRatio", + variable_type="Volume", +) +nu_hat = SolverVariable( + name="solution.nu_hat", + value=float("NaN") * u.m**2 / u.s, + solver_name="SpalartAllmaras_solution", + variable_type="Volume", +) +turbulence_kinetic_energy = SolverVariable( + name="solution.turbulence_kinetic_energy", + value=float("NaN") * u.J / u.kg, + solver_name="kOmegaSST_solution[0]", + variable_type="Volume", +) # k +specific_rate_of_dissipation = SolverVariable( + name="solution.specific_rate_of_dissipation", + value=float("NaN") / u.s, + solver_name="kOmegaSST_solution[1]", + variable_type="Volume", +) # Omega +amplification_factor = SolverVariable( + name="solution.amplification_factor", + value=float("NaN"), + solver_name="solutionTransition[0]", + variable_type="Volume", +) # transition model variable: n, non-dimensional +turbulence_intermittency = SolverVariable( + name="solution.turbulence_intermittency", + value=float("NaN"), + solver_name="solutionTransition[1]", + variable_type="Volume", +) # transition model variable: gamma, non-dimensional + + +density = SolverVariable( + name="solution.density", + value=float("NaN") * u.kg / u.m**3, + solver_name="primitiveVars[0]", + variable_type="Volume", +) velocity = SolverVariable( name="solution.velocity", value=[float("NaN"), float("NaN"), float("NaN")] * u.m / u.s, + solver_name="velocity", + variable_type="Volume", +) +pressure = SolverVariable( + name="solution.pressure", + value=float("NaN") * u.Pa, + solver_name="primitiveVars[4]", + variable_type="Volume", ) -bet_thrust = SolverVariable( - name="solution.bet_thrust", value=float("NaN") -) # Thrust force for BET disk -bet_torque = SolverVariable(name="solution.bet_torque", value=float("NaN")) # Torque for BET disk -bet_omega = SolverVariable( - name="solution.bet_omega", value=float("NaN") -) # Rotation speed for BET disk -CD = SolverVariable(name="solution.CD", value=float("NaN")) # Drag coefficient on patch -CL = SolverVariable(name="solution.CL", value=float("NaN")) # Lift coefficient on patch -forceX = SolverVariable(name="solution.forceX", value=float("NaN")) # Total force in X direction -forceY = SolverVariable(name="solution.forceY", value=float("NaN")) # Total force in Y direction -forceZ = SolverVariable(name="solution.forceZ", value=float("NaN")) # Total force in Z direction -momentX = SolverVariable(name="solution.momentX", value=float("NaN")) # Total moment in X direction -momentY = SolverVariable(name="solution.momentY", value=float("NaN")) # Total moment in Y direction -momentZ = SolverVariable(name="solution.momentZ", value=float("NaN")) # Total moment in Z direction -nodeNormals = SolverVariable( - name="solution.nodeNormals", value=float("NaN") -) # Normal vector of patch -wallFunctionMetric = SolverVariable( - name="solution.wallFunctionMetric", value=float("NaN") -) # Wall model quality indicator -wallShearStress = SolverVariable( - name="solution.wallShearStress", value=float("NaN") -) # Wall viscous shear stress -yPlus = SolverVariable(name="solution.yPlus", value=float("NaN")) # Non-dimensional wall distance +qcriterion = SolverVariable( + name="solution.qcriterion", + value=float("NaN") / u.s**2, + solver_name="qcriterion", + variable_type="Volume", +) +entropy = SolverVariable( + name="solution.entropy", + value=float("NaN") * u.J / u.K, + solver_name="entropy", + variable_type="Volume", +) +temperature = SolverVariable( + name="solution.temperature", + value=float("NaN") * u.K, + solver_name="temperature", + variable_type="Volume", +) +vorticity = SolverVariable( + name="solution.vorticity", + value=[float("NaN"), float("NaN"), float("NaN")] / u.s, + solver_name="vorticity", + variable_type="Volume", +) +wall_distance = SolverVariable( + name="solution.wall_distance", + value=float("NaN") * u.m, + solver_name="wallDistance", + variable_type="Volume", +) + +# Surface +CfVec = SolverVariable( + name="solution.CfVec", + value=[float("NaN"), float("NaN"), float("NaN")], + solver_name="CfVec", + variable_type="Surface", +) +Cf = SolverVariable( + name="solution.Cf", + value=float("NaN"), + solver_name="Cf", + variable_type="Surface", +) +heatflux = SolverVariable( + name="solution.heatflux", + value=float("NaN") * u.W / u.m**2, + solver_name="heatFlux", + variable_type="Surface", +) +node_normals = SolverVariable( + name="solution.node_normals", + value=[float("NaN"), float("NaN"), float("NaN")], + solver_name="nodeNormals", + variable_type="Surface", +) +node_forces_per_unit_area = SolverVariable( + name="solution.node_forces_per_unit_area", + value=[float("NaN"), float("NaN"), float("NaN")] * u.Pa, + solver_name="nodeForcesPerUnitArea", + variable_type="Surface", +) +y_plus = SolverVariable( + name="solution.y_plus", value=float("NaN"), solver_name="yPlus", variable_type="Surface" +) +wall_shear_stress = SolverVariable( + name="solution.wall_shear_stress", + value=[float("NaN"), float("NaN"), float("NaN")] * u.Pa, + solver_name="wallShearStress", + variable_type="Surface", +) +heat_transfer_coefficient_static_temperature = SolverVariable( + name="solution.heat_transfer_coefficient_static_temperature", + value=float("NaN") * u.W / (u.m**2 * u.K), + solver_name="heatTransferCoefficientStaticTemperature", + variable_type="Surface", +) +heat_transfer_coefficient_total_temperature = SolverVariable( + name="solution.heat_transfer_coefficient_total_temperature", + value=float("NaN") * u.W / (u.m**2 * u.K), + solver_name="heatTransferCoefficientTotalTemperature", + variable_type="Surface", +) + + +# TODO +# pylint:disable = fixme +# velocity_relative = SolverVariable( +# name="solution.velocity_relative", +# value=[float("NaN"), float("NaN"), float("NaN")] * u.m / u.s, +# solver_name="velocityRelative", +# prepending_code="double velocityRelative[3];for(int i=0;i<3;i++)" +# + "{velocityRelative[i]=velocity[i]-nodeVelocity[i];}", +# variable_type="Volume", +# ) +# wallFunctionMetric = SolverVariable( +# name="solution.wallFunctionMetric", value=float("NaN"), variable_type="Surface" +# ) +# bet_metrics_alpha_degree = SolverVariable( +# name="solution.bet_metrics_alpha_degree", value=float("NaN") * u.deg, variable_type="Volume" +# ) +# bet_metrics_Cf_axial = SolverVariable( +# name="solution.bet_metrics_Cf_axial", value=float("NaN"), variable_type="Volume" +# ) +# bet_metrics_Cf_circumferential = SolverVariable( +# name="solution.bet_metrics_Cf_circumferential", value=float("NaN"), variable_type="Volume" +# ) +# bet_metrics_local_solidity_integral_weight = SolverVariable( +# name="solution.bet_metrics_local_solidity_integral_weight", +# value=float("NaN"), +# variable_type="Volume", +# ) +# bet_metrics_tip_loss_factor = SolverVariable( +# name="solution.bet_metrics_tip_loss_factor", value=float("NaN"), variable_type="Volume" +# ) +# bet_metrics_velocity_relative = SolverVariable( +# name="solution.bet_metrics_velocity_relative", +# value=[float("NaN"), float("NaN"), float("NaN")] * u.m / u.s, +# variable_type="Volume", +# ) +# betMetricsPerDisk = SolverVariable( +# name="solution.betMetricsPerDisk", value=float("NaN"), variable_type="Volume" +# ) + + +# Abandoned (Possible) +# SpalartAllmaras_hybridModel = SolverVariable( +# name="solution.SpalartAllmaras_hybridModel", value=float("NaN"), variable_type="Volume" +# ) +# kOmegaSST_hybridModel = SolverVariable( +# name="solution.kOmegaSST_hybridModel", value=float("NaN"), variable_type="Volume" +# ) +# localCFL = SolverVariable(name="solution.localCFL", value=float("NaN"), variable_type="Volume") +# numericalDissipationFactor = SolverVariable( +# name="solution.numericalDissipationFactor", value=float("NaN"), variable_type="Volume" +# ) +# lowMachPreconditionerSensor = SolverVariable( +# name="solution.lowMachPreconditionerSensor", value=float("NaN"), variable_type="Volume" +# ) + +# Abandoned +# linearResidualNavierStokes = SolverVariable( +# name="solution.linearResidualNavierStokes", value=float("NaN"), variable_type="Volume" +# ) +# linearResidualTurbulence = SolverVariable( +# name="solution.linearResidualTurbulence", value=float("NaN"), variable_type="Volume" +# ) +# linearResidualTransition = SolverVariable( +# name="solution.linearResidualTransition", value=float("NaN"), variable_type="Volume" +# ) +# residualNavierStokes = SolverVariable( +# name="solution.residualNavierStokes", value=float("NaN"), variable_type="Volume" +# ) +# residualTransition = SolverVariable( +# name="solution.residualTransition", value=float("NaN"), variable_type="Volume" +# ) +# residualTurbulence = SolverVariable( +# name="solution.residualTurbulence", value=float("NaN"), variable_type="Volume" +# ) +# solutionNavierStokes = SolverVariable( +# name="solution.solutionNavierStokes", value=float("NaN"), variable_type="Volume" +# ) +# solutionTurbulence = SolverVariable( +# name="solution.solutionTurbulence", value=float("NaN"), variable_type="Volume" +# ) +# residualHeatSolver = SolverVariable( +# name="solution.residualHeatSolver", value=float("NaN"), variable_type="Volume" +# ) +# velocity_x = SolverVariable(name="solution.velocity_x", value=float("NaN"), variable_type="Volume") +# velocity_y = SolverVariable(name="solution.velocity_y", value=float("NaN"), variable_type="Volume") +# velocity_z = SolverVariable(name="solution.velocity_z", value=float("NaN"), variable_type="Volume") +# velocity_magnitude = SolverVariable( +# name="solution.velocity_magnitude", value=float("NaN"), variable_type="Volume" +# ) +# vorticityMagnitude = SolverVariable( +# name="solution.vorticityMagnitude", value=float("NaN"), variable_type="Volume" +# ) +# vorticity_x = SolverVariable( +# name="solution.vorticity_x", value=float("NaN"), variable_type="Volume" +# ) +# vorticity_y = SolverVariable( +# name="solution.vorticity_y", value=float("NaN"), variable_type="Volume" +# ) +# vorticity_z = SolverVariable( +# name="solution.vorticity_z", value=float("NaN"), variable_type="Volume" +# ) +# wall_shear_stress_magnitude_pa = SolverVariable( +# name="solution.wall_shear_stress_magnitude_pa", value=float("NaN"), variable_type="Surface" +# ) diff --git a/tests/simulation/test_expressions.py b/tests/simulation/test_expressions.py index 926b5f041..1cccd22bd 100644 --- a/tests/simulation/test_expressions.py +++ b/tests/simulation/test_expressions.py @@ -405,9 +405,9 @@ class TestModel(Flow360BaseModel): x = UserVariable(name="x", value=4) - model = TestModel(field=x * u.m + solution.kOmega * u.cm) + model = TestModel(field=x * u.m + solution.y_plus * u.cm) - assert str(model.field) == "x * u.m + solution.kOmega * u.cm" + assert str(model.field) == "x * u.m + solution.y_plus * u.cm" # Raises when trying to evaluate with a message about this variable being blacklisted with pytest.raises(ValueError): @@ -903,7 +903,7 @@ def test_cross_function_use_case(): ) 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]))})" + == "std::vector({(((2 * 0.1) * nodePosition[2]) - ((1 * 0.1) * nodePosition[1])), (((1 * 0.1) * nodePosition[0]) - ((3 * 0.1) * nodePosition[2])), (((3 * 0.1) * nodePosition[1]) - ((2 * 0.1) * nodePosition[0]))})" ) print("\n1.1 Python mode but arg swapped\n") @@ -916,7 +916,7 @@ def test_cross_function_use_case(): ) 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))})" + == "std::vector({(((nodePosition[1] * 1) * 0.1) - ((nodePosition[2] * 2) * 0.1)), (((nodePosition[2] * 3) * 0.1) - ((nodePosition[0] * 1) * 0.1)), (((nodePosition[0] * 2) * 0.1) - ((nodePosition[1] * 3) * 0.1))})" ) print("\n2 Taking advantage of unyt as much as possible\n") @@ -937,7 +937,7 @@ def test_cross_function_use_case(): ) 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]))})" + == "std::vector({(((2 * 0.1) * nodePosition[2]) - ((1 * 0.1) * nodePosition[1])), (((1 * 0.1) * nodePosition[0]) - ((3 * 0.1) * nodePosition[2])), (((3 * 0.1) * nodePosition[1]) - ((2 * 0.1) * nodePosition[0]))})" ) print("\n5 Recursive cross in Python mode\n") @@ -950,7 +950,7 @@ def test_cross_function_use_case(): ) 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))})" + == "std::vector({((((((1 * 0.1) * nodePosition[0]) - ((3 * 0.1) * nodePosition[2])) * 1) * 0.1) - (((((3 * 0.1) * nodePosition[1]) - ((2 * 0.1) * nodePosition[0])) * 2) * 0.1)), ((((((3 * 0.1) * nodePosition[1]) - ((2 * 0.1) * nodePosition[0])) * 3) * 0.1) - (((((2 * 0.1) * nodePosition[2]) - ((1 * 0.1) * nodePosition[1])) * 1) * 0.1)), ((((((2 * 0.1) * nodePosition[2]) - ((1 * 0.1) * nodePosition[1])) * 2) * 0.1) - (((((1 * 0.1) * nodePosition[0]) - ((3 * 0.1) * nodePosition[2])) * 3) * 0.1))})" ) print("\n6 Recursive cross in String mode\n") @@ -964,7 +964,7 @@ def test_cross_function_use_case(): ) 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))})" + == "std::vector({((((((1 * 0.1) * nodePosition[0]) - ((3 * 0.1) * nodePosition[2])) * 1) * 0.1) - (((((3 * 0.1) * nodePosition[1]) - ((2 * 0.1) * nodePosition[0])) * 2) * 0.1)), ((((((3 * 0.1) * nodePosition[1]) - ((2 * 0.1) * nodePosition[0])) * 3) * 0.1) - (((((2 * 0.1) * nodePosition[2]) - ((1 * 0.1) * nodePosition[1])) * 1) * 0.1)), ((((((2 * 0.1) * nodePosition[2]) - ((1 * 0.1) * nodePosition[1])) * 2) * 0.1) - (((((1 * 0.1) * nodePosition[0]) - ((3 * 0.1) * nodePosition[2])) * 3) * 0.1))})" ) print("\n7 Using other variabels in Python mode\n") @@ -978,7 +978,7 @@ def test_cross_function_use_case(): ) 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))})" + == "std::vector({((((((1 * 0.1) * nodePosition[0]) - ((3 * 0.1) * nodePosition[2])) * 1) * 0.1) - (((((3 * 0.1) * nodePosition[1]) - ((2 * 0.1) * nodePosition[0])) * 2) * 0.1)), ((((((3 * 0.1) * nodePosition[1]) - ((2 * 0.1) * nodePosition[0])) * 3) * 0.1) - (((((2 * 0.1) * nodePosition[2]) - ((1 * 0.1) * nodePosition[1])) * 1) * 0.1)), ((((((2 * 0.1) * nodePosition[2]) - ((1 * 0.1) * nodePosition[1])) * 2) * 0.1) - (((((1 * 0.1) * nodePosition[0]) - ((3 * 0.1) * nodePosition[2])) * 3) * 0.1))})" ) print("\n8 Using other constant variabels in Python mode\n") @@ -992,7 +992,7 @@ def test_cross_function_use_case(): ) 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]))})" + == "std::vector({(((2 * 0.1) * nodePosition[2]) - ((1 * 0.1) * nodePosition[1])), (((1 * 0.1) * nodePosition[0]) - ((3 * 0.1) * nodePosition[2])), (((3 * 0.1) * nodePosition[1]) - ((2 * 0.1) * nodePosition[0]))})" ) print("\n9 Using non-unyt_array\n") @@ -1006,7 +1006,7 @@ def test_cross_function_use_case(): ) 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]))})" + == "std::vector({(((2 * 0.1) * nodePosition[2]) - ((1 * 0.1) * nodePosition[1])), (((1 * 0.1) * nodePosition[0]) - ((3 * 0.1) * nodePosition[2])), (((3 * 0.1) * nodePosition[1]) - ((2 * 0.1) * nodePosition[0]))})" ) @@ -1043,7 +1043,7 @@ def test_to_file_from_file_expression( outputs=[ VolumeOutput( output_fields=[ - solution.mut.in_unit(new_name="mut_in_SI"), + solution.mut.in_unit(new_name="mut_in_SI", new_unit="cm**2/min"), constant_variable, constant_array, constant_unyt_quantity, @@ -1083,16 +1083,17 @@ def test_udf_generator(): # velocity scale = 100 m/s, assert ( result.expression - == "velocity_in_SI[0] = (solution.velocity[0] * 100.0); velocity_in_SI[1] = (solution.velocity[1] * 100.0); velocity_in_SI[2] = (solution.velocity[2] * 100.0);" + == "double velocity[3];velocity[0] = primitiveVars[1] * velocityScale;velocity[1] = primitiveVars[2] * velocityScale;velocity[2] = primitiveVars[3] * velocityScale;velocity_in_SI[0] = (velocity[0] * 100.0); velocity_in_SI[1] = (velocity[1] * 100.0); velocity_in_SI[2] = (velocity[2] * 100.0);" ) vel_cross_vec = UserVariable( name="vel_cross_vec", value=math.cross(solution.velocity, [1, 2, 3] * u.cm) ).in_unit(new_unit="m*km/s/s") result = user_variable_to_udf(vel_cross_vec, input_params=params) + print("3>>> result.expression", result.expression) assert ( result.expression - == "vel_cross_vec[0] = ((((solution.velocity[1] * 3) * 0.001) - ((solution.velocity[2] * 2) * 0.001)) * 10.0); vel_cross_vec[1] = ((((solution.velocity[2] * 1) * 0.001) - ((solution.velocity[0] * 3) * 0.001)) * 10.0); vel_cross_vec[2] = ((((solution.velocity[0] * 2) * 0.001) - ((solution.velocity[1] * 1) * 0.001)) * 10.0);" + == "double velocity[3];velocity[0] = primitiveVars[1] * velocityScale;velocity[1] = primitiveVars[2] * velocityScale;velocity[2] = primitiveVars[3] * velocityScale;vel_cross_vec[0] = ((((velocity[1] * 3) * 0.001) - ((velocity[2] * 2) * 0.001)) * 10.0); vel_cross_vec[1] = ((((velocity[2] * 1) * 0.001) - ((velocity[0] * 3) * 0.001)) * 10.0); vel_cross_vec[2] = ((((velocity[0] * 2) * 0.001) - ((velocity[1] * 1) * 0.001)) * 10.0);" ) vel_cross_vec = UserVariable( diff --git a/tests/simulation/translator/ref/Flow360_expression_udf.json b/tests/simulation/translator/ref/Flow360_expression_udf.json new file mode 100644 index 000000000..e1f50b6ed --- /dev/null +++ b/tests/simulation/translator/ref/Flow360_expression_udf.json @@ -0,0 +1,113 @@ +{ + "freestream": { + "alphaAngle": 0.0, + "betaAngle": 0.0, + "Mach": 0.8399999999999999, + "Temperature": 288.15, + "muRef": 4.292321046986499e-08 + }, + "timeStepping": { + "CFL": { + "type": "adaptive", + "min": 0.1, + "max": 10000.0, + "maxRelativeChange": 1.0, + "convergenceLimitingFactor": 0.25 + }, + "physicalSteps": 1, + "orderOfAccuracy": 2, + "maxPseudoSteps": 2000, + "timeStepSize": "inf" + }, + "navierStokesSolver": { + "absoluteTolerance": 1e-10, + "relativeTolerance": 0.0, + "orderOfAccuracy": 2, + "linearSolver": { + "maxIterations": 30 + }, + "CFLMultiplier": 1.0, + "kappaMUSCL": -1.0, + "numericalDissipationFactor": 1.0, + "limitVelocity": false, + "limitPressureDensity": false, + "lowMachPreconditioner": false, + "updateJacobianFrequency": 4, + "maxForceJacUpdatePhysicalSteps": 0, + "modelType": "Compressible", + "equationEvalFrequency": 1 + }, + "turbulenceModelSolver": { + "absoluteTolerance": 1e-08, + "relativeTolerance": 0.0, + "orderOfAccuracy": 2, + "linearSolver": { + "maxIterations": 20 + }, + "CFLMultiplier": 2.0, + "reconstructionGradientLimiter": 0.5, + "quadraticConstitutiveRelation": false, + "updateJacobianFrequency": 4, + "maxForceJacUpdatePhysicalSteps": 0, + "rotationCorrection": false, + "equationEvalFrequency": 4, + "modelType": "SpalartAllmaras", + "modelConstants": { + "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 + }, + "DDES": false, + "ZDES": false, + "gridSizeForLES": "maxEdgeLength" + }, + "initialCondition": { + "type": "initialCondition", + "rho": "rho", + "u": "u", + "v": "v", + "w": "w", + "p": "p" + }, + "boundaries": {}, + "volumeOutput": { + "outputFields": [ + "Mach", + "velocity", + "uuu" + ], + "outputFormat": "paraview", + "computeTimeAverages": false, + "animationFrequency": -1, + "animationFrequencyOffset": 0, + "animationFrequencyTimeAverage": -1, + "animationFrequencyTimeAverageOffset": 0, + "startAverageIntegrationStep": -1 + }, + "userDefinedFields": [ + { + "name": "Mach", + "expression": "double Mach = usingLiquidAsMaterial ? 0 : sqrt(primitiveVars[1] * primitiveVars[1] + primitiveVars[2] * primitiveVars[2] + primitiveVars[3] * primitiveVars[3]) / sqrt(1.4 * primitiveVars[4] / primitiveVars[0]);Mach = (Mach * 1);" + }, + { + "name": "velocity", + "expression": "double velocity[3];velocity[0] = primitiveVars[1] * velocityScale;velocity[1] = primitiveVars[2] * velocityScale;velocity[2] = primitiveVars[3] * velocityScale;velocity[0] = (velocity[0] * 340.2940058082124); velocity[1] = (velocity[1] * 340.2940058082124); velocity[2] = (velocity[2] * 340.2940058082124);" + }, + { + "name": "uuu", + "expression": "double velocity[3];velocity[0] = primitiveVars[1] * velocityScale;velocity[1] = primitiveVars[2] * velocityScale;velocity[2] = primitiveVars[3] * velocityScale;uuu[0] = (velocity[0] * 340.2940058082124); uuu[1] = (velocity[1] * 340.2940058082124); uuu[2] = (velocity[2] * 340.2940058082124);" + } + ], + "usingLiquidAsMaterial": false, + "outputRescale": { + "velocityScale": 1.0 + } +} \ No newline at end of file diff --git a/tests/simulation/translator/test_output_translation.py b/tests/simulation/translator/test_output_translation.py index 03ccf2ecd..8f69bb811 100644 --- a/tests/simulation/translator/test_output_translation.py +++ b/tests/simulation/translator/test_output_translation.py @@ -1254,7 +1254,7 @@ def test_dimensioned_output_fields_translation(vel_in_km_per_hr): }, { "name": "velocity_in_km_per_hr", - "expression": "velocity_in_km_per_hr[0] = (solution.velocity[0] * 3600.0); velocity_in_km_per_hr[1] = (solution.velocity[1] * 3600.0); velocity_in_km_per_hr[2] = (solution.velocity[2] * 3600.0);", + "expression": "double velocity[3];velocity[0] = primitiveVars[1] * velocityScale;velocity[1] = primitiveVars[2] * velocityScale;velocity[2] = primitiveVars[3] * velocityScale;velocity_in_km_per_hr[0] = (velocity[0] * 3600.0); velocity_in_km_per_hr[1] = (velocity[1] * 3600.0); velocity_in_km_per_hr[2] = (velocity[2] * 3600.0);", }, { "name": "velocity_m_per_s", diff --git a/tests/simulation/translator/test_solver_translator.py b/tests/simulation/translator/test_solver_translator.py index a99ee9254..e2c11ccb5 100644 --- a/tests/simulation/translator/test_solver_translator.py +++ b/tests/simulation/translator/test_solver_translator.py @@ -52,6 +52,8 @@ from flow360.component.simulation.time_stepping.time_stepping import RampCFL, Steady from flow360.component.simulation.translator.solver_translator import get_solver_json from flow360.component.simulation.unit_system import SI_unit_system +from flow360.component.simulation.user_code.core.types import UserVariable +from flow360.component.simulation.user_code.variables import solution from tests.simulation.translator.utils.actuator_disk_param_generator import ( actuator_disk_create_param, ) @@ -501,6 +503,26 @@ def test_user_defined_field(): ) translate_and_compare(param, mesh_unit=1 * u.m, ref_json_file="Flow360_udf.json") + with SI_unit_system: + param = SimulationParams( + operating_condition=AerospaceCondition.from_mach( + mach=0.84, + ), + outputs=[ + VolumeOutput( + name="output", + output_fields=[ + solution.Mach, + solution.velocity, + UserVariable(name="uuu", value=solution.velocity), + ], + ) + ], + ) + translated = get_solver_json(param, mesh_unit=1 * u.m) + print("-----------------\n", json.dumps(translated, indent=4)) + translate_and_compare(param, mesh_unit=1 * u.m, ref_json_file="Flow360_expression_udf.json") + def test_boundaries(): operating_condition = AerospaceCondition.from_mach( From 14f1c0f4c5cac47e5d1a4da75c0d11a00231d852 Mon Sep 17 00:00:00 2001 From: Angran Date: Mon, 16 Jun 2025 21:42:37 -0400 Subject: [PATCH 26/34] Separate prepending code to declaration and computation parts (#1165) * Initial implementation * More variable finished * Fix unit test * Fix pylint * Fix the issue that solver name is not used * Move prepending code to translator * Fix prepending code * Fix deserialize issue that output units is not loaded * self review of prepending code * typo fix * Address comments * fix grad pressure's prepending code * Added support for solver variable in output_fields and also fixed a bug when translating Mach (#1160) * Replace wall shear stress with its magnitude, add unit test to whitelisted_callables * Fix coordinate, remove mu * Fix the scaling of turbulence solution * Fix the unit conversion for a list of float * Add missing vorticty magnitude * remove declaration when user-specified name is the same as the solver_name * Add velocity magnitude * Address comments * formatting --------- Co-authored-by: Ben <106089368+benflexcompute@users.noreply.github.com> --- .../translator/solver_translator.py | 115 ++--------- .../translator/user_expression_utils.py | 180 ++++++++++++++++++ .../simulation/user_code/core/context.py | 3 +- .../simulation/user_code/core/types.py | 6 +- .../user_code/variables/solution.py | 22 +-- tests/simulation/test_expressions.py | 30 ++- .../ref/Flow360_expression_udf.json | 4 +- 7 files changed, 236 insertions(+), 124 deletions(-) create mode 100644 flow360/component/simulation/translator/user_expression_utils.py diff --git a/flow360/component/simulation/translator/solver_translator.py b/flow360/component/simulation/translator/solver_translator.py index 7ae353b14..e6405d95a 100644 --- a/flow360/component/simulation/translator/solver_translator.py +++ b/flow360/component/simulation/translator/solver_translator.py @@ -77,6 +77,9 @@ from flow360.component.simulation.primitives import Box, SurfacePair from flow360.component.simulation.simulation_params import SimulationParams from flow360.component.simulation.time_stepping.time_stepping import Steady, Unsteady +from flow360.component.simulation.translator.user_expression_utils import ( + udf_prepending_code, +) from flow360.component.simulation.translator.utils import ( _get_key_name, convert_tuples_to_lists, @@ -97,95 +100,6 @@ ) from flow360.exceptions import Flow360TranslationError -udf_prepending_code = { - "solution.Cp": "double Cp = (primitiveVars[4] - pressureFreestream) / (0.5 * MachRef * MachRef);", - "solution.Cpt": "double MachUser = sqrt(primitiveVars[1] * primitiveVars[1] + " - + "primitiveVars[2] * primitiveVars[2] + primitiveVars[3] * primitiveVars[3])" - + "/sqrt(1.4 * primitiveVars[4] / primitiveVars[0]);" - + "double Cpt = (1.4 * primitiveVars[4] * pow(1.0 + (1.4 - 1.0) / 2. * MachUser * MachUser," - + "1.4 / (1.4 - 1.0)) - pow(1.0 + (1.4 - 1.0) / 2. * MachRef * MachRef," - + "1.4 / (1.4 - 1.0))) / (0.5 * 1.4 * MachRef * MachRef);", - "solution.grad_density": "double gradDensity[3] = {gradPrimitive[0][0], " - + "gradPrimitive[0][1], gradPrimitive[0][2]};", - "solution.grad_velocity_x": "double gradVelocityX[3] = {gradPrimitive[1][0], " - + " gradPrimitive[1][1], gradPrimitive[1][2]};", - "solution.grad_velocity_y": "double gradVelocityY[3] = {gradPrimitive[2][0]," - + "gradPrimitive[2][1], gradPrimitive[2][2]};", - "solution.grad_velocity_z": "double gradVelocityZ[3] = {gradPrimitive[3][0], " - + "gradPrimitive[3][1], gradPrimitive[3][2]};", - "solution.grad_pressure": "double gradPressure[3] = {gradPrimitive[4][0], " - + "gradPrimitive[4][1], gradPrimitive[4][2]};", - "solution.Mach": "double Mach = usingLiquidAsMaterial ? 0 : " - + "sqrt(primitiveVars[1] * primitiveVars[1] + " - + "primitiveVars[2] * primitiveVars[2] + " - + "primitiveVars[3] * primitiveVars[3]) / " - + "sqrt(1.4 * primitiveVars[4] / primitiveVars[0]);", - "solution.mut_ratio": "double mutRatio;mutRatio = mut / mu;", - "solution.velocity": "double velocity[3];" - + "velocity[0] = primitiveVars[1] * velocityScale;" - + "velocity[1] = primitiveVars[2] * velocityScale;" - + "velocity[2] = primitiveVars[3] * velocityScale;", - "solution.qcriterion": "double qcriterion;" - + "double ux = gradPrimitive[1][0];" - + "double uy = gradPrimitive[1][1];" - + "double uz = gradPrimitive[1][2];" - + "double vx = gradPrimitive[2][0];" - + "double vy = gradPrimitive[2][1];" - + "double vz = gradPrimitive[2][2];" - + "double wx = gradPrimitive[3][0];" - + "double wy = gradPrimitive[3][1];" - + "double wz = gradPrimitive[3][2];" - + "double str11 = ux;" - + "double str22 = vy;" - + "double str33 = wz;" - + "double str12 = 0.5 * (uy + vx);" - + "double str13 = 0.5 * (uz + wx);" - + "double str23 = 0.5 * (vz + wy);" - + "double str_norm = str11 * str11 + str22 * str22 + str33 * str33 + " - + "2 * (str12 * str12) + 2 * (str13 * str13) + 2 * (str23 * str23);" - + "double omg12 = 0.5 * (uy - vx);" - + "double omg13 = 0.5 * (uz - wx);" - + "double omg23 = 0.5 * (vz - wy);" - + "double omg_norm = 2 * (omg12 * omg12) + 2 * (omg13 * omg13) + 2 * (omg23 * omg23);" - + "qcriterion = 0.5 * (omg_norm - str_norm) * (velocityScale * velocityScale);", - "solution.entropy": "double entropy;entropy = log(primitiveVars[4] / (1.0 / 1.4) / pow(primitiveVars[0], 1.4));", - "solution.temperature": f"double epsilon = {np.finfo(np.float64).eps};" - "double temperature = (primitiveVars[0] < epsilon && HeatEquation_solution != nullptr) ? " - "HeatEquation_solution[0] : primitiveVars[4] / (primitiveVars[0] * (1.0 / 1.4));", - "solution.vorticity": "double vorticity[3];" - + "vorticity[0] = (gradPrimitive[3][1] - gradPrimitive[2][2]) * velocityScale;" - + "vorticity[1] = (gradPrimitive[1][2] - gradPrimitive[3][0]) * velocityScale;" - + "vorticity[2] = (gradPrimitive[2][0] - gradPrimitive[1][1]) * velocityScale;", - "solution.CfVec": "double CfVec[3];" - + "for (int i = 0; i < 3; i++)" - + "{CfVec[i] = wallShearStress[i] / (0.5 * MachRef * MachRef);}", - "solution.Cf": "double Cf;Cf = magnitude(wallShearStress) / (0.5 * MachRef * MachRef);", - "solution.node_forces_per_unit_area": "double nodeForcesPerUnitArea[3];" - + "double normalMag = magnitude(nodeNormals);" - + "for (int i = 0; i < 3; i++){nodeForcesPerUnitArea[i] = " - + "((primitiveVars[4] - pressureFreestream) * nodeNormals[i] / normalMag + wallViscousStress[i])" - + " * (velocityScale * velocityScale);}", - "solution.heat_transfer_coefficient_static_temperature": "double temperature = " - + "primitiveVars[4] / (primitiveVars[0] * 1.0 / 1.4);" - + f"double temperatureSafeDivide; double epsilon = {np.finfo(np.float64).eps};" - + "temperatureSafeDivide = (temperature - 1.0 < 0) ? " - + "temperature - 1.0 - epsilon : " - + "temperature - 1.0 + epsilon;" - + "double heatTransferCoefficientStaticTemperature = " - + "abs(temperature - 1.0) > epsilon ? " - + "- heatFlux / temperatureSafeDivide : 1.0 / epsilon;", - "solution.heat_transfer_coefficient_total_temperature": "double temperature = " - + "primitiveVars[4] / (primitiveVars[0] * 1.0 / 1.4);" - + "double temperatureTotal = 1.0 + (1.4 - 1.0) / 2.0 * MachRef * MachRef;" - + f"double temperatureSafeDivide; double epsilon = {np.finfo(np.float64).eps};" - + "temperatureSafeDivide = (temperature - temperatureTotal < 0) ? " - + "temperature - temperatureTotal - epsilon : " - + "temperature - temperatureTotal + epsilon;" - + "double heatTransferCoefficientTotalTemperature = " - + "abs(temperature - temperatureTotal) > epsilon ? " - + "temperatureTotal = - heatFlux / temperatureSafeDivide : 1.0 / epsilon;", -} - def dump_dict(input_params): """Dumping param/model to dictionary.""" @@ -660,6 +574,22 @@ def _compute_coefficient_and_offset(source_unit: u.Unit, target_unit: u.Unit): return coefficient, offset + def _prepare_prepending_code(expression: Expression): + prepending_code = [] + for name in expression.solver_variable_names(): + if not udf_prepending_code.get(name): + continue + if name.split(".")[-1] == variable.name: + # Avoid duplicate declaration if the intermediate variable name is + # the same as the solver_name. + prepending_code.append(udf_prepending_code[name]["computation"]) + continue + prepending_code.append( + udf_prepending_code[name]["declaration"] + udf_prepending_code[name]["computation"] + ) + prepending_code = "".join(prepending_code) + return prepending_code + expression: Expression = variable.value requested_unit: Union[u.Unit, None] = expression.get_output_units(input_params=input_params) @@ -677,12 +607,7 @@ def _compute_coefficient_and_offset(source_unit: u.Unit, target_unit: u.Unit): ) expression_length = expression.length - prepending_code = [ - udf_prepending_code[name] - for name in expression.solver_variable_names() - if udf_prepending_code.get(name) - ] - prepending_code = "".join(prepending_code) + prepending_code = _prepare_prepending_code(expression=expression) if expression_length == 1: expression = expression.evaluate(raise_on_non_evaluable=False, force_evaluate=False) diff --git a/flow360/component/simulation/translator/user_expression_utils.py b/flow360/component/simulation/translator/user_expression_utils.py new file mode 100644 index 000000000..846a49c11 --- /dev/null +++ b/flow360/component/simulation/translator/user_expression_utils.py @@ -0,0 +1,180 @@ +"""Utilities for user expression translation.""" + +import numpy as np + +udf_prepending_code = { + "solution.Cp": { + "declaration": "double Cp;", + "computation": "Cp = (primitiveVars[4] - pressureFreestream) / (0.5 * MachRef * MachRef);", + }, + "solution.Cpt": { + "declaration": "double Cpt;", + "computation": "double MachTmp = sqrt(primitiveVars[1] * primitiveVars[1] + " + + "primitiveVars[2] * primitiveVars[2] + primitiveVars[3] * primitiveVars[3]) / " + + "sqrt(1.4 * primitiveVars[4] / primitiveVars[0]);" + + "Cpt = (1.4 * primitiveVars[4] * pow(1.0 + (1.4 - 1.0) / 2. * MachTmp * MachTmp," + + "1.4 / (1.4 - 1.0)) - pow(1.0 + (1.4 - 1.0) / 2. * MachRef * MachRef," + + "1.4 / (1.4 - 1.0))) / (0.5 * 1.4 * MachRef * MachRef);", + }, + "solution.grad_density": { + "declaration": "double gradDensity[3];", + "computation": " gradDensity[0] = gradPrimitive[0][0];" + + "gradDensity[1] = gradPrimitive[0][1];" + + "gradDensity[2] = gradPrimitive[0][2];", + }, + "solution.grad_velocity_x": { + "declaration": "double gradVelocityX[3];", + "computation": "gradVelocityX[0] = gradPrimitive[1][0] * velocityScale;" + + "gradVelocityX[1] = gradPrimitive[1][1] * velocityScale;" + + "gradVelocityX[2] = gradPrimitive[1][2] * velocityScale;", + }, + "solution.grad_velocity_y": { + "declaration": "double gradVelocityY[3];", + "computation": "gradVelocityY[0] = gradPrimitive[2][0] * velocityScale;" + + "gradVelocityY[1] = gradPrimitive[2][1] * velocityScale;" + + "gradVelocityY[2] = gradPrimitive[2][2] * velocityScale;", + }, + "solution.grad_velocity_z": { + "declaration": "double gradVelocityZ[3];", + "computation": "gradVelocityZ[0] = gradPrimitive[3][0] * velocityScale;" + + "gradVelocityZ[1] = gradPrimitive[3][1] * velocityScale;" + + "gradVelocityZ[2] = gradPrimitive[3][2] * velocityScale;", + }, + "solution.grad_pressure": { + "declaration": "double gradPressure[3];", + "computation": "gradPressure[0] = gradPrimitive[4][0]; " + + "gradPressure[1] = gradPrimitive[4][1]; " + + "gradPressure[2] = gradPrimitive[4][2];", + }, + "solution.Mach": { + "declaration": "double Mach;", + "computation": "Mach = usingLiquidAsMaterial ? 0 : " + + "sqrt(primitiveVars[1] * primitiveVars[1] + " + + "primitiveVars[2] * primitiveVars[2] + " + + "primitiveVars[3] * primitiveVars[3]) / " + + "sqrt(1.4 * primitiveVars[4] / primitiveVars[0]);", + }, + "solution.mut_ratio": { + "declaration": "double mutRatio;", + "computation": "mutRatio = mut / mu;", + }, + "solution.nu_hat": { + "declaration": "double nuHat;", + "computation": "nuHat = SpalartAllmaras_solution * velocityScale;", + }, + "solution.turbulence_kinetic_energy": { + "declaration": "double turbulenceKineticEnergy;", + "computation": "turbulenceKineticEnergy = kOmegaSST_solution[0] * pow(velocityScale, 2);", + }, + "solution.specific_rate_of_dissipation": { + "declaration": "double specificRateOfDissipation;", + "computation": "specificRateOfDissipation = kOmegaSST_solution[1] * velocityScale;", + }, + "solution.velocity": { + "declaration": "double velocity[3];", + "computation": "velocity[0] = primitiveVars[1] * velocityScale;" + + "velocity[1] = primitiveVars[2] * velocityScale;" + + "velocity[2] = primitiveVars[3] * velocityScale;", + }, + "solution.velocity_magnitude": { + "declaration": "double velocityMagnitude;", + "computation": "double velocityTmp[3];velocityTmp[0] = primitiveVars[1] * velocityScale;" + + "velocityTmp[1] = primitiveVars[2] * velocityScale;" + + "velocityTmp[2] = primitiveVars[3] * velocityScale;" + + "velocityMagnitude = magnitude(velocityTmp);", + }, + "solution.qcriterion": { + "declaration": "double qcriterion;", + "computation": "double ux = gradPrimitive[1][0];" + + "double uy = gradPrimitive[1][1];" + + "double uz = gradPrimitive[1][2];" + + "double vx = gradPrimitive[2][0];" + + "double vy = gradPrimitive[2][1];" + + "double vz = gradPrimitive[2][2];" + + "double wx = gradPrimitive[3][0];" + + "double wy = gradPrimitive[3][1];" + + "double wz = gradPrimitive[3][2];" + + "double str11 = ux;" + + "double str22 = vy;" + + "double str33 = wz;" + + "double str12 = 0.5 * (uy + vx);" + + "double str13 = 0.5 * (uz + wx);" + + "double str23 = 0.5 * (vz + wy);" + + "double str_norm = str11 * str11 + str22 * str22 + str33 * str33 + " + + "2 * (str12 * str12) + 2 * (str13 * str13) + 2 * (str23 * str23);" + + "double omg12 = 0.5 * (uy - vx);" + + "double omg13 = 0.5 * (uz - wx);" + + "double omg23 = 0.5 * (vz - wy);" + + "double omg_norm = 2 * (omg12 * omg12) + 2 * (omg13 * omg13) + 2 * (omg23 * omg23);" + + "qcriterion = 0.5 * (omg_norm - str_norm) * (velocityScale * velocityScale);", + }, + "solution.entropy": { + "declaration": "double entropy;", + "computation": "entropy = log(primitiveVars[4] / (1.0 / 1.4) / pow(primitiveVars[0], 1.4));", + }, + "solution.temperature": { + "declaration": "double temperature;", + "computation": f"double epsilon = {np.finfo(np.float64).eps};" + "temperature = (primitiveVars[0] < epsilon && HeatEquation_solution != nullptr) ? " + "HeatEquation_solution[0] : primitiveVars[4] / (primitiveVars[0] * (1.0 / 1.4));", + }, + "solution.vorticity": { + "declaration": "double vorticity[3];", + "computation": "vorticity[0] = (gradPrimitive[3][1] - gradPrimitive[2][2]) * velocityScale;" + + "vorticity[1] = (gradPrimitive[1][2] - gradPrimitive[3][0]) * velocityScale;" + + "vorticity[2] = (gradPrimitive[2][0] - gradPrimitive[1][1]) * velocityScale;", + }, + "solution.vorticity_magnitude": { + "declaration": "double vorticityMagnitude;", + "computation": "double vorticityTmp[3];" + + "vorticityTmp[0] = (gradPrimitive[3][1] - gradPrimitive[2][2]) * velocityScale;" + + "vorticityTmp[1] = (gradPrimitive[1][2] - gradPrimitive[3][0]) * velocityScale;" + + "vorticityTmp[2] = (gradPrimitive[2][0] - gradPrimitive[1][1]) * velocityScale;" + + "vorticityMagnitude = magnitude(vorticityTmp);", + }, + "solution.CfVec": { + "declaration": "double CfVec[3];", + "computation": "for (int i = 0; i < 3; i++)" + + "{CfVec[i] = wallShearStress[i] / (0.5 * MachRef * MachRef);}", + }, + "solution.Cf": { + "declaration": "double Cf;", + "computation": "Cf = magnitude(wallShearStress) / (0.5 * MachRef * MachRef);", + }, + "solution.node_forces_per_unit_area": { + "declaration": "double nodeForcesPerUnitArea[3];", + "computation": "double normalMag = magnitude(nodeNormals);" + + "for (int i = 0; i < 3; i++){nodeForcesPerUnitArea[i] = " + + "((primitiveVars[4] - pressureFreestream) * nodeNormals[i] / normalMag + wallViscousStress[i])" + + " * (velocityScale * velocityScale);}", + }, + "solution.heat_transfer_coefficient_static_temperature": { + "declaration": "double heatTransferCoefficientStaticTemperature;", + "computation": "double temperatureTmp = " + + "primitiveVars[4] / (primitiveVars[0] * 1.0 / 1.4);" + + f"double epsilon = {np.finfo(np.float64).eps};" + + "double temperatureSafeDivide = (temperatureTmp - 1.0 < 0) ? " + + "temperatureTmp - 1.0 - epsilon : " + + "temperatureTmp - 1.0 + epsilon;" + + "heatTransferCoefficientStaticTemperature = " + + "abs(temperatureTmp - 1.0) > epsilon ? " + + "- wallHeatFlux / temperatureSafeDivide : 1.0 / epsilon;", + }, + "solution.heat_transfer_coefficient_total_temperature": { + "declaration": "double heatTransferCoefficientTotalTemperature;", + "computation": "double temperatureTmp = " + + "primitiveVars[4] / (primitiveVars[0] * 1.0 / 1.4);" + + "double temperatureTotal = 1.0 + (1.4 - 1.0) / 2.0 * MachRef * MachRef;" + + f"double epsilon = {np.finfo(np.float64).eps};" + + "double temperatureSafeDivide = (temperatureTmp - temperatureTotal < 0) ? " + + "temperatureTmp - temperatureTotal - epsilon : " + + "temperatureTmp - temperatureTotal + epsilon;" + + "double heatTransferCoefficientTotalTemperature = " + + "abs(temperatureTmp - temperatureTotal) > epsilon ? " + + "temperatureTotal = - wallHeatFlux / temperatureSafeDivide : 1.0 / epsilon;", + }, + "solution.wall_shear_stress_magnitude": { + "declaration": "double wallShearStressMagnitude;", + "computation": "wallShearStressMagnitude = magnitude(wallShearStress);", + }, +} diff --git a/flow360/component/simulation/user_code/core/context.py b/flow360/component/simulation/user_code/core/context.py index 2846d7e85..eb064c574 100644 --- a/flow360/component/simulation/user_code/core/context.py +++ b/flow360/component/simulation/user_code/core/context.py @@ -94,7 +94,6 @@ def _import_solution(_) -> Any: "grad_pressure", "Mach", "mut", - "mu", "mut_ratio", "nu_hat", "turbulence_kinetic_energy", @@ -115,7 +114,7 @@ def _import_solution(_) -> Any: "node_normals", "node_forces_per_unit_area", "y_plus", - "wall_shear_stress", + "wall_shear_stress_magnitude", "heat_transfer_coefficient_static_temperature", "heat_transfer_coefficient_total_temperature", ], diff --git a/flow360/component/simulation/user_code/core/types.py b/flow360/component/simulation/user_code/core/types.py index 66f71f0df..db90dae82 100644 --- a/flow360/component/simulation/user_code/core/types.py +++ b/flow360/component/simulation/user_code/core/types.py @@ -647,11 +647,11 @@ def get_output_units(self, input_params=None): def get_unit_from_unit_system(expression: Expression, unit_system_name: str): """Derive the default output unit based on the value's dimensionality and current unit system""" numerical_value = expression.evaluate(raise_on_non_evaluable=False, force_evaluate=True) - if not isinstance(numerical_value, (u.unyt_array, u.unyt_quantity, list)): - # Pure dimensionless constant - return None if isinstance(numerical_value, list): numerical_value = numerical_value[0] + if not isinstance(numerical_value, (u.unyt_array, u.unyt_quantity)): + # Pure dimensionless constant + return None if unit_system_name in ("SI", "SI_unit_system"): return numerical_value.in_base("mks").units diff --git a/flow360/component/simulation/user_code/variables/solution.py b/flow360/component/simulation/user_code/variables/solution.py index f155d65d2..4aaf2aff0 100644 --- a/flow360/component/simulation/user_code/variables/solution.py +++ b/flow360/component/simulation/user_code/variables/solution.py @@ -28,7 +28,7 @@ coordinate = SolverVariable( name="solution.coordinate", value=[float("NaN"), float("NaN"), float("NaN")] * u.m, - solver_name="nodePosition", + solver_name="coordinate", variable_type="Volume", ) # Grid coordinates @@ -87,12 +87,6 @@ solver_name="mut", variable_type="Volume", ) # Turbulent viscosity -mu = SolverVariable( - name="solution.mu", - value=float("NaN") * u.kg / u.m / u.s, - solver_name="mu", - variable_type="Volume", -) # Laminar viscosity mut_ratio = SolverVariable( name="solution.mut_ratio", value=float("NaN"), @@ -102,19 +96,19 @@ nu_hat = SolverVariable( name="solution.nu_hat", value=float("NaN") * u.m**2 / u.s, - solver_name="SpalartAllmaras_solution", + solver_name="nuHat", variable_type="Volume", ) turbulence_kinetic_energy = SolverVariable( name="solution.turbulence_kinetic_energy", value=float("NaN") * u.J / u.kg, - solver_name="kOmegaSST_solution[0]", + solver_name="turbulenceKineticEnergy", variable_type="Volume", ) # k specific_rate_of_dissipation = SolverVariable( name="solution.specific_rate_of_dissipation", value=float("NaN") / u.s, - solver_name="kOmegaSST_solution[1]", + solver_name="specificRateOfDissipation", variable_type="Volume", ) # Omega amplification_factor = SolverVariable( @@ -215,10 +209,10 @@ y_plus = SolverVariable( name="solution.y_plus", value=float("NaN"), solver_name="yPlus", variable_type="Surface" ) -wall_shear_stress = SolverVariable( - name="solution.wall_shear_stress", - value=[float("NaN"), float("NaN"), float("NaN")] * u.Pa, - solver_name="wallShearStress", +wall_shear_stress_magnitude = SolverVariable( + name="solution.wall_shear_stress_magnitude", + value=float("NaN") * u.Pa, + solver_name="wallShearStressMagnitude", variable_type="Surface", ) heat_transfer_coefficient_static_temperature = SolverVariable( diff --git a/tests/simulation/test_expressions.py b/tests/simulation/test_expressions.py index 1cccd22bd..bdb6692de 100644 --- a/tests/simulation/test_expressions.py +++ b/tests/simulation/test_expressions.py @@ -18,6 +18,7 @@ ) from flow360.component.simulation.framework.base_model import Flow360BaseModel from flow360.component.simulation.framework.param_utils import AssetCache +from flow360.component.simulation.framework.updater_utils import compare_lists from flow360.component.simulation.models.material import Water, aluminum from flow360.component.simulation.outputs.outputs import SurfaceOutput, VolumeOutput from flow360.component.simulation.primitives import ( @@ -59,8 +60,10 @@ VelocityType, ViscosityType, ) +from flow360.component.simulation.user_code.core.context import WHITELISTED_CALLABLES from flow360.component.simulation.user_code.core.types import ( Expression, + SolverVariable, UserVariable, ValueOrExpression, ) @@ -903,7 +906,7 @@ def test_cross_function_use_case(): ) assert ( a.value.to_solver_code(params) - == "std::vector({(((2 * 0.1) * nodePosition[2]) - ((1 * 0.1) * nodePosition[1])), (((1 * 0.1) * nodePosition[0]) - ((3 * 0.1) * nodePosition[2])), (((3 * 0.1) * nodePosition[1]) - ((2 * 0.1) * nodePosition[0]))})" + == "std::vector({(((2 * 0.1) * coordinate[2]) - ((1 * 0.1) * coordinate[1])), (((1 * 0.1) * coordinate[0]) - ((3 * 0.1) * coordinate[2])), (((3 * 0.1) * coordinate[1]) - ((2 * 0.1) * coordinate[0]))})" ) print("\n1.1 Python mode but arg swapped\n") @@ -916,7 +919,7 @@ def test_cross_function_use_case(): ) assert ( a.value.to_solver_code(params) - == "std::vector({(((nodePosition[1] * 1) * 0.1) - ((nodePosition[2] * 2) * 0.1)), (((nodePosition[2] * 3) * 0.1) - ((nodePosition[0] * 1) * 0.1)), (((nodePosition[0] * 2) * 0.1) - ((nodePosition[1] * 3) * 0.1))})" + == "std::vector({(((coordinate[1] * 1) * 0.1) - ((coordinate[2] * 2) * 0.1)), (((coordinate[2] * 3) * 0.1) - ((coordinate[0] * 1) * 0.1)), (((coordinate[0] * 2) * 0.1) - ((coordinate[1] * 3) * 0.1))})" ) print("\n2 Taking advantage of unyt as much as possible\n") @@ -937,7 +940,7 @@ def test_cross_function_use_case(): ) assert ( a.value.to_solver_code(params) - == "std::vector({(((2 * 0.1) * nodePosition[2]) - ((1 * 0.1) * nodePosition[1])), (((1 * 0.1) * nodePosition[0]) - ((3 * 0.1) * nodePosition[2])), (((3 * 0.1) * nodePosition[1]) - ((2 * 0.1) * nodePosition[0]))})" + == "std::vector({(((2 * 0.1) * coordinate[2]) - ((1 * 0.1) * coordinate[1])), (((1 * 0.1) * coordinate[0]) - ((3 * 0.1) * coordinate[2])), (((3 * 0.1) * coordinate[1]) - ((2 * 0.1) * coordinate[0]))})" ) print("\n5 Recursive cross in Python mode\n") @@ -950,7 +953,7 @@ def test_cross_function_use_case(): ) assert ( a.value.to_solver_code(params) - == "std::vector({((((((1 * 0.1) * nodePosition[0]) - ((3 * 0.1) * nodePosition[2])) * 1) * 0.1) - (((((3 * 0.1) * nodePosition[1]) - ((2 * 0.1) * nodePosition[0])) * 2) * 0.1)), ((((((3 * 0.1) * nodePosition[1]) - ((2 * 0.1) * nodePosition[0])) * 3) * 0.1) - (((((2 * 0.1) * nodePosition[2]) - ((1 * 0.1) * nodePosition[1])) * 1) * 0.1)), ((((((2 * 0.1) * nodePosition[2]) - ((1 * 0.1) * nodePosition[1])) * 2) * 0.1) - (((((1 * 0.1) * nodePosition[0]) - ((3 * 0.1) * nodePosition[2])) * 3) * 0.1))})" + == "std::vector({((((((1 * 0.1) * coordinate[0]) - ((3 * 0.1) * coordinate[2])) * 1) * 0.1) - (((((3 * 0.1) * coordinate[1]) - ((2 * 0.1) * coordinate[0])) * 2) * 0.1)), ((((((3 * 0.1) * coordinate[1]) - ((2 * 0.1) * coordinate[0])) * 3) * 0.1) - (((((2 * 0.1) * coordinate[2]) - ((1 * 0.1) * coordinate[1])) * 1) * 0.1)), ((((((2 * 0.1) * coordinate[2]) - ((1 * 0.1) * coordinate[1])) * 2) * 0.1) - (((((1 * 0.1) * coordinate[0]) - ((3 * 0.1) * coordinate[2])) * 3) * 0.1))})" ) print("\n6 Recursive cross in String mode\n") @@ -964,7 +967,7 @@ def test_cross_function_use_case(): ) assert ( a.value.to_solver_code(params) - == "std::vector({((((((1 * 0.1) * nodePosition[0]) - ((3 * 0.1) * nodePosition[2])) * 1) * 0.1) - (((((3 * 0.1) * nodePosition[1]) - ((2 * 0.1) * nodePosition[0])) * 2) * 0.1)), ((((((3 * 0.1) * nodePosition[1]) - ((2 * 0.1) * nodePosition[0])) * 3) * 0.1) - (((((2 * 0.1) * nodePosition[2]) - ((1 * 0.1) * nodePosition[1])) * 1) * 0.1)), ((((((2 * 0.1) * nodePosition[2]) - ((1 * 0.1) * nodePosition[1])) * 2) * 0.1) - (((((1 * 0.1) * nodePosition[0]) - ((3 * 0.1) * nodePosition[2])) * 3) * 0.1))})" + == "std::vector({((((((1 * 0.1) * coordinate[0]) - ((3 * 0.1) * coordinate[2])) * 1) * 0.1) - (((((3 * 0.1) * coordinate[1]) - ((2 * 0.1) * coordinate[0])) * 2) * 0.1)), ((((((3 * 0.1) * coordinate[1]) - ((2 * 0.1) * coordinate[0])) * 3) * 0.1) - (((((2 * 0.1) * coordinate[2]) - ((1 * 0.1) * coordinate[1])) * 1) * 0.1)), ((((((2 * 0.1) * coordinate[2]) - ((1 * 0.1) * coordinate[1])) * 2) * 0.1) - (((((1 * 0.1) * coordinate[0]) - ((3 * 0.1) * coordinate[2])) * 3) * 0.1))})" ) print("\n7 Using other variabels in Python mode\n") @@ -978,7 +981,7 @@ def test_cross_function_use_case(): ) assert ( a.value.to_solver_code(params) - == "std::vector({((((((1 * 0.1) * nodePosition[0]) - ((3 * 0.1) * nodePosition[2])) * 1) * 0.1) - (((((3 * 0.1) * nodePosition[1]) - ((2 * 0.1) * nodePosition[0])) * 2) * 0.1)), ((((((3 * 0.1) * nodePosition[1]) - ((2 * 0.1) * nodePosition[0])) * 3) * 0.1) - (((((2 * 0.1) * nodePosition[2]) - ((1 * 0.1) * nodePosition[1])) * 1) * 0.1)), ((((((2 * 0.1) * nodePosition[2]) - ((1 * 0.1) * nodePosition[1])) * 2) * 0.1) - (((((1 * 0.1) * nodePosition[0]) - ((3 * 0.1) * nodePosition[2])) * 3) * 0.1))})" + == "std::vector({((((((1 * 0.1) * coordinate[0]) - ((3 * 0.1) * coordinate[2])) * 1) * 0.1) - (((((3 * 0.1) * coordinate[1]) - ((2 * 0.1) * coordinate[0])) * 2) * 0.1)), ((((((3 * 0.1) * coordinate[1]) - ((2 * 0.1) * coordinate[0])) * 3) * 0.1) - (((((2 * 0.1) * coordinate[2]) - ((1 * 0.1) * coordinate[1])) * 1) * 0.1)), ((((((2 * 0.1) * coordinate[2]) - ((1 * 0.1) * coordinate[1])) * 2) * 0.1) - (((((1 * 0.1) * coordinate[0]) - ((3 * 0.1) * coordinate[2])) * 3) * 0.1))})" ) print("\n8 Using other constant variabels in Python mode\n") @@ -992,7 +995,7 @@ def test_cross_function_use_case(): ) assert ( a.value.to_solver_code(params) - == "std::vector({(((2 * 0.1) * nodePosition[2]) - ((1 * 0.1) * nodePosition[1])), (((1 * 0.1) * nodePosition[0]) - ((3 * 0.1) * nodePosition[2])), (((3 * 0.1) * nodePosition[1]) - ((2 * 0.1) * nodePosition[0]))})" + == "std::vector({(((2 * 0.1) * coordinate[2]) - ((1 * 0.1) * coordinate[1])), (((1 * 0.1) * coordinate[0]) - ((3 * 0.1) * coordinate[2])), (((3 * 0.1) * coordinate[1]) - ((2 * 0.1) * coordinate[0]))})" ) print("\n9 Using non-unyt_array\n") @@ -1006,7 +1009,7 @@ def test_cross_function_use_case(): ) assert ( a.value.to_solver_code(params) - == "std::vector({(((2 * 0.1) * nodePosition[2]) - ((1 * 0.1) * nodePosition[1])), (((1 * 0.1) * nodePosition[0]) - ((3 * 0.1) * nodePosition[2])), (((3 * 0.1) * nodePosition[1]) - ((2 * 0.1) * nodePosition[0]))})" + == "std::vector({(((2 * 0.1) * coordinate[2]) - ((1 * 0.1) * coordinate[1])), (((1 * 0.1) * coordinate[0]) - ((3 * 0.1) * coordinate[2])), (((3 * 0.1) * coordinate[1]) - ((2 * 0.1) * coordinate[0]))})" ) @@ -1119,3 +1122,14 @@ def test_project_variables(): ], ) assert params.private_attribute_asset_cache.project_variables == [aaa] + + +def test_whitelisted_callables(): + def get_user_variable_names(module): + return [attr for attr in dir(module) if isinstance(getattr(module, attr), SolverVariable)] + + solution_vars = get_user_variable_names(solution) + control_vars = get_user_variable_names(control) + + assert compare_lists(solution_vars, WHITELISTED_CALLABLES["flow360.solution"]["callables"]) + assert compare_lists(control_vars, WHITELISTED_CALLABLES["flow360.control"]["callables"]) diff --git a/tests/simulation/translator/ref/Flow360_expression_udf.json b/tests/simulation/translator/ref/Flow360_expression_udf.json index e1f50b6ed..884469911 100644 --- a/tests/simulation/translator/ref/Flow360_expression_udf.json +++ b/tests/simulation/translator/ref/Flow360_expression_udf.json @@ -95,11 +95,11 @@ "userDefinedFields": [ { "name": "Mach", - "expression": "double Mach = usingLiquidAsMaterial ? 0 : sqrt(primitiveVars[1] * primitiveVars[1] + primitiveVars[2] * primitiveVars[2] + primitiveVars[3] * primitiveVars[3]) / sqrt(1.4 * primitiveVars[4] / primitiveVars[0]);Mach = (Mach * 1);" + "expression": "Mach = usingLiquidAsMaterial ? 0 : sqrt(primitiveVars[1] * primitiveVars[1] + primitiveVars[2] * primitiveVars[2] + primitiveVars[3] * primitiveVars[3]) / sqrt(1.4 * primitiveVars[4] / primitiveVars[0]);Mach = (Mach * 1);" }, { "name": "velocity", - "expression": "double velocity[3];velocity[0] = primitiveVars[1] * velocityScale;velocity[1] = primitiveVars[2] * velocityScale;velocity[2] = primitiveVars[3] * velocityScale;velocity[0] = (velocity[0] * 340.2940058082124); velocity[1] = (velocity[1] * 340.2940058082124); velocity[2] = (velocity[2] * 340.2940058082124);" + "expression": "velocity[0] = primitiveVars[1] * velocityScale;velocity[1] = primitiveVars[2] * velocityScale;velocity[2] = primitiveVars[3] * velocityScale;velocity[0] = (velocity[0] * 340.2940058082124); velocity[1] = (velocity[1] * 340.2940058082124); velocity[2] = (velocity[2] * 340.2940058082124);" }, { "name": "uuu", From cb9171cd80dc0aa9348874faa7314075ef2a958d Mon Sep 17 00:00:00 2001 From: Ben <106089368+benflexcompute@users.noreply.github.com> Date: Mon, 16 Jun 2025 21:57:19 -0400 Subject: [PATCH 27/34] UserVariable as Token and value from context (#1161) * Implementation done, now checking pylint * Now writing the overwrite checker * Ready for self review --- flow360/component/project_utils.py | 4 + .../simulation/blueprint/core/context.py | 21 +- .../simulation/framework/param_utils.py | 14 +- .../component/simulation/outputs/outputs.py | 90 +++--- .../component/simulation/simulation_params.py | 6 - .../simulation/user_code/core/types.py | 199 ++++++++++--- .../validation_simulation_params.py | 14 - .../ref/simulation/service_init_geometry.json | 3 +- .../simulation/service_init_surface_mesh.json | 3 +- .../simulation/service_init_volume_mesh.json | 3 +- .../simulation/converter/ref/ref_monitor.json | 2 +- .../simulation_with_project_variables.json | 280 ++++++++++++++++++ tests/simulation/test_expressions.py | 183 +++++++----- .../translator/ref/Flow360_user_variable.json | 132 +++++++++ .../translator/test_solver_translator.py | 60 +++- 15 files changed, 810 insertions(+), 204 deletions(-) create mode 100644 tests/simulation/ref/simulation_with_project_variables.json create mode 100644 tests/simulation/translator/ref/Flow360_user_variable.json diff --git a/flow360/component/project_utils.py b/flow360/component/project_utils.py index fbd64021f..337749947 100644 --- a/flow360/component/project_utils.py +++ b/flow360/component/project_utils.py @@ -21,6 +21,7 @@ from flow360.component.simulation.primitives import Box, Cylinder, GhostSurface from flow360.component.simulation.simulation_params import SimulationParams from flow360.component.simulation.unit_system import LengthType +from flow360.component.simulation.user_code.core.types import save_user_variables from flow360.component.simulation.utils import model_attribute_unlock from flow360.component.simulation.web.asset_base import AssetBase from flow360.component.utils import parse_datetime @@ -281,6 +282,9 @@ def set_up_params_for_uploading( params = _set_up_default_reference_geometry(params, length_unit) + # Convert all reference of UserVariables to VariableToken + params = save_user_variables(params) + return params diff --git a/flow360/component/simulation/blueprint/core/context.py b/flow360/component/simulation/blueprint/core/context.py index 655e28e9f..a3b150c39 100644 --- a/flow360/component/simulation/blueprint/core/context.py +++ b/flow360/component/simulation/blueprint/core/context.py @@ -80,11 +80,17 @@ def get_data_model(self, name: str) -> Optional[pd.BaseModel]: return self._data_models[name] def set_alias(self, name, alias) -> None: - """Set alias used for code generation.""" + """ + Set alias used for code generation. + This is meant for non-user variables. + """ self._aliases[name] = alias def get_alias(self, name) -> Optional[str]: - """Get alias used for code generation.""" + """ + Get alias used for code generation. + This is meant for non-user variables. + """ return self._aliases.get(name) def set(self, name: str, value: Any, data_model: pd.BaseModel = None) -> None: @@ -94,7 +100,7 @@ def set(self, name: str, value: Any, data_model: pd.BaseModel = None) -> None: 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 + data_model (BaseModel, optional): The type of the associate with this entry (for non-user variables) """ self._values[name] = value @@ -137,3 +143,12 @@ def copy(self) -> "EvaluationContext": of the current variable values. """ return EvaluationContext(self._resolver, dict(self._values)) + + @property + def user_variable_names(self): + """Get the set of user variables in the context.""" + return {name for name in self._values.keys() if "." not in name} + + def clear(self): + """Clear user variables from the context.""" + self._values = {name: value for name, value in self._values.items() if "." in name} diff --git a/flow360/component/simulation/framework/param_utils.py b/flow360/component/simulation/framework/param_utils.py index a74bb24ec..82595ada3 100644 --- a/flow360/component/simulation/framework/param_utils.py +++ b/flow360/component/simulation/framework/param_utils.py @@ -1,6 +1,6 @@ """pre processing and post processing utilities for simulation parameters.""" -from typing import List, Optional, Union +from typing import Annotated, List, Optional, Union import pydantic as pd @@ -18,9 +18,17 @@ _VolumeEntityBase, ) from flow360.component.simulation.unit_system import LengthType -from flow360.component.simulation.user_code.core.types import UserVariable +from flow360.component.simulation.user_code.core.types import ( + VariableContextInfo, + update_global_context, +) from flow360.component.simulation.utils import model_attribute_unlock +VariableContextList = Annotated[ + List[VariableContextInfo], + pd.AfterValidator(update_global_context), +] + class AssetCache(Flow360BaseModel): """ @@ -39,7 +47,7 @@ class AssetCache(Flow360BaseModel): use_geometry_AI: bool = pd.Field( False, description="Flag whether user requested the use of GAI." ) - project_variables: Optional[List[UserVariable]] = pd.Field( + project_variables: Optional[VariableContextList] = pd.Field( None, description="List of user variables that are used in all the `Expression` instances." ) diff --git a/flow360/component/simulation/outputs/outputs.py b/flow360/component/simulation/outputs/outputs.py index c4ec80024..3321fe5be 100644 --- a/flow360/component/simulation/outputs/outputs.py +++ b/flow360/component/simulation/outputs/outputs.py @@ -123,16 +123,26 @@ def _validate_non_liquid_output_fields(cls, value: UniqueItemList): ) return value - @pd.field_validator("output_fields", mode="after") + @pd.field_validator("output_fields", mode="before") @classmethod - def _convert_solver_variables_as_user_variables(cls, value: UniqueItemList): - for i, output_item in enumerate(value.items): - if isinstance(output_item, SolverVariable): - # Strip out any prefix before a dot in the name - name = ( - output_item.name.split(".")[-1] if "." in output_item.name else output_item.name - ) - value.items[i] = UserVariable(name=name, value=output_item) + def _convert_solver_variables_as_user_variables(cls, value): + # Handle both dict/list (deserialization) and UniqueItemList (python object) + def solver_variable_to_user_variable(item): + if isinstance(item, SolverVariable): + name = item.name.split(".")[-1] if "." in item.name else item.name + return UserVariable(name=name, value=item) + return item + + # If input is a dict (from deserialization so no SolverVariable expected) + if isinstance(value, dict): + return value + # If input is a list (from Python mode) + if isinstance(value, list): + return [solver_variable_to_user_variable(item) for item in value] + # If input is a UniqueItemList (python object) + if hasattr(value, "items") and isinstance(value.items, list): + value.items = [solver_variable_to_user_variable(item) for item in value.items] + return value return value @@ -209,11 +219,9 @@ class SurfaceOutput(_AnimationAndFileFormatSettings): + "Will choose the value of the last instance of this option of the same output type " + "(:class:`SurfaceOutput` or :class:`TimeAverageSurfaceOutput`) in the output list.", ) - output_fields: UniqueItemList[Union[SurfaceFieldNames, str, UserVariable, SolverVariable]] = ( - pd.Field( - description="List of output variables. Including :ref:`universal output variables`," - + " :ref:`variables specific to SurfaceOutput` and :class:`UserDefinedField`." - ) + output_fields: UniqueItemList[Union[SurfaceFieldNames, str, UserVariable]] = pd.Field( + description="List of output variables. Including :ref:`universal output variables`," + + " :ref:`variables specific to SurfaceOutput` and :class:`UserDefinedField`." ) output_type: Literal["SurfaceOutput"] = pd.Field("SurfaceOutput", frozen=True) @@ -278,12 +286,10 @@ class VolumeOutput(_AnimationAndFileFormatSettings): """ name: Optional[str] = pd.Field("Volume output", description="Name of the `VolumeOutput`.") - output_fields: UniqueItemList[Union[VolumeFieldNames, str, UserVariable, SolverVariable]] = ( - pd.Field( - description="List of output variables. Including :ref:`universal output variables`," - " :ref:`variables specific to VolumeOutput`" - " and :class:`UserDefinedField`." - ) + output_fields: UniqueItemList[Union[VolumeFieldNames, str, UserVariable]] = pd.Field( + description="List of output variables. Including :ref:`universal output variables`," + " :ref:`variables specific to VolumeOutput`" + " and :class:`UserDefinedField`." ) output_type: Literal["VolumeOutput"] = pd.Field("VolumeOutput", frozen=True) @@ -348,12 +354,10 @@ class SliceOutput(_AnimationAndFileFormatSettings): alias="slices", description="List of output :class:`~flow360.Slice` entities.", ) - output_fields: UniqueItemList[Union[SliceFieldNames, str, UserVariable, SolverVariable]] = ( - pd.Field( - description="List of output variables. Including :ref:`universal output variables`," - " :ref:`variables specific to SliceOutput`" - " and :class:`UserDefinedField`." - ) + output_fields: UniqueItemList[Union[SliceFieldNames, str, UserVariable]] = pd.Field( + description="List of output variables. Including :ref:`universal output variables`," + " :ref:`variables specific to SliceOutput`" + " and :class:`UserDefinedField`." ) output_type: Literal["SliceOutput"] = pd.Field("SliceOutput", frozen=True) @@ -436,11 +440,9 @@ class IsosurfaceOutput(_AnimationAndFileFormatSettings): alias="isosurfaces", description="List of :class:`~flow360.Isosurface` entities.", ) - output_fields: UniqueItemList[Union[CommonFieldNames, str, UserVariable, SolverVariable]] = ( - pd.Field( - description="List of output variables. Including " - ":ref:`universal output variables` and :class:`UserDefinedField`." - ) + output_fields: UniqueItemList[Union[CommonFieldNames, str, UserVariable]] = pd.Field( + description="List of output variables. Including " + ":ref:`universal output variables` and :class:`UserDefinedField`." ) output_type: Literal["IsosurfaceOutput"] = pd.Field("IsosurfaceOutput", frozen=True) @@ -475,7 +477,7 @@ class SurfaceIntegralOutput(_OutputBase): alias="surfaces", description="List of boundaries where the surface integral will be calculated.", ) - output_fields: UniqueItemList[Union[str, UserVariable, SolverVariable]] = pd.Field( + output_fields: UniqueItemList[Union[str, UserVariable]] = pd.Field( description="List of output variables, only the :class:`UserDefinedField` is allowed." ) output_type: Literal["SurfaceIntegralOutput"] = pd.Field("SurfaceIntegralOutput", frozen=True) @@ -539,11 +541,9 @@ class ProbeOutput(_OutputBase): + "monitor group. :class:`~flow360.PointArray` is used to " + "define monitored points along a line.", ) - output_fields: UniqueItemList[Union[CommonFieldNames, str, UserVariable, SolverVariable]] = ( - pd.Field( - description="List of output fields. Including :ref:`universal output variables`" - " and :class:`UserDefinedField`." - ) + output_fields: UniqueItemList[Union[CommonFieldNames, str, UserVariable]] = pd.Field( + description="List of output fields. Including :ref:`universal output variables`" + " and :class:`UserDefinedField`." ) output_type: Literal["ProbeOutput"] = pd.Field("ProbeOutput", frozen=True) @@ -605,11 +605,9 @@ class SurfaceProbeOutput(Flow360BaseModel): + "entities belonging to this monitor group." ) - output_fields: UniqueItemList[Union[SurfaceFieldNames, str, UserVariable, SolverVariable]] = ( - pd.Field( - description="List of output variables. Including :ref:`universal output variables`," - " :ref:`variables specific to SurfaceOutput` and :class:`UserDefinedField`." - ) + output_fields: UniqueItemList[Union[SurfaceFieldNames, str, UserVariable]] = pd.Field( + description="List of output variables. Including :ref:`universal output variables`," + " :ref:`variables specific to SurfaceOutput` and :class:`UserDefinedField`." ) output_type: Literal["SurfaceProbeOutput"] = pd.Field("SurfaceProbeOutput", frozen=True) @@ -636,11 +634,9 @@ class SurfaceSliceOutput(_AnimationAndFileFormatSettings): output_format: Literal["paraview"] = pd.Field(default="paraview") - output_fields: UniqueItemList[Union[SurfaceFieldNames, str, UserVariable, SolverVariable]] = ( - pd.Field( - description="List of output variables. Including :ref:`universal output variables`," - " :ref:`variables specific to SurfaceOutput` and :class:`UserDefinedField`." - ) + output_fields: UniqueItemList[Union[SurfaceFieldNames, str, UserVariable]] = pd.Field( + description="List of output variables. Including :ref:`universal output variables`," + " :ref:`variables specific to SurfaceOutput` and :class:`UserDefinedField`." ) output_type: Literal["SurfaceSliceOutput"] = pd.Field("SurfaceSliceOutput", frozen=True) diff --git a/flow360/component/simulation/simulation_params.py b/flow360/component/simulation/simulation_params.py index 29762337b..b5a182598 100644 --- a/flow360/component/simulation/simulation_params.py +++ b/flow360/component/simulation/simulation_params.py @@ -94,7 +94,6 @@ _check_time_average_output, _check_unsteadiness_to_use_hybrid_model, _check_valid_models_for_liquid, - _save_project_variables, ) from flow360.component.utils import remove_properties_by_name from flow360.error_messages import ( @@ -451,11 +450,6 @@ def check_duplicate_user_defined_fields(cls, v): return v - @pd.model_validator(mode="after") - def save_project_variables(self): - """Populate project variables private attribute used in the simulation params""" - return _save_project_variables(self) - @pd.model_validator(mode="after") def check_cht_solver_settings(self): """Check the Conjugate Heat Transfer settings, transferred from checkCHTSolverSettings""" diff --git a/flow360/component/simulation/user_code/core/types.py b/flow360/component/simulation/user_code/core/types.py index db90dae82..4ceb96017 100644 --- a/flow360/component/simulation/user_code/core/types.py +++ b/flow360/component/simulation/user_code/core/types.py @@ -26,10 +26,37 @@ split_keep_delimiters, ) -_user_variables: set[str] = set() _solver_variables: set[str] = set() +class VariableContextInfo(Flow360BaseModel): + """Variable context info for project variables.""" + + name: str + value: ValueOrExpression[AnyNumericType] + + +def save_user_variables(params): + """ + Save user variables to the project variables. + Declared here since I do not want to import default_context everywhere. + """ + params.private_attribute_asset_cache.project_variables = [ + VariableContextInfo(name=name, value=value) + for name, value in default_context._values.items() # pylint: disable=protected-access + if "." not in name # Skipping scoped variables (non-user variables) + ] + return params + + +def update_global_context(value: List[VariableContextInfo]): + """Once the project variables are validated, update the global context.""" + + for item in value: + default_context.set(item.name, item.value) + return value + + def __soft_fail_add__(self, other): if not isinstance(other, Expression) and not isinstance(other, Variable): return np.ndarray.__add__(self, other) @@ -142,12 +169,14 @@ def check_vector_arithmetic(func): """Decorator to check if vector arithmetic is being attempted and raise an error if so.""" def wrapper(self, other): - vector_arithmetic = False - if isinstance(other, unyt_array) and other.shape != (): - vector_arithmetic = True - elif isinstance(other, list): - vector_arithmetic = True - if vector_arithmetic: + def is_array(item): + if isinstance(item, unyt_array) and item.shape != (): + return True + if isinstance(item, list): + return True + return False + + if is_array(self.value) or is_array(other): raise ValueError( f"Vector operation ({func.__name__} between {self.name} and {other}) not " "supported for variables. Please write expression for each component." @@ -157,14 +186,89 @@ def wrapper(self, other): return wrapper +def _check_cyclic_dependencies(*, variable_name: str) -> None: + visited = set() + stack = [(variable_name, [variable_name])] + while stack: + (current_name, current_path) = stack.pop() + 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]: + path_string = " -> ".join(current_path + [current_path[0]]) + details = InitErrorDetails( + type="value_error", + ctx={"error": f"Cyclic dependency between variables {path_string}"}, + ) + raise pd.ValidationError.from_exception_data("Variable value error", [details]) + stack.extend( + [(name, current_path + [name]) for name in used_names if name not in visited] + ) + + class Variable(Flow360BaseModel): """Base class representing a symbolic variable""" - name: str = pd.Field() - value: ValueOrExpression[AnyNumericType] = pd.Field() + name: str = pd.Field(frozen=True) model_config = pd.ConfigDict(validate_assignment=True, extra="allow") + @property + def value(self): + """ + Get the value of the variable from the global context. + """ + return default_context.get(self.name) + + @value.setter + def value(self, value): + """ + Set the value of the variable in the global context. + In parallel to `set_value` this supports syntax like `my_user_var.value = 10.0`. + """ + new_value = pd.TypeAdapter(ValueOrExpression[AnyNumericType]).validate_python(value) + # Not checking overwrite here since it is user controlled explicit assignment operation + default_context.set(self.name, new_value) + _check_cyclic_dependencies(variable_name=self.name) + + @pd.model_validator(mode="before") + @classmethod + def set_value(cls, values): + """ + Supporting syntax like `a = fl.Variable(name="a", value=1)`. + """ + if "name" not in values: + raise ValueError("`name` is required for variable declaration.") + + if "value" in values: + new_value = pd.TypeAdapter(ValueOrExpression[AnyNumericType]).validate_python( + values.pop("value") + ) + # Check overwriting, skip for solver variables: + if values["name"] in default_context.user_variable_names: + diff = new_value != default_context.get(values["name"]) + + if isinstance(diff, np.ndarray): + diff = diff.any() + + if isinstance(diff, list): + # Might not end up here but just in case + diff = any(diff) + + if diff: + raise ValueError( + f"Redeclaring user variable {values['name']} with new value: {new_value}. " + f"Previous value: {default_context.get(values['name'])}" + ) + # Call the setter + default_context.set( + values["name"], + new_value, + ) + _check_cyclic_dependencies(variable_name=values["name"]) + + return values + @check_vector_arithmetic def __add__(self, other): (arg, parenthesize) = _convert_argument(other) @@ -288,39 +392,23 @@ def __eq__(self, other): class UserVariable(Variable): """Class representing a user-defined symbolic variable""" - @pd.model_validator(mode="after") - def update_context(self): - """Auto updating context when new variable is declared""" - default_context.set(self.name, self.value) - _user_variables.add(self.name) - return self + name: str = pd.Field(frozen=True) - @pd.model_validator(mode="after") - def check_dependencies(self): - """Validator for ensuring no cyclic dependency.""" - visited = set() - stack = [(self.name, [self.name])] - while stack: - (current_name, current_path) = stack.pop() - 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]: - path_string = " -> ".join(current_path + [current_path[0]]) - details = InitErrorDetails( - type="value_error", - ctx={"error": f"Cyclic dependency between variables {path_string}"}, - ) - raise pd.ValidationError.from_exception_data("Variable value error", [details]) - stack.extend( - [(name, current_path + [name]) for name in used_names if name not in visited] - ) - return self + type_name: Literal["UserVariable"] = pd.Field("UserVariable", frozen=True) + + @pd.field_validator("name", mode="after") + @classmethod + def check_unscoped_name(cls, v): + """Ensure that the variable name is not scoped. Only solver side variables can be scoped.""" + if "." in v: + raise ValueError( + "User variable name cannot contain dots (scoped variables not supported)." + ) + return v def __hash__(self): """ Support for set and deduplicate. - Can be removed if not used directly in output_fields. """ return hash(self.model_dump_json()) @@ -335,6 +423,7 @@ def in_unit(self, new_unit: Union[str, Unit] = None): class SolverVariable(Variable): """Class representing a pre-defined symbolic variable that cannot be evaluated at client runtime""" + type_name: Literal["SolverVariable"] = pd.Field("SolverVariable", frozen=True) solver_name: Optional[str] = pd.Field(None) variable_type: Literal["Volume", "Surface", "Scalar"] = pd.Field() @@ -349,14 +438,15 @@ def update_context(self): def in_unit(self, new_name: str, new_unit: Union[str, Unit] = None): """ - - Return a UserVariable that will generate results in the new_unit. If new_unit is not specified then the unit will be determined by the unit system. """ if isinstance(new_unit, Unit): new_unit = str(new_unit) - new_variable = UserVariable(name=new_name, value=Expression(expression=self.name)) + new_variable = UserVariable( + name=new_name, + value=Expression(expression=self.name), + ) new_variable.value.output_units = new_unit # pylint:disable=assigning-non-slot return new_variable @@ -418,6 +508,12 @@ def _validate_expression(cls, value) -> Self: return {"expression": expression, "output_units": output_units} + @pd.field_validator("expression", mode="after") + @classmethod + def remove_leading_and_trailing_whitespace(cls, value: str) -> str: + """Remove leading and trailing whitespace from the expression""" + return value.strip() + def evaluate( self, context: EvaluationContext = None, @@ -450,7 +546,7 @@ def user_variables(self): """Get list of user variables used in expression.""" expr = expr_to_model(self.expression, default_context) names = expr.used_names() - names = [name for name in names if name in _user_variables] + names = [name for name in names if name in default_context.user_variable_names] return [UserVariable(name=name, value=default_context.get(name)) for name in names] @@ -458,7 +554,7 @@ def user_variable_names(self): """Get list of user variable names used in expression.""" expr = expr_to_model(self.expression, default_context) names = expr.used_names() - names = [name for name in names if name in _user_variables] + names = [name for name in names if name in default_context.user_variable_names] return names @@ -609,6 +705,11 @@ def __str__(self): def __repr__(self): return f"Expression({self.expression})" + def __eq__(self, other): + if isinstance(other, Expression): + return self.expression == other.expression + return super().__eq__(other) + @property def dimensionality(self): """The physical dimensionality of the expression.""" @@ -684,7 +785,7 @@ class ValueOrExpression(Expression, Generic[T]): def __class_getitem__(cls, typevar_values): # pylint:disable=too-many-statements def _internal_validator(value: Expression): try: - result = value.evaluate(raise_on_non_evaluable=False) + result = value.evaluate(raise_on_non_evaluable=False, force_evaluate=True) except Exception as err: raise ValueError(f"expression evaluation failed: {err}") from err pd.TypeAdapter(typevar_values).validate_python(result, context={"allow_inf_nan": True}) @@ -716,7 +817,15 @@ def _serializer(value, info) -> dict: serialized.expression = value.expression - evaluated = value.evaluate(raise_on_non_evaluable=False) + evaluated = value.evaluate(raise_on_non_evaluable=False, force_evaluate=True) + + if isinstance(evaluated, list): + # May result from Expression which is actually a list of expressions + try: + evaluated = u.unyt_array(evaluated) + except u.exceptions.IterableUnitCoercionError: + # Inconsistent units for components of list + pass if isinstance(evaluated, Number): serialized.evaluated_value = evaluated @@ -756,6 +865,8 @@ def _discriminator(v: Any) -> str: return v.get("typeName") if v.get("typeName") else v.get("type_name") if isinstance(v, (Expression, Variable, str)): return "expression" + if isinstance(v, list) and all(isinstance(item, Expression) for item in v): + return "expression" if isinstance(v, (Number, unyt_array, list)): return "number" raise KeyError("Unknown expression input type: ", v, v.__class__.__name__) diff --git a/flow360/component/simulation/validation/validation_simulation_params.py b/flow360/component/simulation/validation/validation_simulation_params.py index cf6722d7d..6f9b5179d 100644 --- a/flow360/component/simulation/validation/validation_simulation_params.py +++ b/flow360/component/simulation/validation/validation_simulation_params.py @@ -4,7 +4,6 @@ from typing import get_args -from flow360.component.simulation.framework.param_utils import find_instances from flow360.component.simulation.models.solver_numerics import NoneSolver from flow360.component.simulation.models.surface_models import ( Inflow, @@ -22,7 +21,6 @@ VolumeOutput, ) from flow360.component.simulation.time_stepping.time_stepping import Steady, Unsteady -from flow360.component.simulation.user_code.core.types import Expression from flow360.component.simulation.validation.validation_context import ( ALL, CASE, @@ -32,18 +30,6 @@ from flow360.component.simulation.validation.validation_utils import EntityUsageMap -def _save_project_variables(v): - expressions = find_instances(v, Expression) - user_variables = set() - - for expression in expressions: - user_variables = user_variables.union(expression.user_variables()) - - v.private_attribute_asset_cache.project_variables = list(user_variables) - - return v - - def _check_consistency_wall_function_and_surface_output(v): models = v.models diff --git a/tests/ref/simulation/service_init_geometry.json b/tests/ref/simulation/service_init_geometry.json index a18b68ebd..f66beb047 100644 --- a/tests/ref/simulation/service_init_geometry.json +++ b/tests/ref/simulation/service_init_geometry.json @@ -300,7 +300,6 @@ "units": "m" }, "use_inhouse_mesher": false, - "use_geometry_AI": false, - "project_variables":[] + "use_geometry_AI": false } } diff --git a/tests/ref/simulation/service_init_surface_mesh.json b/tests/ref/simulation/service_init_surface_mesh.json index dcce59de7..5910697c1 100644 --- a/tests/ref/simulation/service_init_surface_mesh.json +++ b/tests/ref/simulation/service_init_surface_mesh.json @@ -300,7 +300,6 @@ "units": "cm" }, "use_inhouse_mesher": false, - "use_geometry_AI": false, - "project_variables":[] + "use_geometry_AI": false } } \ 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 a9f286500..cd67f7606 100644 --- a/tests/ref/simulation/service_init_volume_mesh.json +++ b/tests/ref/simulation/service_init_volume_mesh.json @@ -265,7 +265,6 @@ "units": "m" }, "use_inhouse_mesher": false, - "use_geometry_AI": false, - "project_variables":[] + "use_geometry_AI": false } } diff --git a/tests/simulation/converter/ref/ref_monitor.json b/tests/simulation/converter/ref/ref_monitor.json index 377c997f7..3d7a7d8c3 100644 --- a/tests/simulation/converter/ref/ref_monitor.json +++ b/tests/simulation/converter/ref/ref_monitor.json @@ -1 +1 @@ -{"version":"25.6.0b1","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, "controls":null},"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}} +{"version":"25.6.0b1","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, "controls":null},"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":null, "use_geometry_AI": false}} diff --git a/tests/simulation/ref/simulation_with_project_variables.json b/tests/simulation/ref/simulation_with_project_variables.json new file mode 100644 index 000000000..275c614b1 --- /dev/null +++ b/tests/simulation/ref/simulation_with_project_variables.json @@ -0,0 +1,280 @@ +{ + "version": "25.6.0b1", + "unit_system": { + "name": "SI" + }, + "operating_condition": { + "type_name": "AerospaceCondition", + "private_attribute_constructor": "default", + "private_attribute_input_cache": { + "alpha": { + "value": 0.0, + "units": "degree" + }, + "beta": { + "value": 0.0, + "units": "degree" + }, + "thermal_state": { + "type_name": "ThermalState", + "private_attribute_constructor": "default", + "private_attribute_input_cache": {}, + "temperature": { + "value": 288.15, + "units": "K" + }, + "density": { + "value": 1.225, + "units": "kg/m**3" + }, + "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" + } + } + } + } + }, + "alpha": { + "value": 0.0, + "units": "degree" + }, + "beta": { + "value": 0.0, + "units": "degree" + }, + "velocity_magnitude": { + "value": 10.0, + "units": "m/s" + }, + "thermal_state": { + "type_name": "ThermalState", + "private_attribute_constructor": "default", + "private_attribute_input_cache": {}, + "temperature": { + "value": 288.15, + "units": "K" + }, + "density": { + "value": 1.225, + "units": "kg/m**3" + }, + "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" + } + } + } + }, + "reference_velocity_magnitude": { + "value": 10.0, + "units": "m/s" + } + }, + "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", + "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 + }, + "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, + "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 + }, + "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, + "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_fields": [], + "outputs": [ + { + "output_fields": { + "items": [ + { + "name": "bbb", + "type_name": "UserVariable" + } + ] + }, + "frequency": -1, + "frequency_offset": 0, + "output_format": "paraview", + "name": "Volume output", + "output_type": "VolumeOutput" + }, + { + "output_fields": { + "items": [ + { + "name": "bbb", + "type_name": "UserVariable" + } + ] + }, + "name": "Probe output", + "entities": { + "stored_entities": [ + { + "private_attribute_registry_bucket_name": "PointEntityType", + "private_attribute_entity_type_name": "Point", + "private_attribute_id": "111", + "name": "pt1", + "location": { + "value": [ + 1.0, + 2.0, + 3.0 + ], + "units": "m" + } + } + ] + }, + "output_type": "ProbeOutput" + } + ], + "private_attribute_asset_cache": { + "use_inhouse_mesher": false, + "use_geometry_AI": false, + "project_variables": [ + { + "name": "ccc", + "value": { + "type_name": "number", + "value": 12.0, + "units": "m/s" + } + }, + { + "name": "aaa", + "value": { + "type_name": "expression", + "expression": "[solution.velocity[0] + ccc, solution.velocity[1], solution.velocity[2]]", + "evaluated_value": [ + null, + null, + null + ], + "evaluated_units": "m/s" + } + }, + { + "name": "bbb", + "value": { + "type_name": "expression", + "expression": "[aaa[0] + 14 * u.m / u.s, aaa[1], aaa[2]]", + "evaluated_value": [ + null, + null, + null + ], + "evaluated_units": "m/s", + "output_units": "km/ms" + } + } + ] + } +} \ No newline at end of file diff --git a/tests/simulation/test_expressions.py b/tests/simulation/test_expressions.py index bdb6692de..865c3b3df 100644 --- a/tests/simulation/test_expressions.py +++ b/tests/simulation/test_expressions.py @@ -1,11 +1,12 @@ import json import re -from typing import Annotated, List +from typing import Annotated import numpy as np import pydantic as pd import pytest +import flow360.component.simulation.user_code.core.context as context from flow360 import ( AerospaceCondition, HeatEquationInitialCondition, @@ -16,11 +17,17 @@ math, u, ) +from flow360.component.project_utils import save_user_variables from flow360.component.simulation.framework.base_model import Flow360BaseModel from flow360.component.simulation.framework.param_utils import AssetCache from flow360.component.simulation.framework.updater_utils import compare_lists from flow360.component.simulation.models.material import Water, aluminum -from flow360.component.simulation.outputs.outputs import SurfaceOutput, VolumeOutput +from flow360.component.simulation.outputs.output_entities import Point +from flow360.component.simulation.outputs.outputs import ( + ProbeOutput, + SurfaceOutput, + VolumeOutput, +) from flow360.component.simulation.primitives import ( GenericVolume, ReferenceGeometry, @@ -71,6 +78,11 @@ from tests.utils import to_file_from_file_test +@pytest.fixture(autouse=True) +def reset_context(): + context.default_context.clear() + + @pytest.fixture(autouse=True) def change_test_dir(request, monkeypatch): monkeypatch.chdir(request.fspath.dirname) @@ -102,9 +114,6 @@ def solution_variable(): def test_variable_init(): - class TestModel(Flow360BaseModel): - field: List[UserVariable] = pd.Field() - # Variables can be initialized with a... # Value @@ -360,46 +369,44 @@ class TestModel(Flow360BaseModel): direction: ValueOrExpression[LengthType.Direction] = pd.Field() moment: ValueOrExpression[LengthType.Moment] = pd.Field() - x = UserVariable(name="x", value=[1, 0, 0]) - y = UserVariable(name="y", value=[0, 0, 0]) - z = UserVariable(name="z", value=[1, 0, 0, 0]) - w = UserVariable(name="w", value=[1, 1, 1]) + x = UserVariable(name="x", value=[1, 0, 0] * u.m) + y = UserVariable(name="y", value=[0, 0, 0] * u.m) + z = UserVariable(name="z", value=[1, 0, 0, 0] * u.m) + w = UserVariable(name="w", value=[1, 1, 1] * u.m) - model = TestModel( - vector=y * u.m, axis=x * u.m, array=z * u.m, direction=x * u.m, moment=w * u.m - ) + model = TestModel(vector=y, axis=x, array=z, direction=x, moment=w) assert isinstance(model.vector, Expression) assert (model.vector.evaluate() == [0, 0, 0] * u.m).all() - assert str(model.vector) == "y * u.m" + assert str(model.vector) == "y" assert isinstance(model.axis, Expression) assert (model.axis.evaluate() == [1, 0, 0] * u.m).all() - assert str(model.axis) == "x * u.m" + assert str(model.axis) == "x" assert isinstance(model.array, Expression) assert (model.array.evaluate() == [1, 0, 0, 0] * u.m).all() - assert str(model.array) == "z * u.m" + assert str(model.array) == "z" assert isinstance(model.direction, Expression) assert (model.direction.evaluate() == [1, 0, 0] * u.m).all() - assert str(model.direction) == "x * u.m" + assert str(model.direction) == "x" assert isinstance(model.moment, Expression) assert (model.moment.evaluate() == [1, 1, 1] * u.m).all() - assert str(model.moment) == "w * u.m" + assert str(model.moment) == "w" with pytest.raises(pd.ValidationError): - model.vector = z * u.m + model.vector = z with pytest.raises(pd.ValidationError): - model.axis = y * u.m + model.axis = y with pytest.raises(pd.ValidationError): - model.direction = y * u.m + model.direction = y with pytest.raises(pd.ValidationError): - model.moment = x * u.m + model.moment = x def test_solver_builtin(): @@ -448,66 +455,26 @@ class TestModel(Flow360BaseModel): assert constant_variable.model_dump() == { "name": "constant_variable", - "value": { - "evaluated_units": None, - "evaluated_value": None, - "expression": None, - "output_units": None, - "type_name": "number", - "units": None, - "value": 10.0, - }, + "type_name": "UserVariable", } assert constant_array.model_dump() == { "name": "constant_array", - "value": { - "evaluated_units": None, - "evaluated_value": None, - "expression": None, - "output_units": None, - "type_name": "number", - "units": None, - "value": [10, 20], - }, + "type_name": "UserVariable", } assert constant_unyt_quantity.model_dump() == { "name": "constant_unyt_quantity", - "value": { - "evaluated_units": None, - "evaluated_value": None, - "expression": None, - "output_units": None, - "type_name": "number", - "units": "m", - "value": 10.0, - }, + "type_name": "UserVariable", } assert constant_unyt_array.model_dump() == { "name": "constant_unyt_array", - "value": { - "evaluated_units": None, - "evaluated_value": None, - "expression": None, - "output_units": None, - "type_name": "number", - "units": "m", - "value": [10, 20], - }, + "type_name": "UserVariable", } assert solution_variable.model_dump() == { "name": "solution_variable", - "value": { - "evaluated_units": "m/s", - "evaluated_value": [None, None, None], - "expression": "solution.velocity", - "output_units": None, - "type_name": "expression", - "units": None, - "value": None, - }, + "type_name": "UserVariable", } @@ -788,10 +755,10 @@ def test_cyclic_dependencies(): with pytest.raises(pd.ValidationError): x.value = y - x = UserVariable(name="x", value=4) + z = UserVariable(name="z", value=4) with pytest.raises(pd.ValidationError): - x.value = x + z.value = z def test_auto_alias(): @@ -873,8 +840,7 @@ class TestModel(Flow360BaseModel): 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) + expr_1.to_solver_code(params) # From python code expr_2 = TestModel(field=math.cross([1, 2, 3], solution.coordinate)).field @@ -885,8 +851,7 @@ class TestModel(Flow360BaseModel): ) # During solver translation both options are inlined the same way through partial evaluation - solver_2 = expr_2.to_solver_code(params) - print(solver_2) + expr_2.to_solver_code(params) def test_cross_function_use_case(): @@ -1093,7 +1058,6 @@ def test_udf_generator(): name="vel_cross_vec", value=math.cross(solution.velocity, [1, 2, 3] * u.cm) ).in_unit(new_unit="m*km/s/s") result = user_variable_to_udf(vel_cross_vec, input_params=params) - print("3>>> result.expression", result.expression) assert ( result.expression == "double velocity[3];velocity[0] = primitiveVars[1] * velocityScale;velocity[1] = primitiveVars[2] * velocityScale;velocity[2] = primitiveVars[3] * velocityScale;vel_cross_vec[0] = ((((velocity[1] * 3) * 0.001) - ((velocity[2] * 2) * 0.001)) * 10.0); vel_cross_vec[1] = ((((velocity[2] * 1) * 0.001) - ((velocity[0] * 3) * 0.001)) * 10.0); vel_cross_vec[2] = ((((velocity[0] * 2) * 0.001) - ((velocity[1] * 1) * 0.001)) * 10.0);" @@ -1105,8 +1069,15 @@ def test_udf_generator(): assert vel_cross_vec.value.get_output_units(input_params=params) == u.cm**2 / u.s -def test_project_variables(): - aaa = UserVariable(name="aaa", value=solution.velocity + 12 * u.m / u.s) +def test_project_variables_serialization(): + ccc = UserVariable(name="ccc", value=12 * u.m / u.s) + aaa = UserVariable( + name="aaa", value=[solution.velocity[0] + ccc, solution.velocity[1], solution.velocity[2]] + ) + bbb = UserVariable(name="bbb", value=[aaa[0] + 14 * u.m / u.s, aaa[1], aaa[2]]).in_unit( + new_unit="km/ms" + ) + with SI_unit_system: params = SimulationParams( operating_condition=AerospaceCondition( @@ -1116,12 +1087,68 @@ def test_project_variables(): outputs=[ VolumeOutput( output_fields=[ - UserVariable(name="bbb", value=aaa + 14 * u.m / u.s), + bbb, ] - ) + ), + ProbeOutput( + probe_points=[ + Point(name="pt1", location=(1, 2, 3), private_attribute_id="111") + ], + output_fields=[bbb], + ), ], ) - assert params.private_attribute_asset_cache.project_variables == [aaa] + + params = save_user_variables(params) + + with open("ref/simulation_with_project_variables.json", "r+") as fh: + ref_data = fh.read() + + assert ref_data == params.model_dump_json(indent=4, exclude_none=True) + + +def test_project_variables_deserialization(): + with open("ref/simulation_with_project_variables.json", "r+") as fh: + data = json.load(fh) + + # Assert no variables registered yet + with pytest.raises(NameError): + context.default_context.get("aaa") + with pytest.raises(NameError): + context.default_context.get("bbb") + with pytest.raises(NameError): + context.default_context.get("ccc") + + params, _, _ = validate_model( + params_as_dict=data, + root_item_type=None, + validated_by=ValidationCalledBy.LOCAL, + ) + assert params + assert ( + params.outputs[0].output_fields.items[0].value.expression + == "[aaa[0] + 14 * u.m / u.s, aaa[1], aaa[2]]" + ) + + assert params.outputs[0].output_fields.items[0].value.output_units == "km/ms" + + assert ( + params.outputs[0] + .output_fields.items[0] + .value.evaluate(force_evaluate=False, raise_on_non_evaluable=False) + .expression + == "[solution.velocity[0] + 12.0 * u.m / u.s + 14 * u.m / u.s, solution.velocity[1], solution.velocity[2]]" + ) # Fully resolvable + + +def test_overwriting_project_variables(): + UserVariable(name="a", value=1) + + with pytest.raises( + ValueError, + match="Redeclaring user variable a with new value: 2.0. Previous value: 1.0", + ): + UserVariable(name="a", value=2) def test_whitelisted_callables(): diff --git a/tests/simulation/translator/ref/Flow360_user_variable.json b/tests/simulation/translator/ref/Flow360_user_variable.json new file mode 100644 index 000000000..641caea55 --- /dev/null +++ b/tests/simulation/translator/ref/Flow360_user_variable.json @@ -0,0 +1,132 @@ +{ + "boundaries": { + "fluid/body": { + "heatFlux": 0.0, + "roughnessHeight": 0.0, + "type": "NoSlipWall" + }, + "fluid/farfield": { + "type": "Freestream" + } + }, + "freestream": { + "Mach": 0.05, + "Temperature": -1, + "alphaAngle": 5.0, + "betaAngle": 2.0, + "muRef": 5.010000000000001e-09 + }, + "initialCondition": { + "p": "p", + "rho": "rho", + "type": "initialCondition", + "u": "u", + "v": "v", + "w": "w" + }, + "navierStokesSolver": { + "CFLMultiplier": 1.0, + "absoluteTolerance": 1e-10, + "equationEvalFrequency": 1, + "kappaMUSCL": -1.0, + "limitPressureDensity": false, + "limitVelocity": false, + "linearSolver": { + "maxIterations": 30 + }, + "lowMachPreconditioner": true, + "lowMachPreconditionerThreshold": 0.05, + "maxForceJacUpdatePhysicalSteps": 0, + "modelType": "Compressible", + "numericalDissipationFactor": 1.0, + "orderOfAccuracy": 2, + "relativeTolerance": 0.0, + "updateJacobianFrequency": 4 + }, + "outputRescale": { + "velocityScale": 20.0 + }, + "timeStepping": { + "CFL": { + "convergenceLimitingFactor": 0.25, + "max": 10000.0, + "maxRelativeChange": 1.0, + "min": 0.1, + "type": "adaptive" + }, + "maxPseudoSteps": 2000, + "orderOfAccuracy": 2, + "physicalSteps": 1, + "timeStepSize": "inf" + }, + "turbulenceModelSolver": { + "CFLMultiplier": 2.0, + "DDES": false, + "ZDES": false, + "absoluteTolerance": 1e-08, + "equationEvalFrequency": 4, + "gridSizeForLES": "maxEdgeLength", + "linearSolver": { + "maxIterations": 20 + }, + "maxForceJacUpdatePhysicalSteps": 0, + "modelConstants": { + "C_DES": 0.72, + "C_cb1": 0.1355, + "C_cb2": 0.622, + "C_d": 8.0, + "C_min_rd": 10.0, + "C_sigma": 0.6666666666666666, + "C_t3": 1.2, + "C_t4": 0.5, + "C_v1": 7.1, + "C_vonKarman": 0.41, + "C_w2": 0.3 + }, + "modelType": "SpalartAllmaras", + "orderOfAccuracy": 2, + "quadraticConstitutiveRelation": false, + "reconstructionGradientLimiter": 0.5, + "relativeTolerance": 0.0, + "rotationCorrection": false, + "updateJacobianFrequency": 4 + }, + "userDefinedFields": [ + { + "expression": "double velocity[3];velocity[0] = primitiveVars[1];velocity[1] = primitiveVars[2];velocity[2] = primitiveVars[3];velocity_magnitude = magnitude(velocity) * velocityScale;", + "name": "velocity_magnitude" + }, + { + "expression": "Mach = usingLiquidAsMaterial ? 0 : sqrt(primitiveVars[1] * primitiveVars[1] + primitiveVars[2] * primitiveVars[2] + primitiveVars[3] * primitiveVars[3]) / sqrt(1.4 * primitiveVars[4] / primitiveVars[0]);Mach = (Mach * 1);", + "name": "Mach" + }, + { + "expression": "velocity[0] = primitiveVars[1] * velocityScale;velocity[1] = primitiveVars[2] * velocityScale;velocity[2] = primitiveVars[3] * velocityScale;velocity[0] = (velocity[0] * 200.0); velocity[1] = (velocity[1] * 200.0); velocity[2] = (velocity[2] * 200.0);", + "name": "velocity" + }, + { + "expression": "double velocity[3];velocity[0] = primitiveVars[1] * velocityScale;velocity[1] = primitiveVars[2] * velocityScale;velocity[2] = primitiveVars[3] * velocityScale;uuu[0] = (velocity[0] * 0.0002); uuu[1] = (velocity[1] * 0.0002); uuu[2] = (velocity[2] * 0.0002);", + "name": "uuu" + }, + { + "expression": "double velocity[3];velocity[0] = primitiveVars[1] * velocityScale;velocity[1] = primitiveVars[2] * velocityScale;velocity[2] = primitiveVars[3] * velocityScale;my_var[0] = (((((2.0 * 1.0) / 200.0) * velocity[2]) - (((3.0 * 1.0) / 200.0) * velocity[1])) * 40000.0); my_var[1] = (((((3.0 * 1.0) / 200.0) * velocity[0]) - (((1.0 * 1.0) / 200.0) * velocity[2])) * 40000.0); my_var[2] = (((((1.0 * 1.0) / 200.0) * velocity[1]) - (((2.0 * 1.0) / 200.0) * velocity[0])) * 40000.0);", + "name": "my_var" + } + ], + "usingLiquidAsMaterial": true, + "volumeOutput": { + "animationFrequency": -1, + "animationFrequencyOffset": 0, + "animationFrequencyTimeAverage": -1, + "animationFrequencyTimeAverageOffset": 0, + "computeTimeAverages": false, + "outputFields": [ + "Mach", + "velocity", + "uuu", + "my_var" + ], + "outputFormat": "paraview", + "startAverageIntegrationStep": -1 + } +} \ No newline at end of file diff --git a/tests/simulation/translator/test_solver_translator.py b/tests/simulation/translator/test_solver_translator.py index e2c11ccb5..a702c7526 100644 --- a/tests/simulation/translator/test_solver_translator.py +++ b/tests/simulation/translator/test_solver_translator.py @@ -48,11 +48,13 @@ VolumeOutput, ) from flow360.component.simulation.primitives import ReferenceGeometry, Surface +from flow360.component.simulation.services import ValidationCalledBy, validate_model from flow360.component.simulation.simulation_params import SimulationParams from flow360.component.simulation.time_stepping.time_stepping import RampCFL, Steady from flow360.component.simulation.translator.solver_translator import get_solver_json from flow360.component.simulation.unit_system import SI_unit_system 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 solution from tests.simulation.translator.utils.actuator_disk_param_generator import ( actuator_disk_create_param, @@ -519,8 +521,6 @@ def test_user_defined_field(): ) ], ) - translated = get_solver_json(param, mesh_unit=1 * u.m) - print("-----------------\n", json.dumps(translated, indent=4)) translate_and_compare(param, mesh_unit=1 * u.m, ref_json_file="Flow360_expression_udf.json") @@ -640,3 +640,59 @@ def test_liquid_simulation_translation(): # Flow360 time to seconds = 1m/(200m/s) = 0.005 s # t_seconds = (0.005 s * t) translate_and_compare(param, mesh_unit=1 * u.m, ref_json_file="Flow360_liquid_rotation_dd.json") + + +import flow360.component.simulation.user_code.core.context as context + + +@pytest.fixture() +def reset_context(): + context.default_context._values = { + name: item for (name, item) in context.default_context._values.items() if "." in name + } + + +def test_param_with_user_variables(): + some_dependent_variable_a = UserVariable( + name="some_dependent_variable_a", value=[1.0 * u.m / u.s, 2.0 * u.m / u.s, 3.0 * u.m / u.s] + ) + my_var = UserVariable( + name="my_var", value=math.cross(some_dependent_variable_a, solution.velocity) + ) + with SI_unit_system: + param = SimulationParams( + operating_condition=LiquidOperatingCondition( + velocity_magnitude=10 * u.m / u.s, + alpha=5 * u.deg, + beta=2 * u.deg, + material=Water(name="my_water", density=1.000 * 10**3 * u.kg / u.m**3), + ), + models=[ + Wall(entities=Surface(name="fluid/body")), + Freestream(entities=Surface(name="fluid/farfield")), + ], + outputs=[ + VolumeOutput( + name="output", + output_fields=[ + solution.Mach, + solution.velocity, + UserVariable(name="uuu", value=solution.velocity).in_unit(new_unit="km/ms"), + my_var, + ], + ) + ], + ) + # Mimicking real workflow where the Param is serialized and then deserialized + params_validated, _, _ = validate_model( + params_as_dict=param.model_dump(mode="json"), + validated_by=ValidationCalledBy.LOCAL, + root_item_type=None, + ) + + assert params_validated + translate_and_compare( + params_validated, + mesh_unit=1 * u.m, + ref_json_file="Flow360_user_variable.json", + ) From 14a9e91a9a5cfe8b3f9fac85d572db9eef5d58f8 Mon Sep 17 00:00:00 2001 From: Ben <106089368+benflexcompute@users.noreply.github.com> Date: Mon, 16 Jun 2025 22:30:12 -0400 Subject: [PATCH 28/34] Enabled timestepping->step size to be expression too (#1166) * Enabled timestepping->step size to be expression too * Bumped version --- flow360/component/simulation/framework/updater.py | 9 +++++++-- .../simulation/time_stepping/time_stepping.py | 5 ++++- flow360/component/v1/updater.py | 2 +- flow360/version.py | 2 +- pyproject.toml | 2 +- tests/ref/simulation/service_init_geometry.json | 2 +- .../ref/simulation/service_init_surface_mesh.json | 2 +- tests/ref/simulation/service_init_volume_mesh.json | 2 +- tests/simulation/converter/ref/ref_monitor.json | 2 +- .../ref/simulation_with_project_variables.json | 2 +- tests/simulation/service/test_services_v2.py | 4 ++-- tests/simulation/test_updater.py | 14 +++++++------- tests/test_current_flow360_version.py | 2 +- 13 files changed, 29 insertions(+), 21 deletions(-) diff --git a/flow360/component/simulation/framework/updater.py b/flow360/component/simulation/framework/updater.py index ba07ae3a5..71b1f726e 100644 --- a/flow360/component/simulation/framework/updater.py +++ b/flow360/component/simulation/framework/updater.py @@ -176,7 +176,7 @@ def _to_25_4_1(params_as_dict): return params_as_dict -def _to_25_6_0(params_as_dict): +def _to_25_6_1(params_as_dict): # Known: There can not be velocity_direction both under Inflow AND TotalPressure # Move the velocity_direction under TotalPressure to the Inflow level. @@ -197,6 +197,11 @@ def _to_25_6_0(params_as_dict): ): params_as_dict["reference_geometry"]["area"]["type_name"] = "number" + # Add similar logic for step_size in time_stepping + if "time_stepping" in params_as_dict and "step_size" in params_as_dict["time_stepping"]: + if "type_name" not in params_as_dict["time_stepping"]["step_size"]: + params_as_dict["time_stepping"]["step_size"]["type_name"] = "number" + return params_as_dict @@ -208,7 +213,7 @@ def _to_25_6_0(params_as_dict): (Flow360Version("25.2.1"), _to_25_2_1), (Flow360Version("25.2.3"), _to_25_2_3), (Flow360Version("25.4.1"), _to_25_4_1), - (Flow360Version("25.6.0"), _to_25_6_0), + (Flow360Version("25.6.1"), _to_25_6_1), ] # A list of the Python API version tuple with there corresponding updaters. diff --git a/flow360/component/simulation/time_stepping/time_stepping.py b/flow360/component/simulation/time_stepping/time_stepping.py index 78464c69f..72e1f09da 100644 --- a/flow360/component/simulation/time_stepping/time_stepping.py +++ b/flow360/component/simulation/time_stepping/time_stepping.py @@ -6,6 +6,7 @@ from flow360.component.simulation.framework.base_model import Flow360BaseModel from flow360.component.simulation.unit_system import TimeType +from flow360.component.simulation.user_code.core.types import ValueOrExpression def _apply_default_to_none(original, default): @@ -174,7 +175,9 @@ class Unsteady(Flow360BaseModel): ) steps: pd.PositiveInt = pd.Field(description="Number of physical steps.") # pylint: disable=no-member - step_size: TimeType.Positive = pd.Field(description="Time step size in physical step marching,") + step_size: ValueOrExpression[TimeType.Positive] = pd.Field( + description="Time step size in physical step marching," + ) # pylint: disable=duplicate-code CFL: Union[RampCFL, AdaptiveCFL] = pd.Field( default=AdaptiveCFL.default_unsteady(), diff --git a/flow360/component/v1/updater.py b/flow360/component/v1/updater.py index 6fad8b64f..5934f0dfa 100644 --- a/flow360/component/v1/updater.py +++ b/flow360/component/v1/updater.py @@ -28,7 +28,7 @@ def _no_update(params_as_dict): ("25.2.3", "25.4.0", _no_update), ("25.4.0", "25.4.1", _no_update), ("25.4.1", "25.5.1", _no_update), - ("25.5.1", "25.6.0", _no_update), + ("25.5.1", "25.6.1", _no_update), ] diff --git a/flow360/version.py b/flow360/version.py index eb53cf57f..cc57c2270 100644 --- a/flow360/version.py +++ b/flow360/version.py @@ -2,5 +2,5 @@ version """ -__version__ = "25.6.0b1" +__version__ = "25.6.1b1" __solver_version__ = "release-25.6" diff --git a/pyproject.toml b/pyproject.toml index 36985a59d..838d7edcd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "flow360" -version = "v25.6.0b1" +version = "v25.6.1b1" description = "" authors = ["Flexcompute "] diff --git a/tests/ref/simulation/service_init_geometry.json b/tests/ref/simulation/service_init_geometry.json index f66beb047..cf55c1dbd 100644 --- a/tests/ref/simulation/service_init_geometry.json +++ b/tests/ref/simulation/service_init_geometry.json @@ -1,5 +1,5 @@ { - "version": "25.6.0b1", + "version": "25.6.1b1", "unit_system": { "name": "SI" }, diff --git a/tests/ref/simulation/service_init_surface_mesh.json b/tests/ref/simulation/service_init_surface_mesh.json index 5910697c1..65a2b6c35 100644 --- a/tests/ref/simulation/service_init_surface_mesh.json +++ b/tests/ref/simulation/service_init_surface_mesh.json @@ -1,5 +1,5 @@ { - "version": "25.6.0b1", + "version": "25.6.1b1", "unit_system": { "name": "SI" }, diff --git a/tests/ref/simulation/service_init_volume_mesh.json b/tests/ref/simulation/service_init_volume_mesh.json index cd67f7606..df7a1e933 100644 --- a/tests/ref/simulation/service_init_volume_mesh.json +++ b/tests/ref/simulation/service_init_volume_mesh.json @@ -1,5 +1,5 @@ { - "version": "25.6.0b1", + "version": "25.6.1b1", "unit_system": { "name": "SI" }, diff --git a/tests/simulation/converter/ref/ref_monitor.json b/tests/simulation/converter/ref/ref_monitor.json index 3d7a7d8c3..58a93f8f8 100644 --- a/tests/simulation/converter/ref/ref_monitor.json +++ b/tests/simulation/converter/ref/ref_monitor.json @@ -1 +1 @@ -{"version":"25.6.0b1","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, "controls":null},"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":null, "use_geometry_AI": false}} +{"version":"25.6.1b1","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, "controls":null},"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":null, "use_geometry_AI": false}} diff --git a/tests/simulation/ref/simulation_with_project_variables.json b/tests/simulation/ref/simulation_with_project_variables.json index 275c614b1..41e970e5b 100644 --- a/tests/simulation/ref/simulation_with_project_variables.json +++ b/tests/simulation/ref/simulation_with_project_variables.json @@ -1,5 +1,5 @@ { - "version": "25.6.0b1", + "version": "25.6.1b1", "unit_system": { "name": "SI" }, diff --git a/tests/simulation/service/test_services_v2.py b/tests/simulation/service/test_services_v2.py index dcb018d91..fcbe30039 100644 --- a/tests/simulation/service/test_services_v2.py +++ b/tests/simulation/service/test_services_v2.py @@ -1065,7 +1065,7 @@ def test_forward_compatibility_error(): ) assert errors[0] == { - "type": "99.99.99 > 25.6.0b1", + "type": "99.99.99 > 25.6.1b1", "loc": [], "msg": "The cloud `SimulationParam` is too new for your local Python client. " "Errors may occur since forward compatibility is limited.", @@ -1079,7 +1079,7 @@ def test_forward_compatibility_error(): ) assert errors[0] == { - "type": "99.99.99 > 25.6.0b1", + "type": "99.99.99 > 25.6.1b1", "loc": [], "msg": "[Internal] Your `SimulationParams` is too new for the solver. Errors may occur since forward compatibility is limited.", "ctx": {}, diff --git a/tests/simulation/test_updater.py b/tests/simulation/test_updater.py index ffd022b94..e83f34f45 100644 --- a/tests/simulation/test_updater.py +++ b/tests/simulation/test_updater.py @@ -487,14 +487,14 @@ def test_updater_to_25_4_1(): assert params_new["meshing"]["defaults"]["geometry_accuracy"]["units"] == "m" -def test_updater_to_25_6_0(): +def test_updater_to_25_6_1(): with open("../data/simulation/simulation_pre_25_6_0.json", "r") as fp: params = json.load(fp) - def _update_to_25_6_0(pre_update_param_as_dict): + def _update_to_25_6_1(pre_update_param_as_dict): params_new = updater( version_from=f"25.5.1", - version_to=f"25.6.0", + version_to=f"25.6.1", params_as_dict=pre_update_param_as_dict, ) return params_new @@ -508,21 +508,21 @@ def _ensure_validity(params): assert params_new pre_update_param_as_dict = copy.deepcopy(params) - params_new = _update_to_25_6_0(pre_update_param_as_dict) + params_new = _update_to_25_6_1(pre_update_param_as_dict) assert params_new["models"][2]["velocity_direction"] == [0, -1, 0] assert "velocity_direction" not in params_new["models"][2]["spec"] _ensure_validity(params_new) pre_update_param_as_dict = copy.deepcopy(params) pre_update_param_as_dict["models"][2]["spec"]["velocity_direction"] = None - params_new = _update_to_25_6_0(pre_update_param_as_dict) + params_new = _update_to_25_6_1(pre_update_param_as_dict) assert "velocity_direction" not in params_new["models"][2] assert "velocity_direction" not in params_new["models"][2]["spec"] _ensure_validity(params_new) pre_update_param_as_dict = copy.deepcopy(params) pre_update_param_as_dict["models"][2]["spec"].pop("velocity_direction") - params_new = _update_to_25_6_0(pre_update_param_as_dict) + params_new = _update_to_25_6_1(pre_update_param_as_dict) assert "velocity_direction" not in params_new["models"][2] assert "velocity_direction" not in params_new["models"][2]["spec"] _ensure_validity(params_new) @@ -530,7 +530,7 @@ def _ensure_validity(params): pre_update_param_as_dict = copy.deepcopy(params) pre_update_param_as_dict["models"][2]["spec"].pop("velocity_direction") pre_update_param_as_dict["models"][2]["velocity_direction"] = [0, -1, 0] - params_new = _update_to_25_6_0(pre_update_param_as_dict) + params_new = _update_to_25_6_1(pre_update_param_as_dict) assert params_new["models"][2]["velocity_direction"] == [0, -1, 0] assert "velocity_direction" not in params_new["models"][2]["spec"] _ensure_validity(params_new) diff --git a/tests/test_current_flow360_version.py b/tests/test_current_flow360_version.py index 38d6c0368..9b8e536ad 100644 --- a/tests/test_current_flow360_version.py +++ b/tests/test_current_flow360_version.py @@ -2,4 +2,4 @@ def test_version(): - assert __version__ == "25.6.0b1" + assert __version__ == "25.6.1b1" From f88c3f7efaaa566057895e4f6353a57016b1b307 Mon Sep 17 00:00:00 2001 From: Ben <106089368+benflexcompute@users.noreply.github.com> Date: Tue, 17 Jun 2025 12:27:25 -0400 Subject: [PATCH 29/34] Handles NaN desearilization (#1168) --- flow360/component/simulation/user_code/core/types.py | 6 +++++- tests/simulation/test_expressions.py | 10 +++++++--- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/flow360/component/simulation/user_code/core/types.py b/flow360/component/simulation/user_code/core/types.py index 4ceb96017..3eacd96bb 100644 --- a/flow360/component/simulation/user_code/core/types.py +++ b/flow360/component/simulation/user_code/core/types.py @@ -828,7 +828,9 @@ def _serializer(value, info) -> dict: pass if isinstance(evaluated, Number): - serialized.evaluated_value = evaluated + serialized.evaluated_value = ( + evaluated if not np.isnan(evaluated) else None # NaN-None handling + ) elif isinstance(evaluated, unyt_array): if evaluated.size == 1: serialized.evaluated_value = ( @@ -845,6 +847,8 @@ def _serializer(value, info) -> dict: serialized.evaluated_units = str(evaluated.units.expr) else: serialized = SerializedValueOrExpression(type_name="number") + # Note: NaN handling should be unnecessary since it would + # have end up being expression first so not reaching here. if isinstance(value, (Number, List)): serialized.value = value elif isinstance(value, unyt_array): diff --git a/tests/simulation/test_expressions.py b/tests/simulation/test_expressions.py index 865c3b3df..1a9fd2d11 100644 --- a/tests/simulation/test_expressions.py +++ b/tests/simulation/test_expressions.py @@ -1,6 +1,6 @@ import json import re -from typing import Annotated +from typing import Annotated, Optional import numpy as np import pydantic as pd @@ -433,17 +433,21 @@ def test_serializer( ): class TestModel(Flow360BaseModel): field: ValueOrExpression[VelocityType] = pd.Field() + non_dim_field: Optional[ValueOrExpression[float]] = pd.Field(default=None) x = UserVariable(name="x", value=4) + cp = UserVariable(name="my_cp", value=solution.Cp) - model = TestModel(field=x * u.m / u.s + 4 * x**2 * u.m / u.s) + model = TestModel(field=x * u.m / u.s + 4 * x**2 * u.m / u.s, non_dim_field=cp) assert str(model.field) == "x * u.m / u.s + 4 * x ** 2 * u.m / u.s" - serialized = model.model_dump(exclude_none=True) + serialized = model.model_dump() assert serialized["field"]["type_name"] == "expression" assert serialized["field"]["expression"] == "x * u.m / u.s + 4 * x ** 2 * u.m / u.s" + assert serialized["non_dim_field"]["expression"] == "my_cp" + assert serialized["non_dim_field"]["evaluated_value"] == None # Not NaN anymore model = TestModel(field=4 * u.m / u.s) From 258ed8577fd8a02aca9cc5d1a869ea472f15ec48 Mon Sep 17 00:00:00 2001 From: BenYuan Date: Tue, 17 Jun 2025 17:18:05 +0000 Subject: [PATCH 30/34] Added proper base for surface probe output --- flow360/component/simulation/outputs/outputs.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flow360/component/simulation/outputs/outputs.py b/flow360/component/simulation/outputs/outputs.py index 3321fe5be..aa4d0614d 100644 --- a/flow360/component/simulation/outputs/outputs.py +++ b/flow360/component/simulation/outputs/outputs.py @@ -548,7 +548,7 @@ class ProbeOutput(_OutputBase): output_type: Literal["ProbeOutput"] = pd.Field("ProbeOutput", frozen=True) -class SurfaceProbeOutput(Flow360BaseModel): +class SurfaceProbeOutput(_OutputBase): """ :class:`SurfaceProbeOutput` class for setting surface output data probed at monitor points. The specified monitor point will be projected to the :py:attr:`~SurfaceProbeOutput.target_surfaces` From 6c4922e18985676ba28bd9de5a15a910dfa8ff12 Mon Sep 17 00:00:00 2001 From: Ben <106089368+benflexcompute@users.noreply.github.com> Date: Tue, 17 Jun 2025 22:32:03 -0400 Subject: [PATCH 31/34] Ben y/expression front end feedback (#1169) * Updated the project variables schema * rename gradient --- .../simulation/translator/user_expression_utils.py | 6 +++--- .../component/simulation/user_code/core/context.py | 6 +++--- flow360/component/simulation/user_code/core/types.py | 1 + .../simulation/user_code/variables/solution.py | 12 ++++++------ 4 files changed, 13 insertions(+), 12 deletions(-) diff --git a/flow360/component/simulation/translator/user_expression_utils.py b/flow360/component/simulation/translator/user_expression_utils.py index 846a49c11..04e5184d2 100644 --- a/flow360/component/simulation/translator/user_expression_utils.py +++ b/flow360/component/simulation/translator/user_expression_utils.py @@ -22,19 +22,19 @@ + "gradDensity[1] = gradPrimitive[0][1];" + "gradDensity[2] = gradPrimitive[0][2];", }, - "solution.grad_velocity_x": { + "solution.grad_u": { "declaration": "double gradVelocityX[3];", "computation": "gradVelocityX[0] = gradPrimitive[1][0] * velocityScale;" + "gradVelocityX[1] = gradPrimitive[1][1] * velocityScale;" + "gradVelocityX[2] = gradPrimitive[1][2] * velocityScale;", }, - "solution.grad_velocity_y": { + "solution.grad_v": { "declaration": "double gradVelocityY[3];", "computation": "gradVelocityY[0] = gradPrimitive[2][0] * velocityScale;" + "gradVelocityY[1] = gradPrimitive[2][1] * velocityScale;" + "gradVelocityY[2] = gradPrimitive[2][2] * velocityScale;", }, - "solution.grad_velocity_z": { + "solution.grad_w": { "declaration": "double gradVelocityZ[3];", "computation": "gradVelocityZ[0] = gradPrimitive[3][0] * velocityScale;" + "gradVelocityZ[1] = gradPrimitive[3][1] * velocityScale;" diff --git a/flow360/component/simulation/user_code/core/context.py b/flow360/component/simulation/user_code/core/context.py index eb064c574..49041b486 100644 --- a/flow360/component/simulation/user_code/core/context.py +++ b/flow360/component/simulation/user_code/core/context.py @@ -88,9 +88,9 @@ def _import_solution(_) -> Any: "Cp", "Cpt", "grad_density", - "grad_velocity_x", - "grad_velocity_y", - "grad_velocity_z", + "grad_u", + "grad_v", + "grad_w", "grad_pressure", "Mach", "mut", diff --git a/flow360/component/simulation/user_code/core/types.py b/flow360/component/simulation/user_code/core/types.py index 3eacd96bb..bd8547ca5 100644 --- a/flow360/component/simulation/user_code/core/types.py +++ b/flow360/component/simulation/user_code/core/types.py @@ -34,6 +34,7 @@ class VariableContextInfo(Flow360BaseModel): name: str value: ValueOrExpression[AnyNumericType] + model_config = pd.ConfigDict(extra="allow") # For front end support def save_user_variables(params): diff --git a/flow360/component/simulation/user_code/variables/solution.py b/flow360/component/simulation/user_code/variables/solution.py index 4aaf2aff0..86f9e69b8 100644 --- a/flow360/component/simulation/user_code/variables/solution.py +++ b/flow360/component/simulation/user_code/variables/solution.py @@ -50,20 +50,20 @@ solver_name="gradDensity", variable_type="Volume", ) -grad_velocity_x = SolverVariable( - name="solution.grad_velocity_x", +grad_u = SolverVariable( + name="solution.grad_u", value=[float("NaN"), float("NaN"), float("NaN")] / u.s, solver_name="gradVelocityX", variable_type="Volume", ) -grad_velocity_y = SolverVariable( - name="solution.grad_velocity_y", +grad_v = SolverVariable( + name="solution.grad_v", value=[float("NaN"), float("NaN"), float("NaN")] / u.s, solver_name="gradVelocityY", variable_type="Volume", ) -grad_velocity_z = SolverVariable( - name="solution.grad_velocity_z", +grad_w = SolverVariable( + name="solution.grad_w", value=[float("NaN"), float("NaN"), float("NaN")] / u.s, solver_name="gradVelocityZ", variable_type="Volume", From e987fbd4a1577a3148e0233b6a56977a3bdafad8 Mon Sep 17 00:00:00 2001 From: Ben <106089368+benflexcompute@users.noreply.github.com> Date: Wed, 18 Jun 2025 14:01:53 -0400 Subject: [PATCH 32/34] Added translator for ValurOrExpression object (#1175) --- .../simulation/translator/solver_translator.py | 3 ++- flow360/component/simulation/translator/utils.py | 14 ++++++++++++++ .../translator/ref/Flow360_user_variable.json | 14 +++++++------- .../translator/test_solver_translator.py | 2 ++ 4 files changed, 25 insertions(+), 8 deletions(-) diff --git a/flow360/component/simulation/translator/solver_translator.py b/flow360/component/simulation/translator/solver_translator.py index e6405d95a..e9723f62b 100644 --- a/flow360/component/simulation/translator/solver_translator.py +++ b/flow360/component/simulation/translator/solver_translator.py @@ -90,6 +90,7 @@ remove_units_in_dict, replace_dict_key, translate_setting_and_apply_to_all_entities, + translate_value_or_expression_object, update_dict_recursively, ) from flow360.component.simulation.unit_system import LengthType @@ -1227,7 +1228,7 @@ def get_solver_json( "physicalSteps": ts.steps, "orderOfAccuracy": ts.order_of_accuracy, "maxPseudoSteps": ts.max_pseudo_steps, - "timeStepSize": ts.step_size.value.item(), + "timeStepSize": translate_value_or_expression_object(ts.step_size, input_params), } elif isinstance(ts, Steady): translated["timeStepping"] = { diff --git a/flow360/component/simulation/translator/utils.py b/flow360/component/simulation/translator/utils.py index d47c52107..ccee88def 100644 --- a/flow360/component/simulation/translator/utils.py +++ b/flow360/component/simulation/translator/utils.py @@ -8,6 +8,7 @@ from typing import Union import numpy as np +import unyt as u from flow360.component.simulation.framework.entity_base import EntityBase, EntityList from flow360.component.simulation.framework.unique_list import UniqueItemList @@ -147,6 +148,19 @@ def _is_unyt_or_unyt_like_obj(value): return input_dict +def translate_value_or_expression_object( + obj: Union[Expression, u.unyt_quantity, u.unyt_array], input_params: SimulationParams +): + """Translate for an ValueOrExpression object""" + if isinstance(obj, Expression): + # Only allowing client-time evaluable expressions + evaluated = obj.evaluate(raise_on_non_evaluable=True) + converted = input_params.convert_unit(evaluated, "flow360").v.item() + return converted + # Non dimensionalized unyt objects + return obj.value.item() + + def inline_expressions_in_dict(input_dict, input_params): """Inline all client-time evaluable expressions in the provided dict to their evaluated values""" if isinstance(input_dict, dict): diff --git a/tests/simulation/translator/ref/Flow360_user_variable.json b/tests/simulation/translator/ref/Flow360_user_variable.json index 641caea55..52980cbd9 100644 --- a/tests/simulation/translator/ref/Flow360_user_variable.json +++ b/tests/simulation/translator/ref/Flow360_user_variable.json @@ -48,16 +48,16 @@ }, "timeStepping": { "CFL": { - "convergenceLimitingFactor": 0.25, - "max": 10000.0, - "maxRelativeChange": 1.0, + "type": "adaptive", "min": 0.1, - "type": "adaptive" + "max": 1000000.0, + "maxRelativeChange": 50.0, + "convergenceLimitingFactor": 1.0 }, - "maxPseudoSteps": 2000, + "physicalSteps": 123, "orderOfAccuracy": 2, - "physicalSteps": 1, - "timeStepSize": "inf" + "maxPseudoSteps": 20, + "timeStepSize": 300.0 }, "turbulenceModelSolver": { "CFLMultiplier": 2.0, diff --git a/tests/simulation/translator/test_solver_translator.py b/tests/simulation/translator/test_solver_translator.py index a702c7526..3beeac723 100644 --- a/tests/simulation/translator/test_solver_translator.py +++ b/tests/simulation/translator/test_solver_translator.py @@ -659,6 +659,7 @@ def test_param_with_user_variables(): my_var = UserVariable( name="my_var", value=math.cross(some_dependent_variable_a, solution.velocity) ) + my_time_stepping_var = UserVariable(name="my_time_stepping_var", value=1.0 * u.s) with SI_unit_system: param = SimulationParams( operating_condition=LiquidOperatingCondition( @@ -682,6 +683,7 @@ def test_param_with_user_variables(): ], ) ], + time_stepping=Unsteady(step_size=my_time_stepping_var + 0.5 * u.s, steps=123), ) # Mimicking real workflow where the Param is serialized and then deserialized params_validated, _, _ = validate_model( From 49b57b1099564103e264836a7ad4efa4b098a08b Mon Sep 17 00:00:00 2001 From: Ben <106089368+benflexcompute@users.noreply.github.com> Date: Wed, 18 Jun 2025 20:11:34 -0400 Subject: [PATCH 33/34] Added postProcessing flag setter (#1176) * Added postProcessing flag setter * Fixed unit test * Fixed v1 unit test --- flow360/component/simulation/simulation_params.py | 10 +++++++++- .../component/simulation/user_code/core/types.py | 15 +++++++++++++-- tests/simulation/data/simulation.json | 3 ++- .../ref/simulation_with_project_variables.json | 9 ++++++--- 4 files changed, 30 insertions(+), 7 deletions(-) diff --git a/flow360/component/simulation/simulation_params.py b/flow360/component/simulation/simulation_params.py index b5a182598..e068c561b 100644 --- a/flow360/component/simulation/simulation_params.py +++ b/flow360/component/simulation/simulation_params.py @@ -389,7 +389,15 @@ def initialize_variable_space(cls, value: dict): 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"]) + value_or_expression = { + key: value + for key, value in variable_dict["value"].items() + if key != "postProcessing" + } + UserVariable( + name=variable_dict["name"], + value=value_or_expression, + ) return value # pylint: disable=no-self-argument diff --git a/flow360/component/simulation/user_code/core/types.py b/flow360/component/simulation/user_code/core/types.py index bd8547ca5..cc1e7e885 100644 --- a/flow360/component/simulation/user_code/core/types.py +++ b/flow360/component/simulation/user_code/core/types.py @@ -34,7 +34,7 @@ class VariableContextInfo(Flow360BaseModel): name: str value: ValueOrExpression[AnyNumericType] - model_config = pd.ConfigDict(extra="allow") # For front end support + postProcessing: bool = pd.Field() def save_user_variables(params): @@ -42,8 +42,19 @@ def save_user_variables(params): Save user variables to the project variables. Declared here since I do not want to import default_context everywhere. """ + # Get all output variables: + post_processing_variables = set() + for item in params.outputs if params.outputs else []: + if not "output_fields" in item.__class__.model_fields: + continue + for item in item.output_fields.items: + if isinstance(item, UserVariable): + post_processing_variables.add(item.name) + params.private_attribute_asset_cache.project_variables = [ - VariableContextInfo(name=name, value=value) + VariableContextInfo( + name=name, value=value, postProcessing=name in post_processing_variables + ) for name, value in default_context._values.items() # pylint: disable=protected-access if "." not in name # Skipping scoped variables (non-user variables) ] diff --git a/tests/simulation/data/simulation.json b/tests/simulation/data/simulation.json index f38572dbd..63e0422bb 100644 --- a/tests/simulation/data/simulation.json +++ b/tests/simulation/data/simulation.json @@ -1386,7 +1386,8 @@ "value": { "type_name": "number", "value": 1 - } + }, + "postProcessing": false } ] } diff --git a/tests/simulation/ref/simulation_with_project_variables.json b/tests/simulation/ref/simulation_with_project_variables.json index 41e970e5b..fcf4c83e9 100644 --- a/tests/simulation/ref/simulation_with_project_variables.json +++ b/tests/simulation/ref/simulation_with_project_variables.json @@ -246,7 +246,8 @@ "type_name": "number", "value": 12.0, "units": "m/s" - } + }, + "postProcessing": false }, { "name": "aaa", @@ -259,7 +260,8 @@ null ], "evaluated_units": "m/s" - } + }, + "postProcessing": false }, { "name": "bbb", @@ -273,7 +275,8 @@ ], "evaluated_units": "m/s", "output_units": "km/ms" - } + }, + "postProcessing": true } ] } From 096b3f48b73d11e5143719cb8fa0bd1e08a5e46d Mon Sep 17 00:00:00 2001 From: Angran Date: Wed, 18 Jun 2025 20:13:15 -0400 Subject: [PATCH 34/34] Decouple solver variable's solver name with user variable name (#1170) * Initial implementation * Fix unit test --- .../component/simulation/outputs/outputs.py | 9 +- .../translator/solver_translator.py | 9 +- .../translator/user_expression_utils.py | 281 +++++++----------- .../user_code/variables/solution.py | 46 +-- tests/simulation/test_expressions.py | 4 +- .../ref/Flow360_expression_udf.json | 14 +- .../translator/ref/Flow360_user_variable.json | 16 +- .../translator/test_output_translation.py | 2 +- 8 files changed, 156 insertions(+), 225 deletions(-) diff --git a/flow360/component/simulation/outputs/outputs.py b/flow360/component/simulation/outputs/outputs.py index aa4d0614d..14d9521f0 100644 --- a/flow360/component/simulation/outputs/outputs.py +++ b/flow360/component/simulation/outputs/outputs.py @@ -36,7 +36,7 @@ GhostSurface, Surface, ) -from flow360.component.simulation.unit_system import LengthType +from flow360.component.simulation.unit_system import LengthType, unit_system_manager from flow360.component.simulation.user_code.core.types import ( SolverVariable, UserVariable, @@ -129,8 +129,13 @@ def _convert_solver_variables_as_user_variables(cls, value): # Handle both dict/list (deserialization) and UniqueItemList (python object) def solver_variable_to_user_variable(item): if isinstance(item, SolverVariable): + if unit_system_manager.current is None: + raise ValueError( + f"Solver variable {item.name} cannot be used without a unit system." + ) + unit_system_name = unit_system_manager.current.name name = item.name.split(".")[-1] if "." in item.name else item.name - return UserVariable(name=name, value=item) + return UserVariable(name=f"{name}_{unit_system_name}", value=item) return item # If input is a dict (from deserialization so no SolverVariable expected) diff --git a/flow360/component/simulation/translator/solver_translator.py b/flow360/component/simulation/translator/solver_translator.py index e9723f62b..53327ecb1 100644 --- a/flow360/component/simulation/translator/solver_translator.py +++ b/flow360/component/simulation/translator/solver_translator.py @@ -580,14 +580,7 @@ def _prepare_prepending_code(expression: Expression): for name in expression.solver_variable_names(): if not udf_prepending_code.get(name): continue - if name.split(".")[-1] == variable.name: - # Avoid duplicate declaration if the intermediate variable name is - # the same as the solver_name. - prepending_code.append(udf_prepending_code[name]["computation"]) - continue - prepending_code.append( - udf_prepending_code[name]["declaration"] + udf_prepending_code[name]["computation"] - ) + prepending_code.append(udf_prepending_code[name]) prepending_code = "".join(prepending_code) return prepending_code diff --git a/flow360/component/simulation/translator/user_expression_utils.py b/flow360/component/simulation/translator/user_expression_utils.py index 04e5184d2..0c49e98b0 100644 --- a/flow360/component/simulation/translator/user_expression_utils.py +++ b/flow360/component/simulation/translator/user_expression_utils.py @@ -3,178 +3,111 @@ import numpy as np udf_prepending_code = { - "solution.Cp": { - "declaration": "double Cp;", - "computation": "Cp = (primitiveVars[4] - pressureFreestream) / (0.5 * MachRef * MachRef);", - }, - "solution.Cpt": { - "declaration": "double Cpt;", - "computation": "double MachTmp = sqrt(primitiveVars[1] * primitiveVars[1] + " - + "primitiveVars[2] * primitiveVars[2] + primitiveVars[3] * primitiveVars[3]) / " - + "sqrt(1.4 * primitiveVars[4] / primitiveVars[0]);" - + "Cpt = (1.4 * primitiveVars[4] * pow(1.0 + (1.4 - 1.0) / 2. * MachTmp * MachTmp," - + "1.4 / (1.4 - 1.0)) - pow(1.0 + (1.4 - 1.0) / 2. * MachRef * MachRef," - + "1.4 / (1.4 - 1.0))) / (0.5 * 1.4 * MachRef * MachRef);", - }, - "solution.grad_density": { - "declaration": "double gradDensity[3];", - "computation": " gradDensity[0] = gradPrimitive[0][0];" - + "gradDensity[1] = gradPrimitive[0][1];" - + "gradDensity[2] = gradPrimitive[0][2];", - }, - "solution.grad_u": { - "declaration": "double gradVelocityX[3];", - "computation": "gradVelocityX[0] = gradPrimitive[1][0] * velocityScale;" - + "gradVelocityX[1] = gradPrimitive[1][1] * velocityScale;" - + "gradVelocityX[2] = gradPrimitive[1][2] * velocityScale;", - }, - "solution.grad_v": { - "declaration": "double gradVelocityY[3];", - "computation": "gradVelocityY[0] = gradPrimitive[2][0] * velocityScale;" - + "gradVelocityY[1] = gradPrimitive[2][1] * velocityScale;" - + "gradVelocityY[2] = gradPrimitive[2][2] * velocityScale;", - }, - "solution.grad_w": { - "declaration": "double gradVelocityZ[3];", - "computation": "gradVelocityZ[0] = gradPrimitive[3][0] * velocityScale;" - + "gradVelocityZ[1] = gradPrimitive[3][1] * velocityScale;" - + "gradVelocityZ[2] = gradPrimitive[3][2] * velocityScale;", - }, - "solution.grad_pressure": { - "declaration": "double gradPressure[3];", - "computation": "gradPressure[0] = gradPrimitive[4][0]; " - + "gradPressure[1] = gradPrimitive[4][1]; " - + "gradPressure[2] = gradPrimitive[4][2];", - }, - "solution.Mach": { - "declaration": "double Mach;", - "computation": "Mach = usingLiquidAsMaterial ? 0 : " - + "sqrt(primitiveVars[1] * primitiveVars[1] + " - + "primitiveVars[2] * primitiveVars[2] + " - + "primitiveVars[3] * primitiveVars[3]) / " - + "sqrt(1.4 * primitiveVars[4] / primitiveVars[0]);", - }, - "solution.mut_ratio": { - "declaration": "double mutRatio;", - "computation": "mutRatio = mut / mu;", - }, - "solution.nu_hat": { - "declaration": "double nuHat;", - "computation": "nuHat = SpalartAllmaras_solution * velocityScale;", - }, - "solution.turbulence_kinetic_energy": { - "declaration": "double turbulenceKineticEnergy;", - "computation": "turbulenceKineticEnergy = kOmegaSST_solution[0] * pow(velocityScale, 2);", - }, - "solution.specific_rate_of_dissipation": { - "declaration": "double specificRateOfDissipation;", - "computation": "specificRateOfDissipation = kOmegaSST_solution[1] * velocityScale;", - }, - "solution.velocity": { - "declaration": "double velocity[3];", - "computation": "velocity[0] = primitiveVars[1] * velocityScale;" - + "velocity[1] = primitiveVars[2] * velocityScale;" - + "velocity[2] = primitiveVars[3] * velocityScale;", - }, - "solution.velocity_magnitude": { - "declaration": "double velocityMagnitude;", - "computation": "double velocityTmp[3];velocityTmp[0] = primitiveVars[1] * velocityScale;" - + "velocityTmp[1] = primitiveVars[2] * velocityScale;" - + "velocityTmp[2] = primitiveVars[3] * velocityScale;" - + "velocityMagnitude = magnitude(velocityTmp);", - }, - "solution.qcriterion": { - "declaration": "double qcriterion;", - "computation": "double ux = gradPrimitive[1][0];" - + "double uy = gradPrimitive[1][1];" - + "double uz = gradPrimitive[1][2];" - + "double vx = gradPrimitive[2][0];" - + "double vy = gradPrimitive[2][1];" - + "double vz = gradPrimitive[2][2];" - + "double wx = gradPrimitive[3][0];" - + "double wy = gradPrimitive[3][1];" - + "double wz = gradPrimitive[3][2];" - + "double str11 = ux;" - + "double str22 = vy;" - + "double str33 = wz;" - + "double str12 = 0.5 * (uy + vx);" - + "double str13 = 0.5 * (uz + wx);" - + "double str23 = 0.5 * (vz + wy);" - + "double str_norm = str11 * str11 + str22 * str22 + str33 * str33 + " - + "2 * (str12 * str12) + 2 * (str13 * str13) + 2 * (str23 * str23);" - + "double omg12 = 0.5 * (uy - vx);" - + "double omg13 = 0.5 * (uz - wx);" - + "double omg23 = 0.5 * (vz - wy);" - + "double omg_norm = 2 * (omg12 * omg12) + 2 * (omg13 * omg13) + 2 * (omg23 * omg23);" - + "qcriterion = 0.5 * (omg_norm - str_norm) * (velocityScale * velocityScale);", - }, - "solution.entropy": { - "declaration": "double entropy;", - "computation": "entropy = log(primitiveVars[4] / (1.0 / 1.4) / pow(primitiveVars[0], 1.4));", - }, - "solution.temperature": { - "declaration": "double temperature;", - "computation": f"double epsilon = {np.finfo(np.float64).eps};" - "temperature = (primitiveVars[0] < epsilon && HeatEquation_solution != nullptr) ? " - "HeatEquation_solution[0] : primitiveVars[4] / (primitiveVars[0] * (1.0 / 1.4));", - }, - "solution.vorticity": { - "declaration": "double vorticity[3];", - "computation": "vorticity[0] = (gradPrimitive[3][1] - gradPrimitive[2][2]) * velocityScale;" - + "vorticity[1] = (gradPrimitive[1][2] - gradPrimitive[3][0]) * velocityScale;" - + "vorticity[2] = (gradPrimitive[2][0] - gradPrimitive[1][1]) * velocityScale;", - }, - "solution.vorticity_magnitude": { - "declaration": "double vorticityMagnitude;", - "computation": "double vorticityTmp[3];" - + "vorticityTmp[0] = (gradPrimitive[3][1] - gradPrimitive[2][2]) * velocityScale;" - + "vorticityTmp[1] = (gradPrimitive[1][2] - gradPrimitive[3][0]) * velocityScale;" - + "vorticityTmp[2] = (gradPrimitive[2][0] - gradPrimitive[1][1]) * velocityScale;" - + "vorticityMagnitude = magnitude(vorticityTmp);", - }, - "solution.CfVec": { - "declaration": "double CfVec[3];", - "computation": "for (int i = 0; i < 3; i++)" - + "{CfVec[i] = wallShearStress[i] / (0.5 * MachRef * MachRef);}", - }, - "solution.Cf": { - "declaration": "double Cf;", - "computation": "Cf = magnitude(wallShearStress) / (0.5 * MachRef * MachRef);", - }, - "solution.node_forces_per_unit_area": { - "declaration": "double nodeForcesPerUnitArea[3];", - "computation": "double normalMag = magnitude(nodeNormals);" - + "for (int i = 0; i < 3; i++){nodeForcesPerUnitArea[i] = " - + "((primitiveVars[4] - pressureFreestream) * nodeNormals[i] / normalMag + wallViscousStress[i])" - + " * (velocityScale * velocityScale);}", - }, - "solution.heat_transfer_coefficient_static_temperature": { - "declaration": "double heatTransferCoefficientStaticTemperature;", - "computation": "double temperatureTmp = " - + "primitiveVars[4] / (primitiveVars[0] * 1.0 / 1.4);" - + f"double epsilon = {np.finfo(np.float64).eps};" - + "double temperatureSafeDivide = (temperatureTmp - 1.0 < 0) ? " - + "temperatureTmp - 1.0 - epsilon : " - + "temperatureTmp - 1.0 + epsilon;" - + "heatTransferCoefficientStaticTemperature = " - + "abs(temperatureTmp - 1.0) > epsilon ? " - + "- wallHeatFlux / temperatureSafeDivide : 1.0 / epsilon;", - }, - "solution.heat_transfer_coefficient_total_temperature": { - "declaration": "double heatTransferCoefficientTotalTemperature;", - "computation": "double temperatureTmp = " - + "primitiveVars[4] / (primitiveVars[0] * 1.0 / 1.4);" - + "double temperatureTotal = 1.0 + (1.4 - 1.0) / 2.0 * MachRef * MachRef;" - + f"double epsilon = {np.finfo(np.float64).eps};" - + "double temperatureSafeDivide = (temperatureTmp - temperatureTotal < 0) ? " - + "temperatureTmp - temperatureTotal - epsilon : " - + "temperatureTmp - temperatureTotal + epsilon;" - + "double heatTransferCoefficientTotalTemperature = " - + "abs(temperatureTmp - temperatureTotal) > epsilon ? " - + "temperatureTotal = - wallHeatFlux / temperatureSafeDivide : 1.0 / epsilon;", - }, - "solution.wall_shear_stress_magnitude": { - "declaration": "double wallShearStressMagnitude;", - "computation": "wallShearStressMagnitude = magnitude(wallShearStress);", - }, + "solution.Cp": "double ___Cp = (primitiveVars[4] - pressureFreestream) / (0.5 * MachRef * MachRef);", + "solution.Cpt": "double ___MachTmp = sqrt(primitiveVars[1] * primitiveVars[1] + " + + "primitiveVars[2] * primitiveVars[2] + primitiveVars[3] * primitiveVars[3]) / " + + "sqrt(1.4 * primitiveVars[4] / primitiveVars[0]);" + + "double ___Cpt = (1.4 * primitiveVars[4] * pow(1.0 + (1.4 - 1.0) / 2. * ___MachTmp * ___MachTmp," + + "1.4 / (1.4 - 1.0)) - pow(1.0 + (1.4 - 1.0) / 2. * MachRef * MachRef," + + "1.4 / (1.4 - 1.0))) / (0.5 * 1.4 * MachRef * MachRef);", + "solution.grad_density": "double ___grad_density[3]; ___grad_density[0] = gradPrimitive[0][0];" + + "___grad_density[1] = gradPrimitive[0][1];" + + "___grad_density[2] = gradPrimitive[0][2];", + "solution.grad_u": "double ___grad_u[3];" + + "___grad_u[0] = gradPrimitive[1][0] * velocityScale;" + + "___grad_u[1] = gradPrimitive[1][1] * velocityScale;" + + "___grad_u[2] = gradPrimitive[1][2] * velocityScale;", + "solution.grad_v": "double ___grad_v[3];" + + "___grad_v[0] = gradPrimitive[2][0] * velocityScale;" + + "___grad_v[1] = gradPrimitive[2][1] * velocityScale;" + + "___grad_v[2] = gradPrimitive[2][2] * velocityScale;", + "solution.grad_w": "double ___grad_w[3];" + + "___grad_w[0] = gradPrimitive[3][0] * velocityScale;" + + "___grad_w[1] = gradPrimitive[3][1] * velocityScale;" + + "___grad_w[2] = gradPrimitive[3][2] * velocityScale;", + "solution.grad_pressure": "double ___grad_pressure[3];" + + "___grad_pressure[0] = gradPrimitive[4][0];" + + "___grad_pressure[1] = gradPrimitive[4][1];" + + "___grad_pressure[2] = gradPrimitive[4][2];", + "solution.Mach": "double ___Mach;" + + "___Mach = usingLiquidAsMaterial ? 0 : " + + "sqrt(primitiveVars[1] * primitiveVars[1] + " + + "primitiveVars[2] * primitiveVars[2] + " + + "primitiveVars[3] * primitiveVars[3]) / " + + "sqrt(1.4 * primitiveVars[4] / primitiveVars[0]);", + "solution.mut_ratio": "double ___mut_ratio; ___mut_ratio = mut / mu;", + "solution.nu_hat": "double ___nu_hat;___nu_hat = solutionTurbulence * velocityScale;", + "solution.turbulence_kinetic_energy": "double ___turbulence_kinetic_energy;" + "___turbulence_kinetic_energy = solutionTurbulence[0] * pow(velocityScale, 2);", + "solution.specific_rate_of_dissipation": "double ___specific_rate_of_dissipation;" + + "___specific_rate_of_dissipation = solutionTurbulence[1] * velocityScale;", + "solution.velocity": "double ___velocity[3];" + + "___velocity[0] = primitiveVars[1] * velocityScale;" + + "___velocity[1] = primitiveVars[2] * velocityScale;" + + "___velocity[2] = primitiveVars[3] * velocityScale;", + "solution.qcriterion": "double ___qcriterion;" + + "double ___ux = gradPrimitive[1][0];" + + "double ___uy = gradPrimitive[1][1];" + + "double ___uz = gradPrimitive[1][2];" + + "double ___vx = gradPrimitive[2][0];" + + "double ___vy = gradPrimitive[2][1];" + + "double ___vz = gradPrimitive[2][2];" + + "double ___wx = gradPrimitive[3][0];" + + "double ___wy = gradPrimitive[3][1];" + + "double ___wz = gradPrimitive[3][2];" + + "double ___str11 = ___ux;" + + "double ___str22 = ___vy;" + + "double ___str33 = ___wz;" + + "double ___str12 = 0.5 * (___uy + ___vx);" + + "double ___str13 = 0.5 * (___uz + ___wx);" + + "double ___str23 = 0.5 * (___vz + ___wy);" + + "double ___str_norm = ___str11 * ___str11 + ___str22 * ___str22 + ___str33 * ___str33 + " + + "2 * (___str12 * ___str12) + 2 * (___str13 * ___str13) + 2 * (___str23 * ___str23);" + + "double ___omg12 = 0.5 * (___uy - ___vx);" + + "double ___omg13 = 0.5 * (___uz - ___wx);" + + "double ___omg23 = 0.5 * (___vz - ___wy);" + + "double ___omg_norm = 2 * (___omg12 * ___omg12) + 2 * (___omg13 * ___omg13) + 2 * (___omg23 * ___omg23);" + + "___qcriterion = 0.5 * (___omg_norm - ___str_norm) * (velocityScale * velocityScale);", + "solution.entropy": "double ___entropy;" + + "___entropy = log(primitiveVars[4] / (1.0 / 1.4) / pow(primitiveVars[0], 1.4));", + "solution.temperature": "double ___temperature;" + + f"double ___epsilon = {np.finfo(np.float64).eps};" + "___temperature = (primitiveVars[0] < ___epsilon && HeatEquation_solution != nullptr) ? " + "HeatEquation_solution[0] : primitiveVars[4] / (primitiveVars[0] * (1.0 / 1.4));", + "solution.vorticity": "double ___vorticity[3];" + + "___vorticity[0] = (gradPrimitive[3][1] - gradPrimitive[2][2]) * velocityScale;" + + "___vorticity[1] = (gradPrimitive[1][2] - gradPrimitive[3][0]) * velocityScale;" + + "___vorticity[2] = (gradPrimitive[2][0] - gradPrimitive[1][1]) * velocityScale;", + "solution.CfVec": "double ___CfVec[3]; for (int i = 0; i < 3; i++)" + + "{___CfVec[i] = wallShearStress[i] / (0.5 * MachRef * MachRef);}", + "solution.Cf": "double ___Cf;" + + "___Cf = magnitude(wallShearStress) / (0.5 * MachRef * MachRef);", + "solution.node_forces_per_unit_area": "double ___node_forces_per_unit_area[3];" + + "double ___normalMag = magnitude(nodeNormals);" + + "for (int i = 0; i < 3; i++){___node_forces_per_unit_area[i] = " + + "((primitiveVars[4] - pressureFreestream) * nodeNormals[i] / ___normalMag + wallViscousStress[i])" + + " * (velocityScale * velocityScale);}", + "solution.heat_transfer_coefficient_static_temperature": "double ___heat_transfer_coefficient_static_temperature;" + + "double ___temperatureTmp = " + + "primitiveVars[4] / (primitiveVars[0] * 1.0 / 1.4);" + + f"double ___epsilon = {np.finfo(np.float64).eps};" + + "double ___temperatureSafeDivide = (___temperatureTmp - 1.0 < 0) ? " + + "___temperatureTmp - 1.0 - ___epsilon : " + + "___temperatureTmp - 1.0 + ___epsilon;" + + "___heat_transfer_coefficient_static_temperature = " + + "abs(___temperatureTmp - 1.0) > ___epsilon ? " + + "- wallHeatFlux / ___temperatureSafeDivide : 1.0 / ___epsilon;", + "solution.heat_transfer_coefficient_total_temperature": "double ___heat_transfer_coefficient_total_temperature;" + + "double ___temperatureTmp = " + + "primitiveVars[4] / (primitiveVars[0] * 1.0 / 1.4);" + + "double ___temperatureTotal = 1.0 + (1.4 - 1.0) / 2.0 * MachRef * MachRef;" + + f"double ___epsilon = {np.finfo(np.float64).eps};" + + "double ___temperatureSafeDivide = (___temperatureTmp - ___temperatureTotal < 0) ? " + + "___temperatureTmp - ___temperatureTotal - ___epsilon : " + + "___temperatureTmp - ___temperatureTotal + ___epsilon;" + + "double ___heat_transfer_coefficient_total_temperature = " + + "abs(___temperatureTmp - ___temperatureTotal) > ___epsilon ? " + + "___temperatureTotal = - wallHeatFlux / ___temperatureSafeDivide : 1.0 / ___epsilon;", + "solution.wall_shear_stress_magnitude": "double ___wall_shear_stress_magnitude;" + + "___wall_shear_stress_magnitude = magnitude(wallShearStress);", } diff --git a/flow360/component/simulation/user_code/variables/solution.py b/flow360/component/simulation/user_code/variables/solution.py index 86f9e69b8..9ca910689 100644 --- a/flow360/component/simulation/user_code/variables/solution.py +++ b/flow360/component/simulation/user_code/variables/solution.py @@ -35,50 +35,50 @@ Cp = SolverVariable( name="solution.Cp", value=float("NaN"), - solver_name="Cp", + solver_name="___Cp", variable_type="Volume", ) Cpt = SolverVariable( name="solution.Cpt", value=float("NaN"), - solver_name="Cpt", + solver_name="___Cpt", variable_type="Volume", ) grad_density = SolverVariable( name="solution.grad_density", value=[float("NaN"), float("NaN"), float("NaN")] * u.kg / u.m**4, - solver_name="gradDensity", + solver_name="___grad_density", variable_type="Volume", ) grad_u = SolverVariable( name="solution.grad_u", value=[float("NaN"), float("NaN"), float("NaN")] / u.s, - solver_name="gradVelocityX", + solver_name="___grad_u", variable_type="Volume", ) grad_v = SolverVariable( name="solution.grad_v", value=[float("NaN"), float("NaN"), float("NaN")] / u.s, - solver_name="gradVelocityY", + solver_name="___grad_v", variable_type="Volume", ) grad_w = SolverVariable( name="solution.grad_w", value=[float("NaN"), float("NaN"), float("NaN")] / u.s, - solver_name="gradVelocityZ", + solver_name="___grad_w", variable_type="Volume", ) grad_pressure = SolverVariable( name="solution.grad_pressure", value=[float("NaN"), float("NaN"), float("NaN")] * u.Pa / u.m, - solver_name="gradPressure", + solver_name="___grad_pressure", variable_type="Volume", ) Mach = SolverVariable( name="solution.Mach", value=float("NaN"), - solver_name="Mach", + solver_name="___Mach", variable_type="Volume", ) mut = SolverVariable( @@ -90,25 +90,25 @@ mut_ratio = SolverVariable( name="solution.mut_ratio", value=float("NaN"), - solver_name="mutRatio", + solver_name="___mut_ratio", variable_type="Volume", ) nu_hat = SolverVariable( name="solution.nu_hat", value=float("NaN") * u.m**2 / u.s, - solver_name="nuHat", + solver_name="___nu_hat", variable_type="Volume", ) turbulence_kinetic_energy = SolverVariable( name="solution.turbulence_kinetic_energy", value=float("NaN") * u.J / u.kg, - solver_name="turbulenceKineticEnergy", + solver_name="___turbulence_kinetic_energy", variable_type="Volume", ) # k specific_rate_of_dissipation = SolverVariable( name="solution.specific_rate_of_dissipation", value=float("NaN") / u.s, - solver_name="specificRateOfDissipation", + solver_name="___specific_rate_of_dissipation", variable_type="Volume", ) # Omega amplification_factor = SolverVariable( @@ -134,7 +134,7 @@ velocity = SolverVariable( name="solution.velocity", value=[float("NaN"), float("NaN"), float("NaN")] * u.m / u.s, - solver_name="velocity", + solver_name="___velocity", variable_type="Volume", ) pressure = SolverVariable( @@ -147,25 +147,25 @@ qcriterion = SolverVariable( name="solution.qcriterion", value=float("NaN") / u.s**2, - solver_name="qcriterion", + solver_name="___qcriterion", variable_type="Volume", ) entropy = SolverVariable( name="solution.entropy", value=float("NaN") * u.J / u.K, - solver_name="entropy", + solver_name="___entropy", variable_type="Volume", ) temperature = SolverVariable( name="solution.temperature", value=float("NaN") * u.K, - solver_name="temperature", + solver_name="___temperature", variable_type="Volume", ) vorticity = SolverVariable( name="solution.vorticity", value=[float("NaN"), float("NaN"), float("NaN")] / u.s, - solver_name="vorticity", + solver_name="___vorticity", variable_type="Volume", ) wall_distance = SolverVariable( @@ -179,13 +179,13 @@ CfVec = SolverVariable( name="solution.CfVec", value=[float("NaN"), float("NaN"), float("NaN")], - solver_name="CfVec", + solver_name="___CfVec", variable_type="Surface", ) Cf = SolverVariable( name="solution.Cf", value=float("NaN"), - solver_name="Cf", + solver_name="___Cf", variable_type="Surface", ) heatflux = SolverVariable( @@ -203,7 +203,7 @@ node_forces_per_unit_area = SolverVariable( name="solution.node_forces_per_unit_area", value=[float("NaN"), float("NaN"), float("NaN")] * u.Pa, - solver_name="nodeForcesPerUnitArea", + solver_name="___node_forces_per_unit_area", variable_type="Surface", ) y_plus = SolverVariable( @@ -212,19 +212,19 @@ wall_shear_stress_magnitude = SolverVariable( name="solution.wall_shear_stress_magnitude", value=float("NaN") * u.Pa, - solver_name="wallShearStressMagnitude", + solver_name="___wall_shear_stress_magnitude", variable_type="Surface", ) heat_transfer_coefficient_static_temperature = SolverVariable( name="solution.heat_transfer_coefficient_static_temperature", value=float("NaN") * u.W / (u.m**2 * u.K), - solver_name="heatTransferCoefficientStaticTemperature", + solver_name="___heat_transfer_coefficient_static_temperature", variable_type="Surface", ) heat_transfer_coefficient_total_temperature = SolverVariable( name="solution.heat_transfer_coefficient_total_temperature", value=float("NaN") * u.W / (u.m**2 * u.K), - solver_name="heatTransferCoefficientTotalTemperature", + solver_name="___heat_transfer_coefficient_total_temperature", variable_type="Surface", ) diff --git a/tests/simulation/test_expressions.py b/tests/simulation/test_expressions.py index 1a9fd2d11..3252d8b98 100644 --- a/tests/simulation/test_expressions.py +++ b/tests/simulation/test_expressions.py @@ -1055,7 +1055,7 @@ def test_udf_generator(): # velocity scale = 100 m/s, assert ( result.expression - == "double velocity[3];velocity[0] = primitiveVars[1] * velocityScale;velocity[1] = primitiveVars[2] * velocityScale;velocity[2] = primitiveVars[3] * velocityScale;velocity_in_SI[0] = (velocity[0] * 100.0); velocity_in_SI[1] = (velocity[1] * 100.0); velocity_in_SI[2] = (velocity[2] * 100.0);" + == "double ___velocity[3];___velocity[0] = primitiveVars[1] * velocityScale;___velocity[1] = primitiveVars[2] * velocityScale;___velocity[2] = primitiveVars[3] * velocityScale;velocity_in_SI[0] = (___velocity[0] * 100.0); velocity_in_SI[1] = (___velocity[1] * 100.0); velocity_in_SI[2] = (___velocity[2] * 100.0);" ) vel_cross_vec = UserVariable( @@ -1064,7 +1064,7 @@ def test_udf_generator(): result = user_variable_to_udf(vel_cross_vec, input_params=params) assert ( result.expression - == "double velocity[3];velocity[0] = primitiveVars[1] * velocityScale;velocity[1] = primitiveVars[2] * velocityScale;velocity[2] = primitiveVars[3] * velocityScale;vel_cross_vec[0] = ((((velocity[1] * 3) * 0.001) - ((velocity[2] * 2) * 0.001)) * 10.0); vel_cross_vec[1] = ((((velocity[2] * 1) * 0.001) - ((velocity[0] * 3) * 0.001)) * 10.0); vel_cross_vec[2] = ((((velocity[0] * 2) * 0.001) - ((velocity[1] * 1) * 0.001)) * 10.0);" + == "double ___velocity[3];___velocity[0] = primitiveVars[1] * velocityScale;___velocity[1] = primitiveVars[2] * velocityScale;___velocity[2] = primitiveVars[3] * velocityScale;vel_cross_vec[0] = ((((___velocity[1] * 3) * 0.001) - ((___velocity[2] * 2) * 0.001)) * 10.0); vel_cross_vec[1] = ((((___velocity[2] * 1) * 0.001) - ((___velocity[0] * 3) * 0.001)) * 10.0); vel_cross_vec[2] = ((((___velocity[0] * 2) * 0.001) - ((___velocity[1] * 1) * 0.001)) * 10.0);" ) vel_cross_vec = UserVariable( diff --git a/tests/simulation/translator/ref/Flow360_expression_udf.json b/tests/simulation/translator/ref/Flow360_expression_udf.json index 884469911..32d8355cc 100644 --- a/tests/simulation/translator/ref/Flow360_expression_udf.json +++ b/tests/simulation/translator/ref/Flow360_expression_udf.json @@ -80,8 +80,8 @@ "boundaries": {}, "volumeOutput": { "outputFields": [ - "Mach", - "velocity", + "Mach_SI", + "velocity_SI", "uuu" ], "outputFormat": "paraview", @@ -94,16 +94,16 @@ }, "userDefinedFields": [ { - "name": "Mach", - "expression": "Mach = usingLiquidAsMaterial ? 0 : sqrt(primitiveVars[1] * primitiveVars[1] + primitiveVars[2] * primitiveVars[2] + primitiveVars[3] * primitiveVars[3]) / sqrt(1.4 * primitiveVars[4] / primitiveVars[0]);Mach = (Mach * 1);" + "name": "Mach_SI", + "expression": "double ___Mach;___Mach = usingLiquidAsMaterial ? 0 : sqrt(primitiveVars[1] * primitiveVars[1] + primitiveVars[2] * primitiveVars[2] + primitiveVars[3] * primitiveVars[3]) / sqrt(1.4 * primitiveVars[4] / primitiveVars[0]);Mach_SI = (___Mach * 1);" }, { - "name": "velocity", - "expression": "velocity[0] = primitiveVars[1] * velocityScale;velocity[1] = primitiveVars[2] * velocityScale;velocity[2] = primitiveVars[3] * velocityScale;velocity[0] = (velocity[0] * 340.2940058082124); velocity[1] = (velocity[1] * 340.2940058082124); velocity[2] = (velocity[2] * 340.2940058082124);" + "name": "velocity_SI", + "expression": "double ___velocity[3];___velocity[0] = primitiveVars[1] * velocityScale;___velocity[1] = primitiveVars[2] * velocityScale;___velocity[2] = primitiveVars[3] * velocityScale;velocity_SI[0] = (___velocity[0] * 340.2940058082124); velocity_SI[1] = (___velocity[1] * 340.2940058082124); velocity_SI[2] = (___velocity[2] * 340.2940058082124);" }, { "name": "uuu", - "expression": "double velocity[3];velocity[0] = primitiveVars[1] * velocityScale;velocity[1] = primitiveVars[2] * velocityScale;velocity[2] = primitiveVars[3] * velocityScale;uuu[0] = (velocity[0] * 340.2940058082124); uuu[1] = (velocity[1] * 340.2940058082124); uuu[2] = (velocity[2] * 340.2940058082124);" + "expression": "double ___velocity[3];___velocity[0] = primitiveVars[1] * velocityScale;___velocity[1] = primitiveVars[2] * velocityScale;___velocity[2] = primitiveVars[3] * velocityScale;uuu[0] = (___velocity[0] * 340.2940058082124); uuu[1] = (___velocity[1] * 340.2940058082124); uuu[2] = (___velocity[2] * 340.2940058082124);" } ], "usingLiquidAsMaterial": false, diff --git a/tests/simulation/translator/ref/Flow360_user_variable.json b/tests/simulation/translator/ref/Flow360_user_variable.json index 52980cbd9..da8ecfe89 100644 --- a/tests/simulation/translator/ref/Flow360_user_variable.json +++ b/tests/simulation/translator/ref/Flow360_user_variable.json @@ -97,19 +97,19 @@ "name": "velocity_magnitude" }, { - "expression": "Mach = usingLiquidAsMaterial ? 0 : sqrt(primitiveVars[1] * primitiveVars[1] + primitiveVars[2] * primitiveVars[2] + primitiveVars[3] * primitiveVars[3]) / sqrt(1.4 * primitiveVars[4] / primitiveVars[0]);Mach = (Mach * 1);", - "name": "Mach" + "expression": "double ___Mach;___Mach = usingLiquidAsMaterial ? 0 : sqrt(primitiveVars[1] * primitiveVars[1] + primitiveVars[2] * primitiveVars[2] + primitiveVars[3] * primitiveVars[3]) / sqrt(1.4 * primitiveVars[4] / primitiveVars[0]);Mach_SI = (___Mach * 1);", + "name": "Mach_SI" }, { - "expression": "velocity[0] = primitiveVars[1] * velocityScale;velocity[1] = primitiveVars[2] * velocityScale;velocity[2] = primitiveVars[3] * velocityScale;velocity[0] = (velocity[0] * 200.0); velocity[1] = (velocity[1] * 200.0); velocity[2] = (velocity[2] * 200.0);", - "name": "velocity" + "expression": "double ___velocity[3];___velocity[0] = primitiveVars[1] * velocityScale;___velocity[1] = primitiveVars[2] * velocityScale;___velocity[2] = primitiveVars[3] * velocityScale;velocity_SI[0] = (___velocity[0] * 200.0); velocity_SI[1] = (___velocity[1] * 200.0); velocity_SI[2] = (___velocity[2] * 200.0);", + "name": "velocity_SI" }, { - "expression": "double velocity[3];velocity[0] = primitiveVars[1] * velocityScale;velocity[1] = primitiveVars[2] * velocityScale;velocity[2] = primitiveVars[3] * velocityScale;uuu[0] = (velocity[0] * 0.0002); uuu[1] = (velocity[1] * 0.0002); uuu[2] = (velocity[2] * 0.0002);", + "expression": "double ___velocity[3];___velocity[0] = primitiveVars[1] * velocityScale;___velocity[1] = primitiveVars[2] * velocityScale;___velocity[2] = primitiveVars[3] * velocityScale;uuu[0] = (___velocity[0] * 0.0002); uuu[1] = (___velocity[1] * 0.0002); uuu[2] = (___velocity[2] * 0.0002);", "name": "uuu" }, { - "expression": "double velocity[3];velocity[0] = primitiveVars[1] * velocityScale;velocity[1] = primitiveVars[2] * velocityScale;velocity[2] = primitiveVars[3] * velocityScale;my_var[0] = (((((2.0 * 1.0) / 200.0) * velocity[2]) - (((3.0 * 1.0) / 200.0) * velocity[1])) * 40000.0); my_var[1] = (((((3.0 * 1.0) / 200.0) * velocity[0]) - (((1.0 * 1.0) / 200.0) * velocity[2])) * 40000.0); my_var[2] = (((((1.0 * 1.0) / 200.0) * velocity[1]) - (((2.0 * 1.0) / 200.0) * velocity[0])) * 40000.0);", + "expression": "double ___velocity[3];___velocity[0] = primitiveVars[1] * velocityScale;___velocity[1] = primitiveVars[2] * velocityScale;___velocity[2] = primitiveVars[3] * velocityScale;my_var[0] = (((((2.0 * 1.0) / 200.0) * ___velocity[2]) - (((3.0 * 1.0) / 200.0) * ___velocity[1])) * 40000.0); my_var[1] = (((((3.0 * 1.0) / 200.0) * ___velocity[0]) - (((1.0 * 1.0) / 200.0) * ___velocity[2])) * 40000.0); my_var[2] = (((((1.0 * 1.0) / 200.0) * ___velocity[1]) - (((2.0 * 1.0) / 200.0) * ___velocity[0])) * 40000.0);", "name": "my_var" } ], @@ -121,8 +121,8 @@ "animationFrequencyTimeAverageOffset": 0, "computeTimeAverages": false, "outputFields": [ - "Mach", - "velocity", + "Mach_SI", + "velocity_SI", "uuu", "my_var" ], diff --git a/tests/simulation/translator/test_output_translation.py b/tests/simulation/translator/test_output_translation.py index 8f69bb811..3918e6d8e 100644 --- a/tests/simulation/translator/test_output_translation.py +++ b/tests/simulation/translator/test_output_translation.py @@ -1254,7 +1254,7 @@ def test_dimensioned_output_fields_translation(vel_in_km_per_hr): }, { "name": "velocity_in_km_per_hr", - "expression": "double velocity[3];velocity[0] = primitiveVars[1] * velocityScale;velocity[1] = primitiveVars[2] * velocityScale;velocity[2] = primitiveVars[3] * velocityScale;velocity_in_km_per_hr[0] = (velocity[0] * 3600.0); velocity_in_km_per_hr[1] = (velocity[1] * 3600.0); velocity_in_km_per_hr[2] = (velocity[2] * 3600.0);", + "expression": "double ___velocity[3];___velocity[0] = primitiveVars[1] * velocityScale;___velocity[1] = primitiveVars[2] * velocityScale;___velocity[2] = primitiveVars[3] * velocityScale;velocity_in_km_per_hr[0] = (___velocity[0] * 3600.0); velocity_in_km_per_hr[1] = (___velocity[1] * 3600.0); velocity_in_km_per_hr[2] = (___velocity[2] * 3600.0);", }, { "name": "velocity_m_per_s",