Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 11 additions & 5 deletions flow360/component/simulation/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -787,16 +788,14 @@ def validate_expression(variables: list[dict], expressions: list[str]):
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()
_ = variable.value.evaluate(strict=False)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This means that using solver variables in expressions will not throw an error. We are fine with solver variables as long as their value is not being used.

except (ValueError, KeyError, NameError, UnitParseError) as e:
errors.append({"loc": loc, "msg": str(e)})

Expand All @@ -807,15 +806,22 @@ 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)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we pass validation and evaluate to NaN (perhaps because of a solver variable) we should not throw an error, just not write an evaluated value.

if np.isnan(result):
pass
elif 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)
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)
Expand Down
36 changes: 31 additions & 5 deletions flow360/component/simulation/user_code.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -214,6 +212,28 @@ def update_context(cls, value):
_user_variables.add(value.name)
return value

@pd.model_validator(mode="after")
Copy link
Contributor Author

@andrzej-krupka andrzej-krupka May 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This runs a regular DFS traversal but by keeping track of the path we traversed we can check for cycles. If a cycle is found we throw an error and print the cycle in a readable format, e.g.

x -> y -> z -> x

@classmethod
def check_dependencies(cls, value):
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
def check_dependencies(cls, value):
def check_cyclic_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)
Expand Down Expand Up @@ -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:
Expand All @@ -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]:
Copy link
Contributor Author

@andrzej-krupka andrzej-krupka May 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After numpy interop has been implemented using regular list types is not supported to avoid confusion

if context is None:
context = _global_ctx
expr = expr_to_model(self.expression, context)
Expand All @@ -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)
Expand Down
15 changes: 15 additions & 0 deletions tests/simulation/test_expressions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading