diff --git a/doc/changelog.d/5546.added.md b/doc/changelog.d/5546.added.md new file mode 100644 index 000000000000..05a3cd2371c9 --- /dev/null +++ b/doc/changelog.d/5546.added.md @@ -0,0 +1 @@ +Support the use of expression builder objects in settings parameter. diff --git a/src/ansys/fluent/core/offline/expressions/__init__.py b/src/ansys/fluent/core/offline/expressions/__init__.py index dee316fbbf47..b4d7dde10417 100644 --- a/src/ansys/fluent/core/offline/expressions/__init__.py +++ b/src/ansys/fluent/core/offline/expressions/__init__.py @@ -30,7 +30,9 @@ ) solver.setup.named_expressions["dp"] = {} - solver.setup.named_expressions["dp"].definition = str(dp) + # The settings layer accepts the expression object directly, so no manual + # ``str(dp)`` conversion is required (plain strings still work too): + solver.setup.named_expressions["dp"].definition = dp """ from ._ast import ( # noqa: F401 diff --git a/src/ansys/fluent/core/offline/expressions/_ast.py b/src/ansys/fluent/core/offline/expressions/_ast.py index a6ff66abe78c..ffa2d171f335 100644 --- a/src/ansys/fluent/core/offline/expressions/_ast.py +++ b/src/ansys/fluent/core/offline/expressions/_ast.py @@ -22,7 +22,10 @@ from typing import Sequence from ansys.fluent.core._variable_strategies import FluentExprNamingStrategy -from ansys.units.variable_descriptor import VariableDescriptor +from ansys.units.variable_descriptor import ( + VariableDescriptor, + VectorVariableDescriptor, +) from .errors import ExpressionBuildError @@ -63,6 +66,36 @@ def __str__(self) -> str: def __repr__(self) -> str: return f"<{type(self).__name__} {self.__fluent_expr__()!r}>" + # -- settings-layer protocol ------------------------------------------- # + # The generic PyFluent settings layer (``flobject``) consumes expression + # objects through these domain-neutral dunder methods and never + # references anything Fluent-specific. ``__fluent_expr__`` remains the + # package-internal renderer; ``__settings_str__`` simply forwards to it. + def __settings_str__(self) -> str: + """Render this node to the string a settings leaf stores.""" + return self.__fluent_expr__() + + def __settings_kind__(self) -> str | None: + """Return the neutral structural-kind tag (e.g. ``"scalar"``). + + The value mirrors :class:`Kind` (``scalar``, ``vector``, ``boolean``, + ``quantity``, ``location_list``) so the settings layer can reject + structurally incompatible assignments without importing this package. + Returns ``None`` when the kind is unknown. + """ + kind = getattr(self, "kind", None) + return kind.value if isinstance(kind, Kind) else None + + def __settings_dimensions__(self) -> dict[str, float] | None: + """Return physical dimensions as ``{base_dimension_name: exponent}``. + + Dimensions are only known for leaf nodes that carry units or a + :class:`~ansys.units.VariableDescriptor` (see :class:`Quantity` and + :class:`Variable`). Composite nodes return ``None`` so the settings + layer skips the dimensional check for them. + """ + return None + # -- operator overloading (scalar arithmetic) -------------------------- # # These live on the base so that any subclass whose kind is compatible # with arithmetic just works. A kind-check happens inside _binop. @@ -169,6 +202,23 @@ class Quantity(Expr): def __fluent_expr__(self) -> str: return f"{self.value} [{self.units}]" + def __settings_dimensions__(self) -> dict[str, float] | None: + """Derive physical dimensions from the literal units string. + + Uses ``ansys.units`` to turn the units into base-dimension exponents. + Any failure (e.g. an unconfigured unit) yields ``None`` so the settings + layer simply skips the dimensional check rather than blocking the user. + """ + try: + import ansys.units + + dims = ansys.units.Quantity(self.value, self.units).dimensions + return { + getattr(k, "name", str(k)): float(v) for k, v in dict(dims).items() if v + } + except Exception: + return None + @dataclass(frozen=True, eq=False) class Variable(ScalarExpr): @@ -202,6 +252,45 @@ def __post_init__(self): def __fluent_expr__(self) -> str: return self._fluent_name # type: ignore[attr-defined] + def __settings_kind__(self) -> str | None: + """Report the structural kind, distinguishing vector descriptors. + + :class:`Variable` inherits ``kind = Kind.SCALAR`` from + :class:`ScalarExpr` so the builder treats every catalog entry as a + scalar operand. Vector catalog entries (e.g. ``V.VELOCITY``) are backed + by a :class:`VectorVariableDescriptor`, so override the neutral + settings-layer tag to ``"vector"`` for them; this lets the settings + layer reject vector-to-scalar assignments without altering the + builder's ``kind`` behavior. + """ + if isinstance(self.descriptor, VectorVariableDescriptor): + return Kind.VECTOR.value + return super().__settings_kind__() + + def __settings_dimensions__(self) -> dict[str, float] | None: + """Expose the descriptor's physical dimensions, when available. + + ``VariableDescriptor.dimension`` maps :class:`~ansys.units.BaseDimensions` + enum members to exponents (e.g. ``{BaseDimensions.MASS: 1.0, + BaseDimensions.LENGTH: -1.0, BaseDimensions.TIME: -2.0}`` for a + pressure). Keys are normalized to their ``.name`` (``"MASS"``, + ``"LENGTH"``, ``"TIME"``) so they match ``Quantity.__settings_dimensions__`` + and the settings layer's own normalization. Returns ``None`` when the + descriptor carries no usable dimension so the settings layer skips the + dimensional check. + """ + try: + dimension = getattr(self.descriptor, "dimension", None) + if not dimension: + return None + return { + getattr(k, "name", str(k)): float(v) + for k, v in dict(dimension).items() + if v + } + except Exception: + return None + @dataclass(frozen=True, eq=False) class RawName(ScalarExpr): diff --git a/src/ansys/fluent/core/solver/flobject.py b/src/ansys/fluent/core/solver/flobject.py index 4b77d336d601..b12951acb3d1 100644 --- a/src/ansys/fluent/core/solver/flobject.py +++ b/src/ansys/fluent/core/solver/flobject.py @@ -116,61 +116,291 @@ # --------------------------------------------------------------------------- # -# Expression-object support # +# Settings-expression protocol # # --------------------------------------------------------------------------- # # -# Settings leaves whose python-name path (stripped of NamedObject indices) -# ends with one of these tuples will accept "expression objects" -- any -# Python value implementing ``__fluent_expr__() -> str`` -- and convert them -# to their Fluent-side string form before storing. +# Any Python value can behave as an "expression object" at a settings leaf by +# implementing the small, domain-neutral protocol below. The settings layer +# deliberately knows *nothing* about Fluent expressions (or any other concrete +# expression implementation): it only talks to these dunder methods. # -# Today the only entry targets ``setup.named_expressions[].definition``, -# but keeping this as a module-level constant lets future consumers plug in -# additional paths (or migrate to a fully attribute-driven mechanism) without -# touching :class:`Textual`. -EXPRESSION_DEFINITION_PATHS: list[tuple[str, ...]] = [ - ("setup", "named_expressions", "definition"), -] +# __settings_str__(self) -> str +# REQUIRED. Render the object to the string the settings leaf stores. +# Validation (see below) always runs *before* this is called. +# +# __settings_kind__(self) -> str | None +# OPTIONAL. Describe the *shape* of the produced value using one of the +# neutral tags in :data:`_EXPRESSION_KINDS` ("scalar", "vector", +# "boolean", "quantity", "location_list"). Return ``None`` when the kind +# is unknown, in which case the structural check is skipped. +# +# __settings_dimensions__(self) -> dict[str, float] | None +# OPTIONAL. Describe the physical dimensions of the produced value as a +# mapping of base-dimension name (e.g. ``"MASS"``) to exponent. Return +# ``None`` when unknown, in which case the dimensional check is skipped. +# +# This keeps the generic settings layer decoupled from the Fluent expression +# builder: the builder's AST nodes simply implement these methods. +_EXPRESSION_KINDS = ("scalar", "vector", "boolean", "quantity", "location_list") -def _python_name_chain(obj) -> list[str]: - """Return the python-name chain from root to ``obj``, excluding indices. +def _is_settings_expression(state) -> bool: + """Return ``True`` when ``state`` implements the settings-expression protocol. - NamedObject index segments (``"[foo]"``) are dropped so the chain - represents structural position only. + Duck-typed on :func:`__settings_str__` -- a cheap ``getattr`` that + short-circuits for the overwhelmingly common case of plain values. """ - names: list[str] = [] - node = obj - while node is not None: - name = getattr(node, "python_name", None) - if name and not name.startswith("["): - names.append(name) - node = getattr(node, "_parent", None) - names.reverse() - return names - - -def _matches_expression_definition_path(obj) -> bool: - """Return True if ``obj`` sits at one of :data:`EXPRESSION_DEFINITION_PATHS`.""" - chain = _python_name_chain(obj) - for suffix in EXPRESSION_DEFINITION_PATHS: - if len(suffix) <= len(chain) and tuple(chain[-len(suffix) :]) == suffix: - return True - return False + return callable(getattr(state, "__settings_str__", None)) + + +def _normalize_dimensions(dimensions) -> dict[str, float]: + """Normalize a dimensions mapping to ``{name: exponent}``. + + Accepts either an ``ansys.units`` ``Dimensions`` object (whose keys are + ``BaseDimensions`` enum members) or a plain ``{name: exponent}`` dict, so + the settings layer can compare values coming from different sources. Zero + exponents are dropped. + """ + normalized: dict[str, float] = {} + for key, exponent in dict(dimensions).items(): + if not exponent: + continue + # ``BaseDimensions`` enum members expose ``.name``; plain string keys + # are used as-is. + name = getattr(key, "name", key) + normalized[str(name)] = float(exponent) + return normalized + + +def _leaf_dimensions(leaf) -> dict[str, float] | None: + """Best-effort physical dimensions implied by a leaf's units, or ``None``. + + Only numerical leaves (those exposing a ``units()`` method, e.g. + :class:`RealNumerical`) carry unit information. Any failure to resolve the + units into dimensions is treated as "unknown" (returns ``None``) so the + dimensional check never blocks a legitimate assignment. + + A ``None`` units string means the units are unknown (unresolved) and yields + ``None``. An empty units string (``""``) means a *known* dimensionless + setting -- ``get_si_unit_for_fluent_quantity`` returns ``""`` for + dimensionless quantities such as turbulent intensity -- and yields an empty + ``{}`` mapping so that a dimensional expression (e.g. a pressure-valued one) + is correctly rejected rather than silently skipped. + """ + units = getattr(leaf, "units", None) + if not callable(units): + return None + try: + unit_str = units() + if unit_str is None: + return None + return _normalize_dimensions(ansys.units.Quantity(1, unit_str).dimensions) + except Exception: + return None + + +def _leaf_label(leaf) -> str: + """Return a human-readable label for ``leaf`` in error messages. + + ``leaf`` may be a leaf *instance* (whose ``python_path`` is a string) or a + leaf *class* (reached via the shared ``to_scheme_keys`` conversion path, + where ``python_path``/``python_name`` resolve to property descriptors rather + than strings). Falls back to the class name so the message stays readable. + """ + for attr in ("python_path", "python_name"): + value = getattr(leaf, attr, None) + if isinstance(value, str): + return value + return getattr(leaf, "__name__", None) or str(leaf) + + +# Sentinel distinguishing "leaf dimensions were not precomputed" (resolve them +# via an RPC) from a resolved value of ``None`` (units are genuinely unknown). +_LEAF_DIMS_UNRESOLVED = object() + + +def _validate_expression_quantity( + leaf, state, accepted_kinds=None, leaf_dims=_LEAF_DIMS_UNRESOLVED +) -> None: + """Validate an expression object against ``leaf`` *before* it is rendered. + + Performs two independent, best-effort "quantity-type" checks so that an + obviously incompatible expression is rejected in the client settings layer + rather than producing a string that Fluent would later reject: + + 1. **Structural kind** -- compares the expression's ``__settings_kind__`` + against the kinds the leaf accepts (``leaf._expression_kinds``, or + ``accepted_kinds`` when supplied -- e.g. the scalar kinds used for each + element of a real-list leaf). This catches, for example, a vector or + boolean expression assigned to a scalar real leaf. Skipped when either + side does not declare its kind. + 2. **Physical dimension** -- compares the expression's + ``__settings_dimensions__`` against the dimensions implied by the leaf's + units. Only a *confident* mismatch (both sides known and different) + raises; unknown dimensions are tolerated so that composite expressions + (whose dimensions cannot be inferred) are never blocked. + + ``leaf_dims`` lets a caller pass the leaf's already-resolved dimensions so a + bulk assignment (e.g. a :class:`RealList`) resolves the leaf's units once + instead of once per element -- each resolution is an uncached RPC. When + left unset the dimensions are resolved here on demand. + + Raises + ------ + TypeError + If the expression's kind or dimensions are incompatible with ``leaf``. + """ + leaf_label = _leaf_label(leaf) + + # 1. Structural kind check. + if accepted_kinds is None: + accepted_kinds = getattr(leaf, "_expression_kinds", None) + kind_getter = getattr(state, "__settings_kind__", None) + kind = kind_getter() if callable(kind_getter) else None + if accepted_kinds is not None and kind is not None and kind not in accepted_kinds: + raise TypeError( + f"Expression of kind '{kind}' is not valid for '{leaf_label}', " + f"which accepts {', '.join(accepted_kinds)} expressions." + ) + + # 2. Physical dimension check (best-effort, only on confident mismatch). + dims_getter = getattr(state, "__settings_dimensions__", None) + raw_expr_dims = dims_getter() if callable(dims_getter) else None + if raw_expr_dims is None: + return + expr_dims = _normalize_dimensions(raw_expr_dims) + if leaf_dims is _LEAF_DIMS_UNRESOLVED: + leaf_dims = _leaf_dimensions(leaf) + if leaf_dims is None: + # Leaf units are unknown (unresolved) -- do not block. An empty + # ``expr_dims`` ({}) is a *known* dimensionless expression (the protocol + # uses ``None`` for unknown, handled above), so it is still compared + # against the leaf below rather than skipped here. + return + if expr_dims != leaf_dims: + raise TypeError( + f"Expression with dimensions {expr_dims} is not compatible with " + f"'{leaf_label}', which expects dimensions {leaf_dims}." + ) + + +def _render_settings_expression( + leaf, state, accepted_kinds=None, leaf_dims=_LEAF_DIMS_UNRESOLVED +) -> str | None: + """Validate and render ``state`` when it is a settings-expression object. + + Returns the rendered string, or ``None`` when ``state`` is not an + expression object (so callers fall through to their normal handling). The + quantity-type validation always runs *before* the string is produced, + satisfying the "check the quantity type prior to ``str`` conversion" + contract. ``accepted_kinds`` overrides the leaf's declared kinds for the + kind check (used when validating individual elements of a list leaf). + ``leaf_dims`` optionally supplies the leaf's already-resolved dimensions so + repeated element validation does not re-resolve the leaf's units per call. + + Raises + ------ + TypeError + If validation fails, or if the object does not render to a string. + """ + if not _is_settings_expression(state): + return None + _validate_expression_quantity(leaf, state, accepted_kinds, leaf_dims) + rendered = state.__settings_str__() + if not isinstance(rendered, str): + raise TypeError( + f"Expression object {state!r} did not render to a string " + f"(got {type(rendered).__name__})." + ) + return rendered + + +def _render_settings_expression_list(leaf, state, accepted_kinds) -> list | None: + """Render a ``list`` whose elements may be settings-expression objects. + + A list leaf holds a *container* of values, so each element is an independent + expression rather than a single expression describing the whole assignment. + Every expression element is validated (against ``accepted_kinds`` -- the + kinds a single element may take -- and the leaf's units, when it has any) and + rendered to its string form, while plain values are left untouched, + preserving the list structure. ``accepted_kinds`` differs by leaf: a + real-list element is scalar, whereas a string/filename-list element may be + any expression-string kind. + The leaf's dimensions are resolved *once* for the whole assignment and + reused for every element: each resolution is an uncached ``units()`` RPC, so + resolving per element would add one network round trip per element. -def _try_render_expression(state): - """If ``state`` looks like an expression object, return its rendered string. + Returns the rebuilt list, or ``None`` when ``state`` is not a ``list`` or + carries no expression element, so callers fall through to normal handling. + A ``tuple`` is deliberately *not* treated as an element container because a + real leaf interprets a tuple as a ``(value, units)`` quantity. + """ + if not isinstance(state, list): + return None + if not any(_is_settings_expression(element) for element in state): + return None + leaf_dims = _leaf_dimensions(leaf) + return [ + ( + _render_settings_expression(leaf, element, accepted_kinds, leaf_dims) + if _is_settings_expression(element) + else element + ) + for element in state + ] + + +def _textual_list_set_state(self, state=None, **kwargs) -> None: + """``set_state`` for ``Textual`` list leaves (``StringList``/``FilenameList``). + + These leaves subclass ``SettingsBase`` *before* :class:`Textual` in the MRO, + so a bare ``set_state`` would resolve to ``SettingsBase.set_state`` and skip + the settings-expression protocol -- serializing expression objects with + ``str()`` instead of rendering/validating them. Each element of the list + may be an independent expression (e.g. a named-expression definition), so + they are validated against the leaf's per-element accepted kinds and + rendered to their string form before delegating to the inherited setter. + Plain values and non-list states are passed through unchanged. + """ + rendered_list = _render_settings_expression_list( + self, state, getattr(self, "_element_expression_kinds", None) + ) + if rendered_list is not None: + return self.base_set_state(state=rendered_list, **kwargs) + return self.base_set_state(state=state, **kwargs) + + +def _settings_expression_to_scheme(leaf_cls, value): + """Render/validate settings-expression objects in a leaf's scheme value. + + This is the *shared* conversion path used by every state-setting flow -- + direct leaf ``set_state``, :class:`Group` dict updates, and command + arguments -- so an expression object is rendered via ``__settings_str__`` + (not serialized with ``str()``) and kind-checked consistently, regardless of + how it reaches the leaf. + + Only leaves that opt into the protocol act: ``_expression_kinds`` gates + whole-value expressions and ``_element_expression_kinds`` gates per-element + expressions in a list. Plain values (and leaves that do not opt in) are + returned unchanged. - Duck-typed on ``__fluent_expr__``. Returns ``None`` when ``state`` is not - an expression object, so callers can fall through to their existing logic. + ``leaf_cls`` is a leaf *class* here (``to_scheme_keys`` is a classmethod), so + the physical-dimension check -- which needs a live instance's units -- is + skipped; that check still runs on direct leaf assignment where the instance + is available. """ - render = getattr(state, "__fluent_expr__", None) - if callable(render): - rendered = render() - if isinstance(rendered, str): + whole_kinds = getattr(leaf_cls, "_expression_kinds", None) + if whole_kinds is not None: + rendered = _render_settings_expression(leaf_cls, value, whole_kinds) + if rendered is not None: return rendered - return None + element_kinds = getattr(leaf_cls, "_element_expression_kinds", None) + if element_kinds is not None: + rendered_list = _render_settings_expression_list(leaf_cls, value, element_kinds) + if rendered_list is not None: + return rendered_list + return value class InactiveObjectError(RuntimeError): @@ -841,6 +1071,11 @@ class RealNumerical(Numerical): Get the units string. """ + # Real-valued leaves accept expression strings (see ``RealType``); a scalar + # real therefore accepts scalar / value-with-units expression objects. + # Containers (e.g. :class:`RealList`) widen this -- see their override. + _expression_kinds: tuple[str, ...] = ("scalar", "quantity") + def as_quantity(self) -> QuantityT | None: """Get the state of the object as an ansys.units.Quantity.""" error = None @@ -866,7 +1101,11 @@ def set_state(self, state: StateT | None = None, **kwargs: StateType) -> None: ---------- state The type of state can be float, str (representing either - an expression or a value with units), or an ansys.units.Quantity. + an expression or a value with units), an ansys.units.Quantity, + or any object implementing the settings-expression protocol + (``__settings_str__``) -- for example an ``Expr`` produced by the + expression builder. Expression objects are validated against this + leaf's kind and units before being rendered to a string. kwargs : StateType Keyword arguments. @@ -876,7 +1115,29 @@ def set_state(self, state: StateT | None = None, **kwargs: StateType) -> None: If the quantity object cannot be handled for the given path. This can happen if the quantity attribute specifies an unsupported quantity, or if the units specified for the quantity are not supported. + TypeError + If an expression object's kind or dimensions are incompatible with + this leaf. """ + # Expression-object support: an object implementing the + # settings-expression protocol is validated (kind + dimensions) and + # rendered to its string form *before* any quantity handling below. + # Real-valued leaves accept expression strings, so this is where an + # ``Expr`` becomes a valid assignment for them. + rendered = _render_settings_expression(self, state) + if rendered is not None: + return self.base_set_state(state=rendered, **kwargs) + + # List leaves (e.g. ``RealList``) accept a container of reals whose + # elements may themselves be expression objects. Each element is an + # independent scalar, so validate/render them with the per-element kinds + # and this leaf's units while preserving the list structure. + element_kinds = getattr(self, "_element_expression_kinds", None) + if element_kinds is not None: + rendered_list = _render_settings_expression_list(self, state, element_kinds) + if rendered_list is not None: + return self.base_set_state(state=rendered_list, **kwargs) + try: def get_units(): @@ -909,36 +1170,39 @@ def units(self) -> str | None: class Textual(Property): """Exposes attribute accessor on settings object - specific to string objects.""" + # String leaves broadly accept Fluent expression strings (named-expression + # definitions, boundary-condition profiles, and so on). A definition may be + # scalar, vector, boolean, or a value-with-units, so all of those kinds are + # accepted; a bare location list is not a valid string value. + _expression_kinds: tuple[str, ...] = ("scalar", "vector", "boolean", "quantity") + def set_state(self, state: StateT | None = None, **kwargs: StateType) -> None: """Set the state of the object. Parameters ---------- state - Either str, VariableDescriptor, or -- at whitelisted paths - (see :data:`EXPRESSION_DEFINITION_PATHS`) -- any object - implementing ``__fluent_expr__() -> str`` (e.g. an - :class:`~ansys.fluent.core.offline.expressions.Expr` produced by the - expression builder). + Either ``str``, ``VariableDescriptor``, or any object implementing + the settings-expression protocol (``__settings_str__``) -- for + example an ``Expr`` produced by the expression builder. Expression + objects are validated against this leaf's accepted kinds before + being rendered to their string form and stored. kwargs : StateType Keyword arguments. Raises ------ TypeError - If state is not a string. + If state is not a string, or if an expression object's kind is + incompatible with this leaf. """ - # Expression-object support: convert an Expr-like input to its - # Fluent-side string form when this leaf is a recognised - # expression-definition slot. Duck-type check first: it's a cheap - # getattr that short-circuits the parent-walk in the path match for - # the ~all case where ``state`` is a plain value. - if hasattr(state, "__fluent_expr__") and _matches_expression_definition_path( - self - ): - rendered = _try_render_expression(state) - if rendered is not None: - return self.base_set_state(state=rendered, **kwargs) + # Expression-object support: an object implementing the + # settings-expression protocol is validated and rendered to its string + # form before the plain-string handling below. ``VariableDescriptor`` + # does not implement the protocol, so it falls through unchanged. + rendered = _render_settings_expression(self, state) + if rendered is not None: + return self.base_set_state(state=rendered, **kwargs) allowed_types = (str, VariableDescriptor) @@ -1052,14 +1316,54 @@ class SettingsBase(Base, Generic[StateT]): Set the state of the object. """ + def __init_subclass__(cls, **kwargs): + """Enforce correct wiring of the settings-expression protocol. + + A leaf opts into the protocol by declaring ``_expression_kinds``. Because + ``SettingsBase`` precedes the behaviour mixins (:class:`Textual`, + :class:`RealNumerical`) in every leaf's MRO, a bare ``set_state`` resolves + to :meth:`SettingsBase.set_state`, which stores the value directly and + *bypasses* expression rendering/validation -- silently serializing an + expression object with ``str()``. Such a leaf must therefore route + ``set_state`` through an expression-aware setter and expose the core + setter as ``base_set_state``. + + This hook turns that easy-to-forget wiring from a silent runtime bug into + a loud, immediate failure at class-definition (import) time. + """ + super().__init_subclass__(**kwargs) + if getattr(cls, "_expression_kinds", None) is None: + return + if cls.set_state is SettingsBase.set_state: + raise TypeError( + f"{cls.__name__} declares '_expression_kinds' (opting into the " + f"settings-expression protocol) but its 'set_state' resolves to " + f"'SettingsBase.set_state', which bypasses expression handling. " + f"Wire an expression-aware setter in the class body, e.g.\n" + f" base_set_state = SettingsBase[...].set_state\n" + f" set_state = RealNumerical.set_state # or Textual.set_state " + f"/ _textual_list_set_state" + ) + if getattr(cls, "base_set_state", None) is not SettingsBase.set_state: + raise TypeError( + f"{cls.__name__} routes 'set_state' through an expression-aware " + f"setter but does not expose the core setter as " + f"'base_set_state = SettingsBase[...].set_state'. The " + f"expression-aware setter delegates to 'base_set_state', so it " + f"must point at 'SettingsBase.set_state'." + ) + @classmethod def to_scheme_keys(cls, value: StateT, root_cls: type, path: list[str]) -> StateT: """Convert value to have keys with scheme names. This is overridden in the ``Group``, ``NamedObject``, and - ``ListObject`` classes. + ``ListObject`` classes. For a leaf, this is the shared conversion path + reached by direct assignment, :class:`Group` dict updates, and command + arguments alike, so settings-expression objects are rendered/validated + here consistently rather than being serialized with ``str()``. """ - return value + return _settings_expression_to_scheme(cls, value) @classmethod def to_python_keys(cls, value: StateT) -> StateT: @@ -1193,6 +1497,39 @@ class Filename(SettingsBase[str], Textual): _state_type = str + # ``SettingsBase`` precedes ``Textual`` in the MRO, so a bare ``set_state`` + # would resolve to ``SettingsBase.set_state`` and skip the + # settings-expression protocol conversion. Keep delegating to that inherited + # setter, but render/validate an expression object first. + base_set_state: Callable[..., None] = SettingsBase[str].set_state + + def set_state(self, state: StateT | None = None, **kwargs: StateType) -> None: + """Set the state of the object. + + Parameters + ---------- + state + Either ``str`` or any object implementing the settings-expression + protocol (``__settings_str__``) -- for example an ``Expr`` produced + by the expression builder. Expression objects are validated against + this leaf's accepted kinds before being rendered to their string + form and stored. + kwargs : StateType + Keyword arguments. + + Raises + ------ + TypeError + If an expression object's kind is incompatible with this leaf. + """ + # Expression-object support: an object implementing the + # settings-expression protocol is validated and rendered to its string + # form before the inherited setter runs. + rendered = _render_settings_expression(self, state) + if rendered is not None: + return self.base_set_state(state=rendered, **kwargs) + return self.base_set_state(state=state, **kwargs) + def file_purpose(self) -> StateType: """Specifies whether this file is used as input or output by Fluent.""" return self.get_attr(_InlineConstants.file_purpose) @@ -1203,6 +1540,15 @@ class FilenameList(SettingsBase[StringListType], Textual): _state_type = StringListType + # ``SettingsBase`` precedes ``Textual`` in the MRO, so route expression + # elements through the protocol before delegating to the inherited setter. + base_set_state: Callable[..., None] = SettingsBase[StringListType].set_state + set_state: Callable[..., None] = _textual_list_set_state + + # Each element is an independent expression-string, accepting the same kinds + # as a scalar :class:`Textual` leaf. + _element_expression_kinds: tuple[str, ...] = Textual._expression_kinds + def file_purpose(self) -> StateType: """Specifies whether this file is used as input or output by Fluent.""" return self.get_attr(_InlineConstants.file_purpose) @@ -1258,6 +1604,14 @@ class RealList(SettingsBase[RealListType], RealNumerical): base_set_state: Callable[..., None] = SettingsBase[RealListType].set_state set_state: Callable[..., None] = RealNumerical.set_state + # A real-list leaf holds a container of reals, so (in addition to the scalar + # / value-with-units kinds inherited from :class:`RealNumerical`) it also + # accepts vector-valued expressions. + _expression_kinds: tuple[str, ...] = ("scalar", "vector", "quantity") + + # Each element of the container is an independent scalar real. + _element_expression_kinds: tuple[str, ...] = ("scalar", "quantity") + _state_type = RealListType @@ -1282,6 +1636,15 @@ class StringList(SettingsBase[StringListType], Textual): _state_type = StringListType + # ``SettingsBase`` precedes ``Textual`` in the MRO, so route expression + # elements through the protocol before delegating to the inherited setter. + base_set_state: Callable[..., None] = SettingsBase[StringListType].set_state + set_state: Callable[..., None] = _textual_list_set_state + + # Each element is an independent expression-string, accepting the same kinds + # as a scalar :class:`Textual` leaf. + _element_expression_kinds: tuple[str, ...] = Textual._expression_kinds + class BooleanList(SettingsBase[BoolListType], Property): """A ``BooleanList`` object representing a Boolean list setting.""" @@ -2236,6 +2599,15 @@ def _execute_command(self, *args, **kwds): def execute_command(self, *args: StateType, **kwds: StateType) -> StateType: """Execute command.""" kwds = _get_new_keywords(self, *args, **kwds) + # Normalize any settings-expression arguments to their rendered string + # form *before* the file hooks run. ``before_execute`` / ``after_execute`` + # (and ``expand_api_file_argument``) operate on plain strings, so a + # protocol object -- e.g. an expression passed as a Filename argument to + # ``read_case_data`` -- would otherwise raise ``AttributeError`` there. + # Storing the rendered value back into ``kwds`` reuses it for the scheme + # conversion and for ``after_execute`` as well. + for arg in list(kwds): + kwds[arg] = _settings_expression_to_scheme(getattr(self, arg), kwds[arg]) scmKwds = {} for arg, value in kwds.items(): argument = getattr(self, arg) diff --git a/tests/expressions/test_flobject_expression_integration.py b/tests/expressions/test_flobject_expression_integration.py index 1649a66723f0..e6b36159cb75 100644 --- a/tests/expressions/test_flobject_expression_integration.py +++ b/tests/expressions/test_flobject_expression_integration.py @@ -1,26 +1,44 @@ # Copyright (C) 2021 - 2026 Synopsys, Inc. and ANSYS, Inc. All rights reserved. # SPDX-License-Identifier: MIT -"""Tests for Textual.set_state's expression-object integration. - -Verifies -- without a live Fluent session -- that: - -* At a path listed in :data:`EXPRESSION_DEFINITION_PATHS`, a Textual leaf - accepts an object implementing ``__fluent_expr__`` and stores its - rendered string. -* At other paths, expression objects are rejected (falls through to the - existing ``TypeError`` for non-string input). -* Strings and VariableDescriptors continue to work as before. +"""Tests for the generic settings-expression protocol in ``flobject``. + +These tests verify -- without a live Fluent session -- that the settings layer +accepts "expression objects" through a domain-neutral protocol +(``__settings_str__`` / ``__settings_kind__`` / ``__settings_dimensions__``) +rather than any Fluent-specific hook or hard-coded path. Specifically: + +* A :class:`Textual` leaf (``String`` / ``Filename``) accepts an expression + object *anywhere* and stores its rendered string. +* A :class:`RealNumerical` leaf (``Real`` / ``RealList``) accepts an expression + object wherever expression strings are permitted. +* Quantity-type validation (structural kind and physical dimension) runs + *before* the expression is rendered to a string. +* Plain strings and ``VariableDescriptor`` values keep working as before. """ import pytest from ansys.fluent.core.offline.expressions import ExpressionBuilder +from ansys.fluent.core.offline.expressions._ast import Quantity from ansys.fluent.core.solver.flobject import ( - EXPRESSION_DEFINITION_PATHS, + Filename, + FilenameList, + Group, + Numerical, + Real, + RealList, + RealNumerical, + SettingsBase, + String, + StringList, Textual, - _matches_expression_definition_path, - _python_name_chain, + _is_settings_expression, + _normalize_dimensions, + _render_settings_expression, + _render_settings_expression_list, + _settings_expression_to_scheme, + _textual_list_set_state, ) from ansys.units import VariableCatalog as V @@ -29,105 +47,686 @@ # --------------------------------------------------------------------------- # -class _FakeNode: - """Minimal stand-in for a settings-tree node. +class _FakeTextualLeaf: + """Minimal stand-in for a ``String``/``Filename`` settings leaf. - Provides just enough shape (``python_name`` + ``_parent``) for - :func:`_python_name_chain` and :func:`_matches_expression_definition_path` - to work, plus a ``base_set_state`` capture used by - :class:`Textual.set_state`. + Captures whatever ``base_set_state`` receives so tests can assert on the + value the settings layer would have stored. """ _has_migration_adapter = False + _expression_kinds = Textual._expression_kinds + python_path = "setup.named_expressions['x'].definition" + + def __init__(self): + self.captured = None + + def base_set_state(self, state=None, **kwargs): + self.captured = state + return state + + def set_state_via(self, state): + """Route through the real ``Textual.set_state`` for this fake leaf.""" + return Textual.set_state(self, state) + + +class _FakeRealLeaf: + """Minimal stand-in for a ``Real`` settings leaf with a fixed unit. + + ``units()`` mirrors :meth:`RealNumerical.units`, enabling the dimensional + check. ``_expression_kinds`` is inherited from :class:`RealNumerical`. + """ + + _expression_kinds = RealNumerical._expression_kinds + python_path = "setup.boundary_conditions['inlet'].pressure" - def __init__(self, python_name: str, parent=None): - self.python_name = python_name - self._parent = parent + def __init__(self, units="Pa"): + self._units = units self.captured = None + self.units_calls = 0 + + def units(self): + # Count lookups: on a real leaf this is an uncached RPC, so a bulk + # assignment must resolve it once, not once per element. + self.units_calls += 1 + return self._units def base_set_state(self, state=None, **kwargs): self.captured = state return state + def set_state_via(self, state): + """Route through the real ``RealNumerical.set_state`` for this fake leaf.""" + return RealNumerical.set_state(self, state) + + +class _FakeRealListLeaf(_FakeRealLeaf): + """Minimal stand-in for a ``RealList`` leaf (a container of reals). + + Mirrors :class:`RealList`, which widens the accepted kinds to also allow a + single vector-valued expression for the whole-list assignment while sharing + :meth:`RealNumerical.set_state`. + """ + + _expression_kinds = ("scalar", "vector", "quantity") + _element_expression_kinds = ("scalar", "quantity") + python_path = "setup.boundary_conditions['inlet'].flow_direction" + -def _make_leaf(chain: list[str]) -> _FakeNode: - """Build a chain root->leaf and return the leaf.""" - node = None - for name in chain: - node = _FakeNode(name, parent=node) - return node +@pytest.fixture +def builder(): + return ExpressionBuilder() # settings=None -- offline construction -# Use Textual.set_state as an unbound method on a _FakeNode. -def _set_state(node, state): - return Textual.set_state(node, state) +@pytest.fixture +def pressure_expr(builder): + """A scalar, pressure-dimensioned reduction expression.""" + return builder.reductions.area_ave( + expression=V.ABSOLUTE_PRESSURE, locations=["inlet1"] + ) # --------------------------------------------------------------------------- # -# Constant / helpers # +# Protocol detection / helpers # # --------------------------------------------------------------------------- # -def test_default_expression_definition_paths(): - assert ("setup", "named_expressions", "definition") in EXPRESSION_DEFINITION_PATHS +def test_expr_implements_settings_protocol(pressure_expr): + assert _is_settings_expression(pressure_expr) is True + assert pressure_expr.__settings_str__() == "AreaAve(AbsolutePressure,['inlet1'])" + assert pressure_expr.__settings_kind__() == "scalar" + + +def test_plain_values_are_not_settings_expressions(): + assert _is_settings_expression("AbsolutePressure") is False + assert _is_settings_expression(3.14) is False + assert _is_settings_expression(V.ABSOLUTE_PRESSURE) is False -def test_python_name_chain_strips_indices(): - leaf = _make_leaf(["setup", "named_expressions", "[test_expr]", "definition"]) - assert _python_name_chain(leaf) == ["setup", "named_expressions", "definition"] +def test_normalize_dimensions_accepts_name_and_enum_keys(): + import ansys.units + from_quantity = _normalize_dimensions(ansys.units.Quantity(1, "Pa").dimensions) + from_names = _normalize_dimensions({"MASS": 1.0, "LENGTH": -1.0, "TIME": -2.0}) + assert from_quantity == from_names + # Zero exponents are dropped. + assert _normalize_dimensions({"MASS": 0.0, "LENGTH": 1.0}) == {"LENGTH": 1.0} -def test_matches_expression_definition_path_positive(): - leaf = _make_leaf(["setup", "named_expressions", "[dp]", "definition"]) - assert _matches_expression_definition_path(leaf) is True + +# --------------------------------------------------------------------------- # +# Textual leaves accept expression objects anywhere # +# --------------------------------------------------------------------------- # -def test_matches_expression_definition_path_negative(): - leaf = _make_leaf(["setup", "boundary_conditions", "[inlet1]", "name"]) - assert _matches_expression_definition_path(leaf) is False +def test_textual_leaf_accepts_expression_object(pressure_expr): + leaf = _FakeTextualLeaf() + Textual.set_state(leaf, pressure_expr) + assert leaf.captured == "AreaAve(AbsolutePressure,['inlet1'])" + + +def test_textual_leaf_still_accepts_string(): + leaf = _FakeTextualLeaf() + Textual.set_state(leaf, "AreaAve(AbsolutePressure, ['inlet1'])") + assert leaf.captured == "AreaAve(AbsolutePressure, ['inlet1'])" + + +def test_textual_leaf_still_accepts_variable_descriptor(): + leaf = _FakeTextualLeaf() + # A VariableDescriptor does not implement the expression protocol, so it is + # handled by Textual's own field-name strategy: only that a string was + # captured matters here, not its exact spelling. + Textual.set_state(leaf, V.ABSOLUTE_PRESSURE) + assert isinstance(leaf.captured, str) and leaf.captured + + +def test_textual_leaf_rejects_location_list_expression(builder): + leaf = _FakeTextualLeaf() + location_list = builder.locations(["inlet1", "inlet2"]) + with pytest.raises(TypeError, match="kind 'location_list'"): + Textual.set_state(leaf, location_list) # --------------------------------------------------------------------------- # -# Textual.set_state at whitelisted path # +# Filename leaves route through the protocol before their inherited setter # # --------------------------------------------------------------------------- # -@pytest.fixture -def definition_leaf(): - return _make_leaf(["setup", "named_expressions", "[test_expr_1]", "definition"]) +def test_filename_leaf_renders_expression_object(pressure_expr, monkeypatch): + # ``Filename`` resolves ``set_state`` via ``SettingsBase`` in the MRO, so it + # must explicitly route protocol objects through the expression helper + # instead of serializing them with ``str()``. + captured = {} + monkeypatch.setattr( + Filename, + "base_set_state", + lambda self, state=None, **kwargs: captured.__setitem__("state", state), + ) + leaf = Filename(name="x") + leaf.set_state(pressure_expr) + assert captured["state"] == "AreaAve(AbsolutePressure,['inlet1'])" + + +def test_filename_leaf_still_accepts_plain_string(monkeypatch): + captured = {} + monkeypatch.setattr( + Filename, + "base_set_state", + lambda self, state=None, **kwargs: captured.__setitem__("state", state), + ) + leaf = Filename(name="x") + leaf.set_state("input.cas.h5") + assert captured["state"] == "input.cas.h5" + + +def test_filename_leaf_rejects_location_list_expression(builder, monkeypatch): + captured = {} + monkeypatch.setattr( + Filename, + "base_set_state", + lambda self, state=None, **kwargs: captured.__setitem__("state", state), + ) + leaf = Filename(name="x") + location_list = builder.locations(["inlet1", "inlet2"]) + with pytest.raises(TypeError, match="kind 'location_list'"): + leaf.set_state(location_list) + assert captured == {} # never rendered / stored -@pytest.fixture -def expr(): - b = ExpressionBuilder() - return b.reductions.area_ave(expression=V.ABSOLUTE_PRESSURE, locations=["inlet1"]) +# --------------------------------------------------------------------------- # +# Textual list leaves render each expression element via the protocol # +# --------------------------------------------------------------------------- # + +@pytest.mark.parametrize("list_cls", [StringList, FilenameList]) +def test_textual_list_renders_expression_elements(list_cls, pressure_expr, monkeypatch): + # ``StringList`` / ``FilenameList`` resolve ``set_state`` via ``SettingsBase`` + # in the MRO, so (like ``Filename``) they must route each expression element + # through the protocol instead of serializing it with ``str()``. + captured = {} + monkeypatch.setattr( + list_cls, + "base_set_state", + lambda self, state=None, **kwargs: captured.__setitem__("state", state), + ) + leaf = list_cls(name="x") + leaf.set_state(["a.txt", pressure_expr, "b.txt"]) + assert captured["state"] == [ + "a.txt", + "AreaAve(AbsolutePressure,['inlet1'])", + "b.txt", + ] + + +@pytest.mark.parametrize("list_cls", [StringList, FilenameList]) +def test_textual_list_still_accepts_plain_list(list_cls, monkeypatch): + captured = {} + monkeypatch.setattr( + list_cls, + "base_set_state", + lambda self, state=None, **kwargs: captured.__setitem__("state", state), + ) + leaf = list_cls(name="x") + leaf.set_state(["a.txt", "b.txt"]) + assert captured["state"] == ["a.txt", "b.txt"] + + +@pytest.mark.parametrize("list_cls", [StringList, FilenameList]) +def test_textual_list_rejects_location_list_element(list_cls, builder, monkeypatch): + captured = {} + monkeypatch.setattr( + list_cls, + "base_set_state", + lambda self, state=None, **kwargs: captured.__setitem__("state", state), + ) + leaf = list_cls(name="x") + location_list = builder.locations(["inlet1", "inlet2"]) + with pytest.raises(TypeError, match="kind 'location_list'"): + leaf.set_state([location_list]) + assert captured == {} # never rendered / stored -def test_expression_object_rendered_at_whitelisted_path(definition_leaf, expr): - _set_state(definition_leaf, expr) - assert definition_leaf.captured == "AreaAve(AbsolutePressure,['inlet1'])" + +# --------------------------------------------------------------------------- # +# Real leaves accept expression objects (the new capability) # +# --------------------------------------------------------------------------- # -def test_string_still_accepted_at_whitelisted_path(definition_leaf): - _set_state(definition_leaf, "AreaAve(AbsolutePressure, ['inlet1'])") - assert definition_leaf.captured == "AreaAve(AbsolutePressure, ['inlet1'])" +def test_real_leaf_accepts_expression_object(pressure_expr): + leaf = _FakeRealLeaf(units="Pa") + RealNumerical.set_state(leaf, pressure_expr) + assert leaf.captured == "AreaAve(AbsolutePressure,['inlet1'])" -def test_variable_descriptor_still_accepted_at_whitelisted_path(definition_leaf): - _set_state(definition_leaf, V.ABSOLUTE_PRESSURE) - # Textual uses its own field-name naming strategy for VariableDescriptors; - # the exact spelling is not the concern of this test -- only that a string - # was captured (i.e. we did not intercept and mis-handle the descriptor). - assert isinstance(definition_leaf.captured, str) - assert definition_leaf.captured # non-empty +def test_real_leaf_still_accepts_plain_float(): + leaf = _FakeRealLeaf(units="Pa") + RealNumerical.set_state(leaf, 101325.0) + assert leaf.captured == 101325.0 # --------------------------------------------------------------------------- # -# Textual.set_state at non-whitelisted path # +# Quantity-type validation runs before str conversion # # --------------------------------------------------------------------------- # -def test_expression_object_rejected_at_other_path(expr): - leaf = _make_leaf(["setup", "some_group", "[x]", "some_string_field"]) +def test_real_leaf_rejects_dimension_mismatch(): + # A velocity-dimensioned quantity assigned to a pressure (Pa) leaf must be + # rejected *before* it is rendered to a string. + leaf = _FakeRealLeaf(units="Pa") + velocity = Quantity(5.0, "m s^-1") + with pytest.raises(TypeError, match="not compatible"): + RealNumerical.set_state(leaf, velocity) + assert leaf.captured is None # never rendered / stored + + +def test_real_leaf_accepts_matching_dimension(): + leaf = _FakeRealLeaf(units="Pa") + pressure_quantity = Quantity(101325.0, "Pa") + RealNumerical.set_state(leaf, pressure_quantity) + assert leaf.captured == "101325.0 [Pa]" + + +def test_real_leaf_rejects_vector_expression_kind(builder): + # A scalar Real leaf does not accept a vector expression regardless of + # dimensions. + leaf = _FakeRealLeaf(units="m s^-1") + vector = builder.vector.make_vec( + x=builder.literal(1.0), y=builder.literal(2.0), z=builder.literal(3.0) + ) + with pytest.raises(TypeError, match="kind 'vector'"): + RealNumerical.set_state(leaf, vector) + assert leaf.captured is None + + +def test_unknown_dimension_expression_is_not_blocked(pressure_expr): + # A composite reduction reports no dimensions, so the dimensional check is + # skipped and the expression is stored even against a differently-united + # leaf (the kind check still passes: both scalar). + assert pressure_expr.__settings_dimensions__() is None + leaf = _FakeRealLeaf(units="m s^-1") + RealNumerical.set_state(leaf, pressure_expr) + assert leaf.captured == "AreaAve(AbsolutePressure,['inlet1'])" + + +# --------------------------------------------------------------------------- # +# List leaves render / validate each expression element # +# --------------------------------------------------------------------------- # + + +def test_real_list_renders_expression_elements(pressure_expr): + # Each element of the list is rendered through the protocol; plain values + # are left untouched and the list structure is preserved. + leaf = _FakeRealListLeaf(units="Pa") + RealNumerical.set_state(leaf, [1.0, pressure_expr, 3.0]) + assert leaf.captured == [1.0, "AreaAve(AbsolutePressure,['inlet1'])", 3.0] + + +def test_real_list_renders_settings_str_only_element(): + # An object implementing only ``__settings_str__`` is rendered via the + # protocol rather than serialized with ``str()``. + class _StrOnly: + def __settings_str__(self): + return "my_named_expression" + + leaf = _FakeRealListLeaf(units="Pa") + RealNumerical.set_state(leaf, [_StrOnly()]) + assert leaf.captured == ["my_named_expression"] + + +def test_real_list_rejects_vector_element(builder): + # Each element is validated with scalar kinds, so a vector element is + # rejected even though the list leaf itself accepts a whole-list vector. + leaf = _FakeRealListLeaf(units="m s^-1") + vector = builder.vector.make_vec( + x=builder.literal(1.0), y=builder.literal(2.0), z=builder.literal(3.0) + ) + with pytest.raises(TypeError, match="kind 'vector'"): + RealNumerical.set_state(leaf, [vector]) + assert leaf.captured is None + + +def test_real_list_rejects_dimension_mismatch_element(): + # A velocity-dimensioned element assigned to a pressure (Pa) list leaf is + # rejected before any element is rendered. + leaf = _FakeRealListLeaf(units="Pa") + with pytest.raises(TypeError, match="not compatible"): + RealNumerical.set_state(leaf, [Quantity(5.0, "m s^-1")]) + assert leaf.captured is None + + +def test_real_list_still_accepts_plain_list(): + leaf = _FakeRealListLeaf(units="Pa") + RealNumerical.set_state(leaf, [1.0, 2.0, 3.0]) + assert leaf.captured == [1.0, 2.0, 3.0] + + +def test_render_list_helper_returns_none_without_expression_elements(): + leaf = _FakeRealListLeaf(units="Pa") + # No expression element -> fall through to normal handling. + assert _render_settings_expression_list(leaf, [1.0, 2.0], ("scalar",)) is None + # A tuple is a (value, units) quantity, not an element container. + assert _render_settings_expression_list(leaf, (1.0, "Pa"), ("scalar",)) is None + + +def test_real_list_resolves_leaf_units_once_per_assignment(): + # Each units() lookup is an uncached RPC on a real leaf. A list of + # known-dimension expressions must resolve the leaf's units ONCE for the + # whole assignment, not once per element. + leaf = _FakeRealListLeaf(units="Pa") + elements = [Quantity(float(i), "Pa") for i in range(5)] + RealNumerical.set_state(leaf, elements) + assert leaf.captured == [f"{float(i)} [Pa]" for i in range(5)] + assert leaf.units_calls == 1 + + +def test_real_list_mismatch_still_resolves_units_once(): + # A confident dimension mismatch is still raised, and only one units() + # lookup is performed for the assignment. + leaf = _FakeRealListLeaf(units="Pa") + with pytest.raises(TypeError, match="not compatible"): + RealNumerical.set_state(leaf, [Quantity(1.0, "Pa"), Quantity(2.0, "m s^-1")]) + assert leaf.units_calls == 1 + + +# --------------------------------------------------------------------------- # +# _render_settings_expression contract # +# --------------------------------------------------------------------------- # + + +def test_render_helper_returns_none_for_plain_value(): + leaf = _FakeRealLeaf() + assert _render_settings_expression(leaf, 42.0) is None + + +def test_render_helper_validates_before_rendering(): + leaf = _FakeRealLeaf(units="Pa") + velocity = Quantity(5.0, "m s^-1") with pytest.raises(TypeError): - _set_state(leaf, expr) + _render_settings_expression(leaf, velocity) + + +# --------------------------------------------------------------------------- # +# Parse a string, read its quantity type, compare with the target parameter # +# --------------------------------------------------------------------------- # +# +# This mirrors the end-to-end flow a caller cares about: start from an +# expression *string*, parse it back into an expression object, ask that +# object for its quantity (structural) type, and check it against the quantity +# type the target settings parameter accepts -- all before any assignment. + + +@pytest.mark.parametrize( + ("expression_string", "expected_kind"), + [ + ("AreaAve(AbsolutePressure, ['inlet1'])", "scalar"), # reduction -> scalar + ("AbsolutePressure", "scalar"), # bare variable -> scalar + ("5 [m/s]", "quantity"), # value-with-units -> quantity + ("MakeVec(1, 2, 3)", "vector"), # vector constructor -> vector + ("AbsolutePressure > 0", "boolean"), # comparison -> boolean + ], +) +def test_parsed_expression_reports_its_quantity_type( + builder, expression_string, expected_kind +): + # Parse the string back into an expression object via the builder ... + expr = builder.parse(expression_string) + # ... and read the quantity (structural) type of the built object. + assert expr.__settings_kind__() == expected_kind + + +@pytest.mark.parametrize( + ("expression_string", "target_leaf_factory", "compatible"), + [ + # A scalar expression is compatible with a scalar Real parameter. + ("AreaAve(AbsolutePressure, ['inlet1'])", _FakeRealLeaf, True), + # A value-with-units is also accepted by a Real parameter. + ("101325 [Pa]", _FakeRealLeaf, True), + # A vector expression is NOT compatible with a scalar Real parameter. + ("MakeVec(1, 2, 3)", _FakeRealLeaf, False), + # A boolean expression is NOT compatible with a scalar Real parameter. + ("AbsolutePressure > 0", _FakeRealLeaf, False), + # A vector expression IS compatible with a (string) definition + # parameter, which accepts scalar/vector/boolean/quantity. + ("MakeVec(1, 2, 3)", _FakeTextualLeaf, True), + ], +) +def test_parsed_expression_kind_vs_target_parameter_kind( + builder, expression_string, target_leaf_factory, compatible +): + # Build the expression object from its string form. + expr = builder.parse(expression_string) + expr_kind = expr.__settings_kind__() + + # The "quantity type of the target parameter" is the set of kinds it + # accepts, exposed via ``_expression_kinds``. + target = target_leaf_factory() + accepted_kinds = target._expression_kinds + + # Direct comparison of the two quantity types. + assert (expr_kind in accepted_kinds) is compatible + + # And the settings layer enforces exactly this comparison before storing: + if compatible: + target.set_state_via(expr) # stores the rendered string + assert target.captured is not None + else: + with pytest.raises(TypeError, match=f"kind '{expr_kind}'"): + target.set_state_via(expr) + assert target.captured is None + + +# --------------------------------------------------------------------------- # +# SettingsBase enforces correct expression-protocol wiring at import time # +# --------------------------------------------------------------------------- # +# +# Leaves declare ``SettingsBase`` before the behaviour mixin, so a bare +# ``set_state`` resolves to ``SettingsBase.set_state`` and silently bypasses the +# expression protocol. ``SettingsBase.__init_subclass__`` turns that easy-to- +# forget wiring mistake into a loud failure at class-definition time. + + +def test_guard_rejects_leaf_that_forgets_to_wire_set_state(): + # Declares _expression_kinds (opts in) but never wires an expression-aware + # set_state -> set_state resolves to SettingsBase.set_state. + with pytest.raises(TypeError, match="bypasses expression handling"): + + class _ForgotToWire(SettingsBase[str], Textual): + _expression_kinds = ("scalar",) + + +def test_guard_rejects_leaf_that_forgets_base_set_state(): + # Routes set_state through an expression-aware setter but forgets to expose + # the core setter as base_set_state (which that setter delegates to). + with pytest.raises(TypeError, match="base_set_state"): + + class _HalfWired(SettingsBase[str], Textual): + _expression_kinds = ("scalar",) + set_state = Textual.set_state + + +def test_guard_accepts_correctly_wired_leaf(): + # A leaf that wires both set_state and base_set_state passes the guard. + class _CorrectlyWired(SettingsBase[str], Textual): + _expression_kinds = ("scalar",) + base_set_state = SettingsBase[str].set_state + set_state = Textual.set_state + + assert _CorrectlyWired.set_state is Textual.set_state + assert _CorrectlyWired.base_set_state is SettingsBase.set_state + + +def test_guard_accepts_textual_list_setter(): + # The list helper used by StringList / FilenameList is a valid wiring too. + class _CorrectlyWiredList(SettingsBase[list], Textual): + _expression_kinds = ("scalar", "vector") + base_set_state = SettingsBase[list].set_state + set_state = _textual_list_set_state + + assert _CorrectlyWiredList.set_state is _textual_list_set_state + assert _CorrectlyWiredList.base_set_state is SettingsBase.set_state + + +def test_guard_ignores_leaf_without_expression_kinds(): + # A leaf that does not opt into the protocol (no _expression_kinds anywhere + # in its MRO) is unaffected, even though its set_state resolves to + # SettingsBase.set_state. + class _PlainLeaf(SettingsBase[int], Numerical): + pass + + assert getattr(_PlainLeaf, "_expression_kinds", None) is None + assert _PlainLeaf.set_state is SettingsBase.set_state # no error raised + + +@pytest.mark.parametrize( + "leaf_cls", [Real, RealList, String, StringList, Filename, FilenameList] +) +def test_shipped_expression_leaves_are_correctly_wired(leaf_cls): + # Regression guard: every shipped leaf that opts into the protocol must not + # resolve set_state back to the bypassing SettingsBase.set_state. + assert leaf_cls._expression_kinds is not None + assert leaf_cls.set_state is not SettingsBase.set_state + assert leaf_cls.base_set_state is SettingsBase.set_state + + +# --------------------------------------------------------------------------- # +# Group dict updates and command arguments use the shared conversion path # +# --------------------------------------------------------------------------- # +# +# Group dict updates (``named.set_state({'definition': obj})``) and command +# arguments both convert their values through ``to_scheme_keys`` -- NOT through +# the per-leaf ``set_state`` -- so the rendering/validation must live in that +# shared conversion path. ``SettingsBase.set_state`` forwards the result of +# ``to_scheme_keys`` straight to the backend, so asserting on ``to_scheme_keys`` +# faithfully covers both flows. + + +def _group_with_child(child_cls, fluent_name="definition", python_name="definition"): + """Build a minimal ``Group`` subclass exposing one leaf child.""" + + child = type(f"_Child_{fluent_name}", (child_cls,), {"fluent_name": fluent_name}) + grp = type( + "_Grp", + (Group,), + { + "child_names": [python_name], + "_child_classes": {python_name: child}, + "_child_aliases": {}, + }, + ) + return grp + + +def test_group_dict_update_renders_expression_value(pressure_expr): + # A builder expression nested in a group dict is rendered to its string. + grp = _group_with_child(String) + out = grp.to_scheme_keys({"definition": pressure_expr}, grp, []) + assert out == {"definition": "AreaAve(AbsolutePressure,['inlet1'])"} + + +def test_group_dict_update_renders_settings_str_only_value(): + # An object implementing only ``__settings_str__`` is rendered via the + # protocol, not serialized with ``str()``. + class _StrOnly: + def __settings_str__(self): + return "my_named_expression" + + def __repr__(self): + return "" + + grp = _group_with_child(String) + out = grp.to_scheme_keys({"definition": _StrOnly()}, grp, []) + assert out == {"definition": "my_named_expression"} + + +def test_group_dict_update_rejects_incompatible_kind(builder): + # A String 'definition' child does not accept a location-list expression -- + # the kind check runs in the shared path too. + grp = _group_with_child(String) + location_list = builder.locations(["inlet1", "inlet2"]) + with pytest.raises(TypeError, match="kind 'location_list'"): + grp.to_scheme_keys({"definition": location_list}, grp, []) + + +def test_group_dict_update_renders_real_list_elements(pressure_expr): + # Nested real-list elements are rendered element-wise via the shared path. + grp = _group_with_child(RealList, fluent_name="values", python_name="values") + out = grp.to_scheme_keys({"values": [1.0, pressure_expr, 3.0]}, grp, []) + assert out == {"values": [1.0, "AreaAve(AbsolutePressure,['inlet1'])", 3.0]} + + +def test_command_argument_renders_expression_value(pressure_expr): + # Command arguments convert via ``.to_scheme_keys(...)`` -- the + # same shared path -- so an expression argument is rendered, not str()'d. + arg_cls = type("_ExprArg", (String,), {"fluent_name": "expression"}) + assert ( + arg_cls.to_scheme_keys(pressure_expr, arg_cls, []) + == "AreaAve(AbsolutePressure,['inlet1'])" + ) + + +def test_command_argument_rejects_incompatible_kind(builder): + # A Real-valued command argument rejects a vector expression before sending. + arg_cls = type("_RealArg", (Real,), {"fluent_name": "value"}) + vector = builder.vector.make_vec( + x=builder.literal(1.0), y=builder.literal(2.0), z=builder.literal(3.0) + ) + with pytest.raises(TypeError, match="kind 'vector'"): + arg_cls.to_scheme_keys(vector, arg_cls, []) + + +def test_read_case_data_filename_argument_renders_protocol_object(pressure_expr): + # End-to-end command path: a Filename argument reaches ``before_execute`` + # (and ``expand_api_file_argument``) *before* ``to_scheme_keys``. A protocol + # object must be normalized to its rendered string first, otherwise + # ``value.replace(".cas", ".dat")`` raises ``AttributeError`` for + # ``read_case_data``. The rendered value must also flow to ``after_execute``. + from ansys.fluent.core.solver import flobject + + captured = {} + + class _CmdProxy: + def is_interactive_mode(self): + return False + + def has_wildcard(self, name): + return False + + def execute_cmd(self, parent_path, command, **kwds): + captured["command"] = command + captured["kwds"] = kwds + return None + + static_info = { + "type": "group", + "commands": { + "read-case-data": { + "type": "command", + "arguments": { + "file-name": {"type": "file", "file_purpose": "input"}, + }, + }, + }, + } + # Build directly from static info via get_cls so the command tree is used + # regardless of the ``use_runtime_python_classes`` config (``get_root`` + # would otherwise load the real prebuilt settings tree, which has no + # ``read_case_data`` at the root). + root_cls, _ = flobject.get_cls("", static_info, version="252") + root = root_cls() + root.set_flproxy(_CmdProxy()) + # Before the fix this raised AttributeError inside expand_api_file_argument. + root.read_case_data.execute_command(file_name=pressure_expr) + assert captured["command"] == "read-case-data" + assert captured["kwds"] == {"file-name": "AreaAve(AbsolutePressure,['inlet1'])"} + + +def test_shared_path_passes_plain_values_through(): + # Non-expression values and non-opt-in leaves are untouched. + assert _settings_expression_to_scheme(String, "plain string") == "plain string" + assert _settings_expression_to_scheme(RealList, [1.0, 2.0]) == [1.0, 2.0] + + class _ExprOnly: + def __settings_str__(self): + return "x" + + # A leaf that does not opt into the protocol leaves the object untouched. + obj = _ExprOnly() + assert _settings_expression_to_scheme(Numerical, obj) is obj diff --git a/tests/expressions/test_fluent_smoke.py b/tests/expressions/test_fluent_smoke.py index f5d2134321db..b49a8d67905f 100644 --- a/tests/expressions/test_fluent_smoke.py +++ b/tests/expressions/test_fluent_smoke.py @@ -8,6 +8,12 @@ using the Python builder, assigns it to a ``named_expressions`` definition (which round-trips through the parser via the Fluent string form), and evaluates the result. + +It also confirms that the assignment flows through the *generic* +settings-expression protocol in ``flobject`` -- i.e. no hard-coded +``setup.named_expressions.definition`` path special-casing -- by checking that +the target leaf advertises its accepted quantity kinds via +``_expression_kinds`` and that the expression's kind matches before storage. """ from typing import Any @@ -15,6 +21,7 @@ import pytest from ansys.fluent.core.offline.expressions import ExpressionBuilder, parse +from ansys.units import VariableCatalog from ansys.units import VariableCatalog as V @@ -25,7 +32,7 @@ def test_expression_builder_area_ave_on_static_mixer( solver = static_mixer_case_session solver.solution.initialization.hybrid_initialize() - b = ExpressionBuilder(session=solver) + b = ExpressionBuilder(session=solver) # session enables validation # Build "AreaAve(AbsolutePressure, ['inlet1'])" with the Python builder. expr = b.reductions.area_ave(expression=V.ABSOLUTE_PRESSURE, locations=["inlet1"]) @@ -37,7 +44,33 @@ def test_expression_builder_area_ave_on_static_mixer( # Assign as a named expression and evaluate on the live session. named = solver.setup.named_expressions.create() - named.definition = expr # Expr accepted directly by flobject integration. - val = named.get_value() - assert isinstance(val, float) and val != 0.0 + # The target leaf accepts expression objects through the generic protocol + # (no hard-coded path): it advertises the quantity kinds it permits, and the + # expression's own quantity kind is among them, so the type check passes. + assert expr.__settings_kind__() in named.definition._expression_kinds + + named.definition = expr # no str() needed, type checked + value = named.get_value() + + assert isinstance(value, float) and value != 0.0 + + +@pytest.mark.fluent_version(">=25.1") +def test_named_expression_assignment_matches_requested_snippet( + static_mixer_case_session: Any, +): + """End-to-end check mirroring the documented user snippet verbatim.""" + solver = static_mixer_case_session + solver.solution.initialization.hybrid_initialize() + + b = ExpressionBuilder(session=solver) # session enables validation + expr = b.reductions.area_ave( + expression=VariableCatalog.ABSOLUTE_PRESSURE, locations=["inlet1"] + ) # -> AreaAve(AbsolutePressure, ['inlet1']) + + named = solver.setup.named_expressions.create() + named.definition = expr # no str() needed, type checked + value = named.get_value() + + assert isinstance(value, float)