Skip to content

[FL-729] [FLPY-7] Dimensioned Volume Output #1012

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
Jun 9, 2025
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
3 changes: 2 additions & 1 deletion flow360/component/simulation/outputs/outputs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<UniversalVariablesV2>`,"
" :ref:`variables specific to VolumeOutput<VolumeAndSliceSpecificVariablesV2>`"
" and :class:`UserDefinedField`."
Expand Down
150 changes: 142 additions & 8 deletions flow360/component/simulation/translator/solver_translator.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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


Expand Down
4 changes: 2 additions & 2 deletions flow360/component/simulation/translator/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand All @@ -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():
Expand Down
11 changes: 10 additions & 1 deletion flow360/component/simulation/unit_system.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"""
Expand Down
Loading