Skip to content

[FL-729] [FLPY-7] Dimensioned 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

Open
wants to merge 3 commits into
base: expressions
Choose a base branch
from
Open
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
5 changes: 4 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 import ValueOrExpression
from flow360.component.simulation.validation.validation_context import (
ALL,
CASE,
Expand Down Expand Up @@ -260,7 +261,9 @@ class VolumeOutput(_AnimationAndFileFormatSettings):
"""

name: Optional[str] = pd.Field("Volume output", description="Name of the `VolumeOutput`.")
output_fields: UniqueItemList[Union[VolumeFieldNames, str]] = pd.Field(
# TODO: Not all SolverVariables can be used here.
# TODO: `Expression` and `SolverVariable` still need business logic for validation (Surface Field/Expression?)
output_fields: UniqueItemList[Union[VolumeFieldNames, str, ValueOrExpression]] = pd.Field(
Copy link
Contributor

@andrzej-krupka andrzej-krupka May 16, 2025

Choose a reason for hiding this comment

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

Since ValueOrExpression is meant to be used as a generic type (e.g. ValueOrExpression[LengthType.Vector] etc..) it might be better to just use Expression here (I think its functionally identical?)... As I understand pure values here (like 4 * u.m / u.s) make no sense here, so the field type might be slightly misleading.

description="List of output variables. Including :ref:`universal output variables<UniversalVariablesV2>`,"
" :ref:`variables specific to VolumeOutput<VolumeAndSliceSpecificVariablesV2>`"
" and :class:`UserDefinedField`."
Expand Down
4 changes: 2 additions & 2 deletions flow360/component/simulation/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -520,7 +520,7 @@ def _translate_simulation_json(
translation_func=None,
):
"""
Get JSON for surface meshing from a given simulaiton JSON.
Get JSON for surface meshing from a given simulation JSON.

"""
translated_dict = None
Expand All @@ -535,7 +535,7 @@ def _translate_simulation_json(
translated_dict = translation_func(input_params, mesh_unit)
except Flow360TranslationError as err:
raise ValueError(str(err)) from err
except Exception as err: # tranlsation itself is not supposed to raise any other exception
except Exception as err: # translation itself is not supposed to raise any other exception
raise ValueError(
f"Unexpected error translating to {target_name} json: " + str(err)
) from err
Expand Down
169 changes: 151 additions & 18 deletions flow360/component/simulation/translator/solver_translator.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,18 @@
translate_setting_and_apply_to_all_entities,
update_dict_recursively,
)
from flow360.component.simulation.unit_system import LengthType
from flow360.component.simulation.unit_system import (
CGS_unit_system,
LengthType,
SI_unit_system,
UnitSystem,
imperial_unit_system,
unit_system_manager,
)
from flow360.component.simulation.user_code import (
ValueOrExpression,
_solver_variable_values,
)
from flow360.component.simulation.utils import (
is_exact_instance,
is_instance_of_type_in_union,
Expand Down Expand Up @@ -218,6 +229,42 @@ def rotation_translator(model: Rotation):
return volume_zone


def _expression_has_single_solver_variable(expression: ValueOrExpression):
"""
Check if the expression has a single solver variable.

This is used to determine if the expression is meant to dump a single native variable with potential requested target unit system. Instead of being a UDF.
"""
return len(expression.solver_variable_names()) == 1


def _get_expression_udf_name(output_field: ValueOrExpression):
if output_field.output_unit_system is None:
output_field.output_unit_system = unit_system_manager.current.name

# Note: 2 Possibilities:
# 1. `ValueOrExpression` Single Solver Variable: Normal output with potential requested target unit system.
# 2. `ValueOrExpression` with UserVariable: UDF written by user.
if _expression_has_single_solver_variable(output_field):
# 1.
field_name = output_field.solver_variable_names()[0]
else:
# 2.
raise NotImplementedError("UDF not supported yet")
# TODO: Better heuristic for output field name?
return field_name.partition("solution.")[2] + f"_{output_field.output_unit_system}"


def _get_output_field_name(output_field: Union[ValueOrExpression, str]):
"""Convert output field to string"""
if isinstance(output_field, str):
return output_field
if isinstance(output_field, ValueOrExpression):
return _get_expression_udf_name(output_field)

raise NotImplementedError()


def translate_output_fields(
output_model: Union[
SurfaceOutput,
Expand All @@ -232,11 +279,15 @@ def translate_output_fields(
],
):
"""Get output fields"""
return {"outputFields": output_model.output_fields.items}
output_fields = []
for output_field in output_model.output_fields.items:
output_fields.append(_get_output_field_name(output_field))

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 @@ -350,11 +401,16 @@ def translate_volume_output(
is_average=volume_output_class is TimeAverageVolumeOutput,
)
# Get outputFields
output_fields = []

for output_field in get_global_setting_from_first_instance(
output_params, volume_output_class, "output_fields"
).items:
output_fields.append(_get_output_field_name(output_field))

volume_output.update(
{
"outputFields": 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 @@ -505,6 +561,71 @@ def translate_acoustic_output(output_params: list):
return None


def get_solver_variable_with_converted_unit(
expression: ValueOrExpression, input_params: SimulationParams
) -> ValueOrExpression:

def _get_dimensionalization_factor(
dimensionality, unit_system: UnitSystem, params: SimulationParams
):
"""
Get the dimensionalization factor for each component.
"""
return params.convert_unit(
unit_system[dimensionality], target_system="flow360_v2"
).value.item()

unit_system_name = (
expression.output_unit_system
if expression.output_unit_system
else unit_system_manager.current.name
)

solver_variable_name = expression.solver_variable_names()[0]
dimensionality = None

assert _expression_has_single_solver_variable(expression)

# Step 0: Figure out the dimensionality of the solver variable.
for variable_name, value in _solver_variable_values.items():
if variable_name == solver_variable_name:
dimensionality = value.units.dimensions
break

if not dimensionality:
raise Flow360TranslationError("Solver variable not found:", solver_variable_name)

# Step 1: Get the target unit for the solver variable.
unit_system = None

if unit_system_name == "SI":
unit_system = SI_unit_system
elif unit_system_name == "Imperial":
unit_system = imperial_unit_system
elif unit_system_name == "CGS":
unit_system = CGS_unit_system
else:
raise NotImplementedError()

# Step 2: Get the dimensionalization factor for each component.
conversion_constant = _get_dimensionalization_factor(dimensionality, unit_system, input_params)
return expression / conversion_constant


def assemble_udf_expression(expression: ValueOrExpression):
solver_variable_name = expression.solver_variable_names()[0]
for variable_name, value in _solver_variable_values.items():
if variable_name == solver_variable_name:
n_dim = value.value.ndim
break
if n_dim == 0:
return f"{_get_expression_udf_name(expression)} = {expression.to_solver_code()};"
assembled_expression = ""
for i_comp in range(len(value.value)):
assembled_expression += f"{_get_expression_udf_name(expression)}[{i_comp}] = ({expression.to_solver_code()})[{i_comp}];"
return assembled_expression


def process_output_fields_for_udf(input_params: SimulationParams):
"""
Process all output fields from different output types and generate additional
Expand All @@ -514,28 +635,39 @@ def process_output_fields_for_udf(input_params: SimulationParams):
input_params: SimulationParams object containing outputs configuration

Returns:
tuple: (all_field_names, generated_udfs) where:
- all_field_names is a set of all output field names
tuple: (all_fields, generated_udfs) where:
- all_fields is a set of all output field names
- generated_udfs is a list of UserDefinedField objects for dimensioned fields
"""

# Collect all output field names from all output types
all_field_names = set()
all_fields = set()

if input_params.outputs:
for output in input_params.outputs:
if hasattr(output, "output_fields") and output.output_fields:
all_field_names.update(output.output_fields.items)
all_fields.update(output.output_fields.items)

if isinstance(input_params.operating_condition, LiquidOperatingCondition):
all_field_names.add("velocity_magnitude")
all_fields.add("velocity_magnitude")

# Generate UDFs for dimensioned fields
generated_udfs = []
for field_name in all_field_names:
udf_expression = generate_predefined_udf(field_name, input_params)
if udf_expression:
generated_udfs.append(UserDefinedField(name=field_name, expression=udf_expression))
for field in all_fields:
if isinstance(field, str):
udf_expression = generate_predefined_udf(field, input_params)
if udf_expression:
generated_udfs.append(UserDefinedField(name=field, expression=udf_expression))
Copy link
Contributor

Choose a reason for hiding this comment

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

This UserDefinedField type looks very similar to what a UserVariable is - do you think we could leverage this or is there some extra business logic that we need to take care of? Maybe we could inherit?


elif isinstance(field, ValueOrExpression):
converted_expression = get_solver_variable_with_converted_unit(field, input_params)
assembled_string = assemble_udf_expression(converted_expression)
generated_udfs.append(
UserDefinedField(
name=_get_expression_udf_name(field),
expression=assembled_string,
)
)

return generated_udfs

Expand Down Expand Up @@ -1286,13 +1418,14 @@ def get_solver_json(
)

##:: Step 4: Get outputs (has to be run after the boundaries are translated)

translated = translate_output(input_params, translated)
with input_params.unit_system:
translated = translate_output(input_params, translated)

##:: Step 5: Get user defined fields and auto-generated fields for dimensioned output
translated["userDefinedFields"] = []
# Add auto-generated UDFs for dimensioned fields
generated_udfs = process_output_fields_for_udf(input_params)
with input_params.unit_system:
generated_udfs = process_output_fields_for_udf(input_params)

# Add user-specified UDFs and auto-generated UDFs for dimensioned fields
for udf in [*input_params.user_defined_fields, *generated_udfs]:
Expand Down
12 changes: 11 additions & 1 deletion flow360/component/simulation/unit_system.py
Original file line number Diff line number Diff line change
Expand Up @@ -1596,7 +1596,17 @@ 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
Loading