From 2f164aeeadde39e239b1c0aaadd3aef29f47d4f5 Mon Sep 17 00:00:00 2001 From: Angran Date: Thu, 9 Oct 2025 16:41:03 +0000 Subject: [PATCH 01/30] Initial forceOutput interface --- .../simulation/outputs/output_fields.py | 27 +++++++++++++ .../component/simulation/outputs/outputs.py | 38 ++++++++++++++++++- 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/flow360/component/simulation/outputs/output_fields.py b/flow360/component/simulation/outputs/output_fields.py index df9924156..d32d723d6 100644 --- a/flow360/component/simulation/outputs/output_fields.py +++ b/flow360/component/simulation/outputs/output_fields.py @@ -209,6 +209,33 @@ "heatTransferCoefficientStaticTemperature", "heatTransferCoefficientTotalTemperature", ] + +ForceOutputCoefficientNames = Literal[ + "CD", + "CL", + "CFx", + "CFy", + "CFz", + "CMx", + "CMy", + "CMz", + "CDPressure", + "CLPressure", + "CFxPressure", + "CFyPressure", + "CFzPressure", + "CMxPressure", + "CMyPressure", + "CMzPressure", + "CDSkinFriction", + "CLSkinFriction", + "CFxSkinFriction", + "CFySkinFriction", + "CFzSkinFriction", + "CMxSkinFriction", + "CMySkinFriction", + "CMzSkinFriction", +] # pylint: disable=no-member _FIELD_UNIT_MAPPING = { # Standard non-dimensioned fields - (unit, unit_system) diff --git a/flow360/component/simulation/outputs/outputs.py b/flow360/component/simulation/outputs/outputs.py index fb15438c4..8619ddbc9 100644 --- a/flow360/component/simulation/outputs/outputs.py +++ b/flow360/component/simulation/outputs/outputs.py @@ -26,6 +26,7 @@ from flow360.component.simulation.outputs.output_fields import ( AllFieldNames, CommonFieldNames, + ForceOutputCoefficientNames, InvalidOutputFieldsForLiquid, SliceFieldNames, SurfaceFieldNames, @@ -666,6 +667,41 @@ def allow_only_simulation_surfaces_or_imported_surfaces(cls, value): return value +class ForceOutput(_OutputBase): + """ + :class:`ForceOutput` class for setting total force output of specific surfaces. + + Example + ------- + + Define :class:`ForceOutput` to output total CL and CD on multiple wing surfaces. + + >>> fl.ForceOutput( + ... name="force_output_wings", + ... entities=[volume_mesh["wing1"], volume_mesh["wing2"]], + ... coefficient=["CL", "CD"] + ... ) + + ==== + """ + + name: str = pd.Field("Force output", description="Name of the force output.") + entities: EntityListAllowingGhost[Surface, GhostSurface, GhostCircularPlane, GhostSphere] = ( + pd.Field( + alias="surfaces", + description="List of boundaries where the force will be calculated.", + ) + ) + coefficient: UniqueItemList[ForceOutputCoefficientNames] = pd.Field( + description="List of force coefficients. Including CL, CD, CFx, CFy, CFz, CMx, CMy, CMz, " + "and their SkinFriction/Pressure, such as CLSkinFriction and CLPressure." + ) + moving_statistic: Optional[MovingStatistic] = pd.Field( + None, description="The moving statistics used to monitor the output." + ) + output_type: Literal["ForceOutput"] = pd.Field("ForceOutput", frozen=True) + + class ProbeOutput(_OutputBase): """ :class:`ProbeOutput` class for setting output data probed at monitor points. @@ -1339,6 +1375,6 @@ class ForceDistributionOutput(Flow360BaseModel): ) MonitorOutputType = Annotated[ - Union[SurfaceIntegralOutput, ProbeOutput, SurfaceProbeOutput], + Union[ForceOutput, SurfaceIntegralOutput, ProbeOutput, SurfaceProbeOutput], pd.Field(discriminator="output_type"), ] From 8c60547fd2527f6bfeb98691b93d839cf188d3e3 Mon Sep 17 00:00:00 2001 From: Angran Date: Mon, 13 Oct 2025 18:36:10 +0000 Subject: [PATCH 02/30] Update description of coefficients --- flow360/component/simulation/outputs/outputs.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/flow360/component/simulation/outputs/outputs.py b/flow360/component/simulation/outputs/outputs.py index 8619ddbc9..3d70ed2e5 100644 --- a/flow360/component/simulation/outputs/outputs.py +++ b/flow360/component/simulation/outputs/outputs.py @@ -692,9 +692,9 @@ class ForceOutput(_OutputBase): description="List of boundaries where the force will be calculated.", ) ) - coefficient: UniqueItemList[ForceOutputCoefficientNames] = pd.Field( - description="List of force coefficients. Including CL, CD, CFx, CFy, CFz, CMx, CMy, CMz, " - "and their SkinFriction/Pressure, such as CLSkinFriction and CLPressure." + coefficients: UniqueItemList[ForceOutputCoefficientNames] = pd.Field( + description="List of force coefficients. Including CL, CD, CFx, CFy, CFz, CMx, CMy, CMz. " + "For surface forces, their SkinFriction/Pressure is also supported, such as CLSkinFriction and CLPressure." ) moving_statistic: Optional[MovingStatistic] = pd.Field( None, description="The moving statistics used to monitor the output." From 5574720653fcc02ff7988fcd34ad592afccb08e2 Mon Sep 17 00:00:00 2001 From: Angran Date: Wed, 15 Oct 2025 16:42:32 +0000 Subject: [PATCH 03/30] include forceOutput to init --- flow360/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/flow360/__init__.py b/flow360/__init__.py index 18a7b1207..a1a65ff15 100644 --- a/flow360/__init__.py +++ b/flow360/__init__.py @@ -128,6 +128,7 @@ AeroAcousticOutput, ForceDistributionOutput, IsosurfaceOutput, + ForceOutput, MovingStatistic, Observer, ProbeOutput, @@ -337,6 +338,7 @@ "ImportedSurface", "OctreeSpacing", "RunControl", + "ForceOutput", ] _warn_prerelease() From dcec0ec7d41282b2ad6a0a53753502fa6d50b77b Mon Sep 17 00:00:00 2001 From: Angran Date: Fri, 17 Oct 2025 15:01:30 +0000 Subject: [PATCH 04/30] Update the translator logic to support computation of BET/AD/PM coefficients --- flow360/__init__.py | 2 +- .../component/simulation/outputs/outputs.py | 5 ++- .../translator/solver_translator.py | 38 +++++++++---------- .../translator/ref/Flow360_actuator_disk.json | 3 +- .../Flow360_custom_volume_translation.json | 3 +- .../ref/Flow360_porous_media_box.json | 3 +- .../ref/Flow360_porous_media_volume_zone.json | 3 +- ...Flow360_xv15_bet_disk_nested_rotation.json | 7 ++-- ...Flow360_xv15_bet_disk_steady_airplane.json | 7 ++-- .../Flow360_xv15_bet_disk_steady_hover.json | 7 ++-- .../Flow360_xv15_bet_disk_unsteady_hover.json | 7 ++-- ...w360_xv15_bet_disk_unsteady_hover_UDD.json | 7 ++-- 12 files changed, 49 insertions(+), 43 deletions(-) diff --git a/flow360/__init__.py b/flow360/__init__.py index a1a65ff15..13801a2ac 100644 --- a/flow360/__init__.py +++ b/flow360/__init__.py @@ -127,8 +127,8 @@ from flow360.component.simulation.outputs.outputs import ( AeroAcousticOutput, ForceDistributionOutput, - IsosurfaceOutput, ForceOutput, + IsosurfaceOutput, MovingStatistic, Observer, ProbeOutput, diff --git a/flow360/component/simulation/outputs/outputs.py b/flow360/component/simulation/outputs/outputs.py index 3d70ed2e5..208c7279b 100644 --- a/flow360/component/simulation/outputs/outputs.py +++ b/flow360/component/simulation/outputs/outputs.py @@ -679,7 +679,7 @@ class ForceOutput(_OutputBase): >>> fl.ForceOutput( ... name="force_output_wings", ... entities=[volume_mesh["wing1"], volume_mesh["wing2"]], - ... coefficient=["CL", "CD"] + ... output_fields=["CL", "CD"] ... ) ==== @@ -692,7 +692,7 @@ class ForceOutput(_OutputBase): description="List of boundaries where the force will be calculated.", ) ) - coefficients: UniqueItemList[ForceOutputCoefficientNames] = pd.Field( + output_fields: UniqueItemList[ForceOutputCoefficientNames] = pd.Field( description="List of force coefficients. Including CL, CD, CFx, CFy, CFz, CMx, CMy, CMz. " "For surface forces, their SkinFriction/Pressure is also supported, such as CLSkinFriction and CLPressure." ) @@ -1360,6 +1360,7 @@ class ForceDistributionOutput(Flow360BaseModel): StreamlineOutput, TimeAverageStreamlineOutput, ForceDistributionOutput, + ForceOutput, ], pd.Field(discriminator="output_type"), ] diff --git a/flow360/component/simulation/translator/solver_translator.py b/flow360/component/simulation/translator/solver_translator.py index ed7c640f5..d00056e59 100644 --- a/flow360/component/simulation/translator/solver_translator.py +++ b/flow360/component/simulation/translator/solver_translator.py @@ -63,6 +63,7 @@ from flow360.component.simulation.outputs.outputs import ( AeroAcousticOutput, ForceDistributionOutput, + ForceOutput, Isosurface, IsosurfaceOutput, MonitorOutputType, @@ -1461,26 +1462,24 @@ def update_controls_modeling_constants(controls, translated): control["modelConstants"] = control.pop("modelingConstants") -def check_moving_statistic_existence(params: SimulationParams): - """Check if moving statistic exists in the monitor outputs""" - if not params.outputs: - return False - for output in params.outputs: - if not isinstance(output, get_args(get_args(MonitorOutputType)[0])): - continue - if output.moving_statistic is None: - continue +def check_external_postprocessing_existence(params: SimulationParams): + """Check if external postprocessing needed.""" + if params.models: + for model in params.models: + if isinstance(model, (BETDisk, ActuatorDisk, PorousMedium)): + return True + if params.outputs: + for output in params.outputs: + if not isinstance(output, get_args(get_args(MonitorOutputType)[0])): + continue + if not isinstance(output, ForceOutput) and output.moving_statistic is None: + continue + return True + if params.run_control and bool(params.run_control.stopping_criteria): return True return False -def check_stopping_criterion_existence(params: SimulationParams): - """Check if stopping criterion exists in the Fluid model""" - if not params.run_control: - return False - return bool(params.run_control.stopping_criteria) - - def calculate_monitor_semaphore_hash(params: SimulationParams): """Get the hash for monitor processor's semaphore""" json_string_list = [] @@ -1881,12 +1880,9 @@ def get_solver_json( ##:: Step 11: Get run control settings translated["runControl"] = {} - translated["runControl"]["shouldCheckStopCriterion"] = check_stopping_criterion_existence( - input_params + translated["runControl"]["externalProcessMonitorOutput"] = ( + check_external_postprocessing_existence(input_params) ) - translated["runControl"]["externalProcessMonitorOutput"] = check_moving_statistic_existence( - input_params - ) or check_stopping_criterion_existence(input_params) if translated["runControl"]["externalProcessMonitorOutput"]: translated["runControl"]["monitorProcessorHash"] = calculate_monitor_semaphore_hash( input_params diff --git a/tests/simulation/translator/ref/Flow360_actuator_disk.json b/tests/simulation/translator/ref/Flow360_actuator_disk.json index 8b56b3aa5..066535e5b 100644 --- a/tests/simulation/translator/ref/Flow360_actuator_disk.json +++ b/tests/simulation/translator/ref/Flow360_actuator_disk.json @@ -126,6 +126,7 @@ }, "runControl": { "shouldCheckStopCriterion": false, - "externalProcessMonitorOutput": false + "externalProcessMonitorOutput": true, + "monitorProcessorHash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" } } diff --git a/tests/simulation/translator/ref/Flow360_custom_volume_translation.json b/tests/simulation/translator/ref/Flow360_custom_volume_translation.json index c76c581e3..af6f6c9fa 100644 --- a/tests/simulation/translator/ref/Flow360_custom_volume_translation.json +++ b/tests/simulation/translator/ref/Flow360_custom_volume_translation.json @@ -117,6 +117,7 @@ }, "runControl": { "shouldCheckStopCriterion": false, - "externalProcessMonitorOutput": false + "externalProcessMonitorOutput": true, + "monitorProcessorHash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" } } \ No newline at end of file diff --git a/tests/simulation/translator/ref/Flow360_porous_media_box.json b/tests/simulation/translator/ref/Flow360_porous_media_box.json index 978a57e4a..2e02e24d9 100644 --- a/tests/simulation/translator/ref/Flow360_porous_media_box.json +++ b/tests/simulation/translator/ref/Flow360_porous_media_box.json @@ -201,6 +201,7 @@ }, "runControl": { "shouldCheckStopCriterion": false, - "externalProcessMonitorOutput": false + "externalProcessMonitorOutput": true, + "monitorProcessorHash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" } } diff --git a/tests/simulation/translator/ref/Flow360_porous_media_volume_zone.json b/tests/simulation/translator/ref/Flow360_porous_media_volume_zone.json index b724f302e..3f7243c3f 100644 --- a/tests/simulation/translator/ref/Flow360_porous_media_volume_zone.json +++ b/tests/simulation/translator/ref/Flow360_porous_media_volume_zone.json @@ -192,6 +192,7 @@ }, "runControl": { "shouldCheckStopCriterion": false, - "externalProcessMonitorOutput": false + "externalProcessMonitorOutput": true, + "monitorProcessorHash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" } } diff --git a/tests/simulation/translator/ref/Flow360_xv15_bet_disk_nested_rotation.json b/tests/simulation/translator/ref/Flow360_xv15_bet_disk_nested_rotation.json index c09342e08..29b85e50d 100644 --- a/tests/simulation/translator/ref/Flow360_xv15_bet_disk_nested_rotation.json +++ b/tests/simulation/translator/ref/Flow360_xv15_bet_disk_nested_rotation.json @@ -532,13 +532,14 @@ "v": "v", "w": "w" }, - "userDefinedFields":[], + "userDefinedFields": [], "usingLiquidAsMaterial": false, "outputRescale": { "velocityScale": 1.0 }, "runControl": { "shouldCheckStopCriterion": false, - "externalProcessMonitorOutput": false + "externalProcessMonitorOutput": true, + "monitorProcessorHash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" } -} +} \ No newline at end of file diff --git a/tests/simulation/translator/ref/Flow360_xv15_bet_disk_steady_airplane.json b/tests/simulation/translator/ref/Flow360_xv15_bet_disk_steady_airplane.json index 65e02678e..f403b2de2 100644 --- a/tests/simulation/translator/ref/Flow360_xv15_bet_disk_steady_airplane.json +++ b/tests/simulation/translator/ref/Flow360_xv15_bet_disk_steady_airplane.json @@ -490,13 +490,14 @@ "v": "v", "w": "w" }, - "userDefinedFields":[], + "userDefinedFields": [], "usingLiquidAsMaterial": false, "outputRescale": { "velocityScale": 1.0 }, "runControl": { "shouldCheckStopCriterion": false, - "externalProcessMonitorOutput": false + "externalProcessMonitorOutput": true, + "monitorProcessorHash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" } -} +} \ No newline at end of file diff --git a/tests/simulation/translator/ref/Flow360_xv15_bet_disk_steady_hover.json b/tests/simulation/translator/ref/Flow360_xv15_bet_disk_steady_hover.json index ba9b45c2d..3abfa78cf 100644 --- a/tests/simulation/translator/ref/Flow360_xv15_bet_disk_steady_hover.json +++ b/tests/simulation/translator/ref/Flow360_xv15_bet_disk_steady_hover.json @@ -490,13 +490,14 @@ "v": "v", "w": "w" }, - "userDefinedFields":[], + "userDefinedFields": [], "usingLiquidAsMaterial": false, "outputRescale": { "velocityScale": 1.0 }, "runControl": { "shouldCheckStopCriterion": false, - "externalProcessMonitorOutput": false + "externalProcessMonitorOutput": true, + "monitorProcessorHash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" } -} +} \ No newline at end of file diff --git a/tests/simulation/translator/ref/Flow360_xv15_bet_disk_unsteady_hover.json b/tests/simulation/translator/ref/Flow360_xv15_bet_disk_unsteady_hover.json index d4e7b234c..34650ef08 100644 --- a/tests/simulation/translator/ref/Flow360_xv15_bet_disk_unsteady_hover.json +++ b/tests/simulation/translator/ref/Flow360_xv15_bet_disk_unsteady_hover.json @@ -495,13 +495,14 @@ "v": "v", "w": "w" }, - "userDefinedFields":[], + "userDefinedFields": [], "usingLiquidAsMaterial": false, "outputRescale": { "velocityScale": 1.0 }, "runControl": { "shouldCheckStopCriterion": false, - "externalProcessMonitorOutput": false + "externalProcessMonitorOutput": true, + "monitorProcessorHash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" } -} +} \ No newline at end of file diff --git a/tests/simulation/translator/ref/Flow360_xv15_bet_disk_unsteady_hover_UDD.json b/tests/simulation/translator/ref/Flow360_xv15_bet_disk_unsteady_hover_UDD.json index 8ce4dd741..4f225f27a 100644 --- a/tests/simulation/translator/ref/Flow360_xv15_bet_disk_unsteady_hover_UDD.json +++ b/tests/simulation/translator/ref/Flow360_xv15_bet_disk_unsteady_hover_UDD.json @@ -526,13 +526,14 @@ "v": "v", "w": "w" }, - "userDefinedFields":[], + "userDefinedFields": [], "usingLiquidAsMaterial": false, "outputRescale": { "velocityScale": 1.0 }, "runControl": { "shouldCheckStopCriterion": false, - "externalProcessMonitorOutput": false + "externalProcessMonitorOutput": true, + "monitorProcessorHash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" } -} +} \ No newline at end of file From b59c5ff1c7c7f1d7b27eabc30f79af06df2b159c Mon Sep 17 00:00:00 2001 From: Angran Date: Mon, 20 Oct 2025 20:54:23 +0000 Subject: [PATCH 05/30] update remote file name for porous medium coefficients --- flow360/component/results/case_results.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flow360/component/results/case_results.py b/flow360/component/results/case_results.py index d344a3571..7bc67708e 100644 --- a/flow360/component/results/case_results.py +++ b/flow360/component/results/case_results.py @@ -984,7 +984,7 @@ def _iterate_step_values(zone_name, _, env, values): class PorousMediumCoefficientsCSVModel(ResultCSVModel): """CSV model for porous medium coefficients output.""" - remote_file_name: str = pd.Field("porous_medium_coefficients_v2.csv", frozen=True) + remote_file_name: str = pd.Field("porous_media_coefficients_v2.csv", frozen=True) class BETForcesRadialDistributionResultCSVModel(OptionallyDownloadableResultCSVModel): From e30f71af60373d7debda60831ec86d403285f736 Mon Sep 17 00:00:00 2001 From: Angran Date: Mon, 20 Oct 2025 20:55:23 +0000 Subject: [PATCH 06/30] Use surface models to define ForceOutput and add corresponding validation --- .../component/simulation/outputs/outputs.py | 45 ++++++++++++++++--- .../validation/validation_context.py | 12 +++++ 2 files changed, 51 insertions(+), 6 deletions(-) diff --git a/flow360/component/simulation/outputs/outputs.py b/flow360/component/simulation/outputs/outputs.py index 208c7279b..31320ad95 100644 --- a/flow360/component/simulation/outputs/outputs.py +++ b/flow360/component/simulation/outputs/outputs.py @@ -15,7 +15,10 @@ from flow360.component.simulation.framework.entity_base import EntityList, generate_uuid from flow360.component.simulation.framework.expressions import StringExpression from flow360.component.simulation.framework.unique_list import UniqueItemList -from flow360.component.simulation.models.surface_models import EntityListAllowingGhost +from flow360.component.simulation.models.surface_models import ( + EntityListAllowingGhost, + Wall, +) from flow360.component.simulation.outputs.output_entities import ( Isosurface, Point, @@ -686,11 +689,8 @@ class ForceOutput(_OutputBase): """ name: str = pd.Field("Force output", description="Name of the force output.") - entities: EntityListAllowingGhost[Surface, GhostSurface, GhostCircularPlane, GhostSphere] = ( - pd.Field( - alias="surfaces", - description="List of boundaries where the force will be calculated.", - ) + surface_models: List[Union[Wall, str]] = pd.Field( + description="List of surface models whose force contribution will be calculated.", ) output_fields: UniqueItemList[ForceOutputCoefficientNames] = pd.Field( description="List of force coefficients. Including CL, CD, CFx, CFy, CFz, CMx, CMy, CMz. " @@ -701,6 +701,39 @@ class ForceOutput(_OutputBase): ) output_type: Literal["ForceOutput"] = pd.Field("ForceOutput", frozen=True) + @pd.field_serializer("surface_models") + def serialize_models(self, v): + """Serialize only the model's id of the related object.""" + model_ids = [] + for model in v: + if isinstance(model, Wall): + model_ids.append(model.private_attribute_id) + continue + model_ids.append(model) + return model_ids + + @pd.field_validator("surface_models", mode="before") + @classmethod + def _preprocess_models_with_id(cls, v): + def preprocess_single_model(model, validation_info): + if not isinstance(model, str): + return model + if ( + validation_info is None + or validation_info.physics_model_dict is None + or validation_info.physics_model_dict.get(v) is None + ): + raise ValueError("The model does not exist in the models list.") + surface_model_dict = validation_info.physics_model_dict[v] + model = Wall.model_validate(surface_model_dict) + return model + + processed_models = [] + validation_info = get_validation_info() + for model in v: + processed_models.append(preprocess_single_model(model, validation_info)) + return processed_models + class ProbeOutput(_OutputBase): """ diff --git a/flow360/component/simulation/validation/validation_context.py b/flow360/component/simulation/validation/validation_context.py index 0ad2b55ad..e9cde6d0e 100644 --- a/flow360/component/simulation/validation/validation_context.py +++ b/flow360/component/simulation/validation/validation_context.py @@ -135,6 +135,7 @@ class ParamsValidationInfo: # pylint:disable=too-few-public-methods,too-many-in "global_bounding_box", "planar_face_tolerance", "output_dict", + "physics_model_dict", "half_model_symmetry_plane_center_y", "quasi_3d_symmetry_planes_center_y", "at_least_one_body_transformed", @@ -315,6 +316,16 @@ def _get_output_dict(cls, param_as_dict: dict): if output.get("private_attribute_id") is not None } + @classmethod + def _get_physics_model_dict(cls, param_as_dict: dict): + if param_as_dict.get("models") is None: + return None + return { + model["private_attribute_id"]: model + for model in param_as_dict["models"] + if model.get("private_attribute_id") is not None + } + @classmethod def _get_half_model_symmetry_plane_center_y(cls, param_as_dict: dict): ghost_entities = get_value_with_path( @@ -436,6 +447,7 @@ def __init__(self, param_as_dict: dict, referenced_expressions: list): self.global_bounding_box = self._get_global_bounding_box(param_as_dict=param_as_dict) self.planar_face_tolerance = self._get_planar_face_tolerance(param_as_dict=param_as_dict) self.output_dict = self._get_output_dict(param_as_dict=param_as_dict) + self.physics_model_dict = self._get_physics_model_dict(param_as_dict=param_as_dict) self.half_model_symmetry_plane_center_y = self._get_half_model_symmetry_plane_center_y( param_as_dict=param_as_dict ) From e62153c06ea0aed0f408774e8feb16a10dccecee Mon Sep 17 00:00:00 2001 From: Angran Date: Tue, 21 Oct 2025 17:06:41 +0000 Subject: [PATCH 07/30] unify remote file names for coefficients csv --- flow360/component/results/case_results.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/flow360/component/results/case_results.py b/flow360/component/results/case_results.py index 7bc67708e..c972ab784 100644 --- a/flow360/component/results/case_results.py +++ b/flow360/component/results/case_results.py @@ -705,7 +705,7 @@ def _iterate_step_values(disk_name, disk_ctx, env, values): class ActuatorDiskCoefficientsCSVModel(ResultCSVModel): """CSV model for actuator disk coefficients output.""" - remote_file_name: str = pd.Field("actuator_disk_coefficients_v2.csv", frozen=True) + remote_file_name: str = pd.Field("actuatorDisk_force_coefficients_v2.csv", frozen=True) class _BETDiskResults(_DimensionedCSVResultModel): @@ -900,7 +900,7 @@ def _iterate_step_values(disk_name, disk_ctx, env, values): class BETDiskCoefficientsCSVModel(ResultCSVModel): """CSV model for BET disk coefficients output.""" - remote_file_name: str = pd.Field("bet_disk_coefficients_v2.csv", frozen=True) + remote_file_name: str = pd.Field("bet_force_coefficients_v2.csv", frozen=True) def format_headers( self, params: SimulationParams, pattern: str = "$BETName_$CylinderName" @@ -984,7 +984,7 @@ def _iterate_step_values(zone_name, _, env, values): class PorousMediumCoefficientsCSVModel(ResultCSVModel): """CSV model for porous medium coefficients output.""" - remote_file_name: str = pd.Field("porous_media_coefficients_v2.csv", frozen=True) + remote_file_name: str = pd.Field("porous_media_force_coefficients_v2.csv", frozen=True) class BETForcesRadialDistributionResultCSVModel(OptionallyDownloadableResultCSVModel): From dc9f6830081b2cd0e9a7e498f362884b10746fac Mon Sep 17 00:00:00 2001 From: Angran Date: Tue, 21 Oct 2025 19:05:55 +0000 Subject: [PATCH 08/30] Fix the bug when preprocess surface models with id --- flow360/component/simulation/outputs/outputs.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/flow360/component/simulation/outputs/outputs.py b/flow360/component/simulation/outputs/outputs.py index 31320ad95..e312e863d 100644 --- a/flow360/component/simulation/outputs/outputs.py +++ b/flow360/component/simulation/outputs/outputs.py @@ -715,16 +715,18 @@ def serialize_models(self, v): @pd.field_validator("surface_models", mode="before") @classmethod def _preprocess_models_with_id(cls, v): + """Deserialize string-format surface models as Wall objects.""" + def preprocess_single_model(model, validation_info): if not isinstance(model, str): return model if ( validation_info is None or validation_info.physics_model_dict is None - or validation_info.physics_model_dict.get(v) is None + or validation_info.physics_model_dict.get(model) is None ): raise ValueError("The model does not exist in the models list.") - surface_model_dict = validation_info.physics_model_dict[v] + surface_model_dict = validation_info.physics_model_dict[model] model = Wall.model_validate(surface_model_dict) return model From 9987f397b71b95c5575bd6ebef6df7e5c941a308 Mon Sep 17 00:00:00 2001 From: Angran Date: Wed, 22 Oct 2025 14:33:53 +0000 Subject: [PATCH 09/30] keep the same pseudo_step and physical_step column order in the output coefficient csv file --- flow360/component/results/results_utils.py | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/flow360/component/results/results_utils.py b/flow360/component/results/results_utils.py index bcbff705c..cdbc36125 100644 --- a/flow360/component/results/results_utils.py +++ b/flow360/component/results/results_utils.py @@ -189,8 +189,10 @@ def _build_coeff_env(params) -> Dict[str, Any]: def _copy_time_columns(src: Dict[str, list]) -> Dict[str, list]: out: Dict[str, list] = {} - out[_PSEUDO_STEP] = src[_PSEUDO_STEP] - out[_PHYSICAL_STEP] = src[_PHYSICAL_STEP] + for key in src.keys(): + if key in [_PSEUDO_STEP, _PHYSICAL_STEP]: + out[key] = src[key] + continue return out @@ -338,13 +340,6 @@ class PorousMediumCoefficientsComputation: # pylint:disable=too-few-public-methods """Static utilities for porous medium coefficient computations.""" - @staticmethod - def _copy_time_columns(src: Dict[str, list]) -> Dict[str, list]: - out: Dict[str, list] = {} - out[_PSEUDO_STEP] = src[_PSEUDO_STEP] - out[_PHYSICAL_STEP] = src[_PHYSICAL_STEP] - return out - @staticmethod def _iter_zones(values: Dict[str, list]): # Support both default "zone__<...>" and arbitrary GenericVolume-style names From 75d5c845f991fae85c2151be1d7e65b6765f86a4 Mon Sep 17 00:00:00 2001 From: Angran Date: Thu, 23 Oct 2025 18:41:25 +0000 Subject: [PATCH 10/30] translate stop criterion setting to solver json and perform checking in solver --- .../translator/solver_translator.py | 83 +++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/flow360/component/simulation/translator/solver_translator.py b/flow360/component/simulation/translator/solver_translator.py index d00056e59..0ecac1bd8 100644 --- a/flow360/component/simulation/translator/solver_translator.py +++ b/flow360/component/simulation/translator/solver_translator.py @@ -5,6 +5,7 @@ import json from typing import Type, Union, get_args +import numpy as np import unyt as u from flow360.component.simulation.conversion import ( @@ -46,6 +47,7 @@ PorousMedium, Rotation, Solid, + StopCriterion, ) from flow360.component.simulation.operating_condition.operating_condition import ( LiquidOperatingCondition, @@ -1499,6 +1501,81 @@ def calculate_monitor_semaphore_hash(params: SimulationParams): return hasher.hexdigest() +def get_stop_criterion_settings(criterion: StopCriterion, params: SimulationParams): + """Get the stop criterion settings""" + + def get_criterion_monitored_file_info(monitor_output, monitor_field): + monitor_output_name = monitor_output.name.replace("/", "_") + monitored_column = None + monitored_csv_filename = None + if isinstance(monitor_output, (ProbeOutput, SurfaceProbeOutput)): + point = monitor_output.entities.stored_entities[0] + monitored_column = f"{monitor_output.name}_{point.name}_{str(monitor_field)}" + monitored_csv_filename = f"monitor_{monitor_output_name}" + if isinstance(monitor_output, SurfaceIntegralOutput): + monitored_column = f"{str(monitor_field)}_integral" + monitor_output_processed = [monitor_output.copy()] + process_user_variables_for_integral(monitor_output_processed) + monitor_field = monitor_output_processed[0].output_fields.items[0] + monitored_csv_filename = f"monitor_{monitor_output_name}" + if isinstance(monitor_output, ForceOutput): + monitored_column = f"total{monitor_field}" + monitored_csv_filename = f"force_output_{monitor_output_name}" + + if monitor_output.moving_statistic is not None: + monitored_column += f"_{monitor_output.moving_statistic.method}" + monitored_csv_filename += "_moving_statistic" + monitored_csv_filename += "_v2.csv" + return monitored_csv_filename, monitored_column + + def get_criterion_tolerance_info(criterion_tolerance, monitor_field, params): + flow360_unit_system = params.flow360_unit_system + if isinstance(monitor_field, UserVariable): + source_units = monitor_field.value.get_output_units(input_params=params) + if source_units.dimensions == u.dimensions.angle: + flow360_unit_system["angle"] = source_units.units + criterion_tolerance_nondim = ( + criterion_tolerance.in_base(flow360_unit_system).v.item() + if not isinstance(criterion_tolerance, float) + else criterion_tolerance + ) + else: + source_units = u.dimensionless # pylint:disable=no-member + criterion_tolerance_nondim = criterion_tolerance + + flow360_units = source_units.get_base_equivalent(flow360_unit_system) + coeff_source_to_flow360, offset_source_to_flow360 = source_units.get_conversion_factor( + flow360_units, dtype=np.float64 + ) + offset_source_to_flow360 = ( + 0.0 + if offset_source_to_flow360 is None + else -offset_source_to_flow360 / coeff_source_to_flow360 + ) + + return criterion_tolerance_nondim, coeff_source_to_flow360, offset_source_to_flow360 + + criterion_csv_filename, criterion_column = get_criterion_monitored_file_info( + monitor_output=criterion.monitor_output, monitor_field=criterion.monitor_field + ) + criterion_tolerance_nondim, coeff_source_to_flow360, offset_source_to_flow360 = ( + get_criterion_tolerance_info( + criterion_tolerance=criterion.tolerance, + monitor_field=criterion.monitor_field, + params=params, + ) + ) + + return { + "monitoredColumn": criterion_column, + "monitoredFileName": criterion_csv_filename, + "tolerance": criterion_tolerance_nondim, + "toleranceWindowSize": criterion.tolerance_window_size, + "sourceToFlow360Coefficient": coeff_source_to_flow360, + "sourceToFlow360Offset": offset_source_to_flow360, + } + + # pylint: disable=too-many-statements # pylint: disable=too-many-branches # pylint: disable=too-many-locals @@ -1887,6 +1964,12 @@ def get_solver_json( translated["runControl"]["monitorProcessorHash"] = calculate_monitor_semaphore_hash( input_params ) + translated["runControl"]["stoppingCriteria"] = [] + if input_params.run_control and bool(input_params.run_control.stopping_criteria): + for criterion in input_params.run_control.stopping_criteria: + translated["runControl"]["stoppingCriteria"].append( + get_stop_criterion_settings(criterion, input_params) + ) translated["usingLiquidAsMaterial"] = isinstance( input_params.operating_condition, LiquidOperatingCondition From e06bde9da7d8a365f988422c7ceaba7fa50c813e Mon Sep 17 00:00:00 2001 From: Angran Date: Sun, 26 Oct 2025 00:23:33 +0000 Subject: [PATCH 11/30] Fix solver translator --- .../component/simulation/translator/solver_translator.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/flow360/component/simulation/translator/solver_translator.py b/flow360/component/simulation/translator/solver_translator.py index 0ecac1bd8..9b816b309 100644 --- a/flow360/component/simulation/translator/solver_translator.py +++ b/flow360/component/simulation/translator/solver_translator.py @@ -47,7 +47,6 @@ PorousMedium, Rotation, Solid, - StopCriterion, ) from flow360.component.simulation.operating_condition.operating_condition import ( LiquidOperatingCondition, @@ -98,6 +97,9 @@ Surface, SurfacePair, ) +from flow360.component.simulation.run_control.stopping_criterion import ( + StoppingCriterion, +) from flow360.component.simulation.simulation_params import SimulationParams from flow360.component.simulation.time_stepping.time_stepping import Steady, Unsteady from flow360.component.simulation.translator.user_expression_utils import ( @@ -1501,7 +1503,7 @@ def calculate_monitor_semaphore_hash(params: SimulationParams): return hasher.hexdigest() -def get_stop_criterion_settings(criterion: StopCriterion, params: SimulationParams): +def get_stop_criterion_settings(criterion: StoppingCriterion, params: SimulationParams): """Get the stop criterion settings""" def get_criterion_monitored_file_info(monitor_output, monitor_field): From aa60d5a93fdbdc792fa3b03de64a419d93fb33d6 Mon Sep 17 00:00:00 2001 From: Angran Date: Mon, 27 Oct 2025 15:25:30 +0000 Subject: [PATCH 12/30] update solver translator for stopping criteria and fix unit test --- .../simulation/translator/solver_translator.py | 7 +++---- .../angular_velocity_with_expression.json | 3 +-- .../ref/value_or_expression/liquid_op_vel_mag.json | 3 +-- .../ref/value_or_expression/op_vel_mag.json | 3 +-- .../ref_area_with_expression.json | 3 +-- .../time_stepping_with_expression.json | 3 +-- .../translator/ref/Flow360_CHT_three_cylinders.json | 3 +-- .../translator/ref/Flow360_NestedCylindersSRF.json | 5 ++--- .../ref/Flow360_TurbFlatPlate137x97_BoxTrip.json | 1 - .../translator/ref/Flow360_XV15HoverMRF.json | 5 ++--- .../translator/ref/Flow360_actuator_disk.json | 1 - .../translator/ref/Flow360_boundaries.json | 1 - .../ref/Flow360_custom_volume_translation.json | 1 - .../translator/ref/Flow360_expression_udf.json | 1 - .../translator/ref/Flow360_heatFluxCylinder.json | 5 ++--- .../ref/Flow360_initial_condition_v2.json | 1 - tests/simulation/translator/ref/Flow360_liquid.json | 1 - .../translator/ref/Flow360_liquid_rotation_dd.json | 1 - .../Flow360_liquid_rotation_dd_with_ref_vel.json | 1 - .../simulation/translator/ref/Flow360_om6Wing.json | 1 - ...360_om6Wing_SA_with_low_reynolds_correction.json | 1 - .../translator/ref/Flow360_om6Wing_debug_point.json | 1 - .../translator/ref/Flow360_om6Wing_debug_type.json | 1 - ...ow360_om6wing_FS_with_turbulence_quantities.json | 1 - .../translator/ref/Flow360_om6wing_FS_with_vel.json | 1 - .../ref/Flow360_om6wing_FS_with_vel_expression.json | 1 - .../ref/Flow360_om6wing_SA_with_modified_C_w2.json | 1 - ...60_om6wing_SST_with_modified_C_sigma_omega1.json | 5 ++--- ...ing_stopping_criterion_and_moving_statistic.json | 13 +++++++++++-- .../translator/ref/Flow360_om6wing_streamlines.json | 1 - .../translator/ref/Flow360_om6wing_wall_model.json | 1 - .../ref/Flow360_periodic_euler_vortex.json | 5 ++--- .../simulation/translator/ref/Flow360_plateASI.json | 1 - .../translator/ref/Flow360_porous_jump.json | 1 - .../translator/ref/Flow360_porous_media_box.json | 3 +-- .../ref/Flow360_porous_media_volume_zone.json | 3 +-- .../ref/Flow360_restart_manipulation_v2.json | 1 - .../translator/ref/Flow360_symmetryBC.json | 1 - .../translator/ref/Flow360_tutorial_2dcrm.json | 1 - tests/simulation/translator/ref/Flow360_udf.json | 1 - .../translator/ref/Flow360_user_variable.json | 1 - .../translator/ref/Flow360_user_variable_heat.json | 1 - .../ref/Flow360_user_variable_isosurface.json | 1 - .../translator/ref/Flow360_vortex_propagation.json | 5 ++--- .../ref/Flow360_xv15_bet_disk_nested_rotation.json | 1 - .../ref/Flow360_xv15_bet_disk_steady_airplane.json | 1 - .../ref/Flow360_xv15_bet_disk_steady_hover.json | 1 - .../ref/Flow360_xv15_bet_disk_unsteady_hover.json | 1 - .../Flow360_xv15_bet_disk_unsteady_hover_UDD.json | 1 - 49 files changed, 34 insertions(+), 73 deletions(-) diff --git a/flow360/component/simulation/translator/solver_translator.py b/flow360/component/simulation/translator/solver_translator.py index 9b816b309..e54116b9b 100644 --- a/flow360/component/simulation/translator/solver_translator.py +++ b/flow360/component/simulation/translator/solver_translator.py @@ -1966,12 +1966,11 @@ def get_solver_json( translated["runControl"]["monitorProcessorHash"] = calculate_monitor_semaphore_hash( input_params ) - translated["runControl"]["stoppingCriteria"] = [] + stopping_criteria = [] if input_params.run_control and bool(input_params.run_control.stopping_criteria): for criterion in input_params.run_control.stopping_criteria: - translated["runControl"]["stoppingCriteria"].append( - get_stop_criterion_settings(criterion, input_params) - ) + stopping_criteria.append(get_stop_criterion_settings(criterion, input_params)) + translated["runControl"]["stoppingCriteria"] = stopping_criteria translated["usingLiquidAsMaterial"] = isinstance( input_params.operating_condition, LiquidOperatingCondition diff --git a/tests/simulation/ref/value_or_expression/angular_velocity_with_expression.json b/tests/simulation/ref/value_or_expression/angular_velocity_with_expression.json index 10d4610ca..22acf3ae6 100644 --- a/tests/simulation/ref/value_or_expression/angular_velocity_with_expression.json +++ b/tests/simulation/ref/value_or_expression/angular_velocity_with_expression.json @@ -124,7 +124,6 @@ "velocityScale": 20.0 }, "runControl": { - "shouldCheckStopCriterion": false, "externalProcessMonitorOutput": false } -} +} \ No newline at end of file diff --git a/tests/simulation/ref/value_or_expression/liquid_op_vel_mag.json b/tests/simulation/ref/value_or_expression/liquid_op_vel_mag.json index 45033e95a..a0cc5cf53 100644 --- a/tests/simulation/ref/value_or_expression/liquid_op_vel_mag.json +++ b/tests/simulation/ref/value_or_expression/liquid_op_vel_mag.json @@ -105,7 +105,6 @@ "velocityScale": 20.0 }, "runControl": { - "shouldCheckStopCriterion": false, "externalProcessMonitorOutput": false } -} +} \ No newline at end of file diff --git a/tests/simulation/ref/value_or_expression/op_vel_mag.json b/tests/simulation/ref/value_or_expression/op_vel_mag.json index 40dab0394..1c640ff57 100644 --- a/tests/simulation/ref/value_or_expression/op_vel_mag.json +++ b/tests/simulation/ref/value_or_expression/op_vel_mag.json @@ -98,7 +98,6 @@ "velocityScale": 1.0 }, "runControl": { - "shouldCheckStopCriterion": false, "externalProcessMonitorOutput": false } -} +} \ No newline at end of file diff --git a/tests/simulation/ref/value_or_expression/ref_area_with_expression.json b/tests/simulation/ref/value_or_expression/ref_area_with_expression.json index b37e1abce..80a0a50c2 100644 --- a/tests/simulation/ref/value_or_expression/ref_area_with_expression.json +++ b/tests/simulation/ref/value_or_expression/ref_area_with_expression.json @@ -109,7 +109,6 @@ "velocityScale": 20.0 }, "runControl": { - "shouldCheckStopCriterion": false, "externalProcessMonitorOutput": false } -} +} \ No newline at end of file diff --git a/tests/simulation/ref/value_or_expression/time_stepping_with_expression.json b/tests/simulation/ref/value_or_expression/time_stepping_with_expression.json index 3a82c06e0..ddada2d0a 100644 --- a/tests/simulation/ref/value_or_expression/time_stepping_with_expression.json +++ b/tests/simulation/ref/value_or_expression/time_stepping_with_expression.json @@ -105,7 +105,6 @@ "velocityScale": 20.0 }, "runControl": { - "shouldCheckStopCriterion": false, "externalProcessMonitorOutput": false } -} +} \ No newline at end of file diff --git a/tests/simulation/translator/ref/Flow360_CHT_three_cylinders.json b/tests/simulation/translator/ref/Flow360_CHT_three_cylinders.json index 749cd211d..b424d26df 100644 --- a/tests/simulation/translator/ref/Flow360_CHT_three_cylinders.json +++ b/tests/simulation/translator/ref/Flow360_CHT_three_cylinders.json @@ -46,7 +46,7 @@ "absoluteTolerance": 1e-15, "maxIterations": 100 }, - "modelType":"HeatEquation", + "modelType": "HeatEquation", "maxForceJacUpdatePhysicalSteps": 0, "orderOfAccuracy": 2, "relativeTolerance": 0.0, @@ -187,7 +187,6 @@ "velocityScale": 1.0 }, "runControl": { - "shouldCheckStopCriterion": false, "externalProcessMonitorOutput": false } } \ No newline at end of file diff --git a/tests/simulation/translator/ref/Flow360_NestedCylindersSRF.json b/tests/simulation/translator/ref/Flow360_NestedCylindersSRF.json index 8dc35870f..ef8ca3cb8 100644 --- a/tests/simulation/translator/ref/Flow360_NestedCylindersSRF.json +++ b/tests/simulation/translator/ref/Flow360_NestedCylindersSRF.json @@ -128,13 +128,12 @@ "v": "v", "w": "w" }, - "userDefinedFields":[], + "userDefinedFields": [], "usingLiquidAsMaterial": false, "outputRescale": { "velocityScale": 1.0 }, "runControl": { - "shouldCheckStopCriterion": false, "externalProcessMonitorOutput": false } -} +} \ No newline at end of file diff --git a/tests/simulation/translator/ref/Flow360_TurbFlatPlate137x97_BoxTrip.json b/tests/simulation/translator/ref/Flow360_TurbFlatPlate137x97_BoxTrip.json index e92615751..635d732ee 100644 --- a/tests/simulation/translator/ref/Flow360_TurbFlatPlate137x97_BoxTrip.json +++ b/tests/simulation/translator/ref/Flow360_TurbFlatPlate137x97_BoxTrip.json @@ -189,7 +189,6 @@ "velocityScale": 1.0 }, "runControl": { - "shouldCheckStopCriterion": false, "externalProcessMonitorOutput": false } } \ No newline at end of file diff --git a/tests/simulation/translator/ref/Flow360_XV15HoverMRF.json b/tests/simulation/translator/ref/Flow360_XV15HoverMRF.json index d0837a36c..f73a8ecd8 100644 --- a/tests/simulation/translator/ref/Flow360_XV15HoverMRF.json +++ b/tests/simulation/translator/ref/Flow360_XV15HoverMRF.json @@ -141,13 +141,12 @@ "v": "v", "w": "w" }, - "userDefinedFields":[], + "userDefinedFields": [], "usingLiquidAsMaterial": false, "outputRescale": { "velocityScale": 1.0 }, "runControl": { - "shouldCheckStopCriterion": false, "externalProcessMonitorOutput": false } -} +} \ No newline at end of file diff --git a/tests/simulation/translator/ref/Flow360_actuator_disk.json b/tests/simulation/translator/ref/Flow360_actuator_disk.json index 066535e5b..e91281acc 100644 --- a/tests/simulation/translator/ref/Flow360_actuator_disk.json +++ b/tests/simulation/translator/ref/Flow360_actuator_disk.json @@ -125,7 +125,6 @@ "velocityScale": 1.0 }, "runControl": { - "shouldCheckStopCriterion": false, "externalProcessMonitorOutput": true, "monitorProcessorHash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" } diff --git a/tests/simulation/translator/ref/Flow360_boundaries.json b/tests/simulation/translator/ref/Flow360_boundaries.json index aec4ffc83..3ded9cdb4 100644 --- a/tests/simulation/translator/ref/Flow360_boundaries.json +++ b/tests/simulation/translator/ref/Flow360_boundaries.json @@ -218,7 +218,6 @@ "velocityScale": 1.0 }, "runControl": { - "shouldCheckStopCriterion": false, "externalProcessMonitorOutput": false } } \ No newline at end of file diff --git a/tests/simulation/translator/ref/Flow360_custom_volume_translation.json b/tests/simulation/translator/ref/Flow360_custom_volume_translation.json index af6f6c9fa..fc8fab57c 100644 --- a/tests/simulation/translator/ref/Flow360_custom_volume_translation.json +++ b/tests/simulation/translator/ref/Flow360_custom_volume_translation.json @@ -116,7 +116,6 @@ "velocityScale": 1.0 }, "runControl": { - "shouldCheckStopCriterion": false, "externalProcessMonitorOutput": true, "monitorProcessorHash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" } diff --git a/tests/simulation/translator/ref/Flow360_expression_udf.json b/tests/simulation/translator/ref/Flow360_expression_udf.json index d23902ae2..67d47822c 100644 --- a/tests/simulation/translator/ref/Flow360_expression_udf.json +++ b/tests/simulation/translator/ref/Flow360_expression_udf.json @@ -113,7 +113,6 @@ "velocityScale": 1.0 }, "runControl": { - "shouldCheckStopCriterion": false, "externalProcessMonitorOutput": false } } \ No newline at end of file diff --git a/tests/simulation/translator/ref/Flow360_heatFluxCylinder.json b/tests/simulation/translator/ref/Flow360_heatFluxCylinder.json index abeedf184..730ced6f2 100644 --- a/tests/simulation/translator/ref/Flow360_heatFluxCylinder.json +++ b/tests/simulation/translator/ref/Flow360_heatFluxCylinder.json @@ -119,13 +119,12 @@ "v": "v", "w": "w" }, - "userDefinedFields":[], + "userDefinedFields": [], "usingLiquidAsMaterial": false, "outputRescale": { "velocityScale": 1.0 }, "runControl": { - "shouldCheckStopCriterion": false, "externalProcessMonitorOutput": false } -} +} \ No newline at end of file diff --git a/tests/simulation/translator/ref/Flow360_initial_condition_v2.json b/tests/simulation/translator/ref/Flow360_initial_condition_v2.json index 63a8dfa05..53025e098 100644 --- a/tests/simulation/translator/ref/Flow360_initial_condition_v2.json +++ b/tests/simulation/translator/ref/Flow360_initial_condition_v2.json @@ -90,7 +90,6 @@ "velocityScale": 1.0 }, "runControl": { - "shouldCheckStopCriterion": false, "externalProcessMonitorOutput": false } } \ No newline at end of file diff --git a/tests/simulation/translator/ref/Flow360_liquid.json b/tests/simulation/translator/ref/Flow360_liquid.json index 2922f6a19..0c47e5c8f 100644 --- a/tests/simulation/translator/ref/Flow360_liquid.json +++ b/tests/simulation/translator/ref/Flow360_liquid.json @@ -103,7 +103,6 @@ "velocityScale": 20.0 }, "runControl": { - "shouldCheckStopCriterion": false, "externalProcessMonitorOutput": false } } diff --git a/tests/simulation/translator/ref/Flow360_liquid_rotation_dd.json b/tests/simulation/translator/ref/Flow360_liquid_rotation_dd.json index 919be4b0e..11933991d 100644 --- a/tests/simulation/translator/ref/Flow360_liquid_rotation_dd.json +++ b/tests/simulation/translator/ref/Flow360_liquid_rotation_dd.json @@ -137,7 +137,6 @@ } }, "runControl": { - "shouldCheckStopCriterion": false, "externalProcessMonitorOutput": false } } diff --git a/tests/simulation/translator/ref/Flow360_liquid_rotation_dd_with_ref_vel.json b/tests/simulation/translator/ref/Flow360_liquid_rotation_dd_with_ref_vel.json index 17c4a53e8..8d7968a04 100644 --- a/tests/simulation/translator/ref/Flow360_liquid_rotation_dd_with_ref_vel.json +++ b/tests/simulation/translator/ref/Flow360_liquid_rotation_dd_with_ref_vel.json @@ -138,7 +138,6 @@ } }, "runControl": { - "shouldCheckStopCriterion": false, "externalProcessMonitorOutput": false } } diff --git a/tests/simulation/translator/ref/Flow360_om6Wing.json b/tests/simulation/translator/ref/Flow360_om6Wing.json index 7a921230b..aec58a848 100644 --- a/tests/simulation/translator/ref/Flow360_om6Wing.json +++ b/tests/simulation/translator/ref/Flow360_om6Wing.json @@ -187,7 +187,6 @@ "velocityScale": 1.0 }, "runControl": { - "shouldCheckStopCriterion": false, "externalProcessMonitorOutput": false } } diff --git a/tests/simulation/translator/ref/Flow360_om6Wing_SA_with_low_reynolds_correction.json b/tests/simulation/translator/ref/Flow360_om6Wing_SA_with_low_reynolds_correction.json index bf86d3f2e..6a746b095 100644 --- a/tests/simulation/translator/ref/Flow360_om6Wing_SA_with_low_reynolds_correction.json +++ b/tests/simulation/translator/ref/Flow360_om6Wing_SA_with_low_reynolds_correction.json @@ -187,7 +187,6 @@ "velocityScale": 1.0 }, "runControl": { - "shouldCheckStopCriterion": false, "externalProcessMonitorOutput": false } } diff --git a/tests/simulation/translator/ref/Flow360_om6Wing_debug_point.json b/tests/simulation/translator/ref/Flow360_om6Wing_debug_point.json index 57ce0eb3d..5fd886bf8 100644 --- a/tests/simulation/translator/ref/Flow360_om6Wing_debug_point.json +++ b/tests/simulation/translator/ref/Flow360_om6Wing_debug_point.json @@ -192,7 +192,6 @@ "velocityScale": 1.0 }, "runControl": { - "shouldCheckStopCriterion": false, "externalProcessMonitorOutput": false } } diff --git a/tests/simulation/translator/ref/Flow360_om6Wing_debug_type.json b/tests/simulation/translator/ref/Flow360_om6Wing_debug_type.json index f0441b471..c498261ce 100644 --- a/tests/simulation/translator/ref/Flow360_om6Wing_debug_type.json +++ b/tests/simulation/translator/ref/Flow360_om6Wing_debug_type.json @@ -188,7 +188,6 @@ "velocityScale": 1.0 }, "runControl": { - "shouldCheckStopCriterion": false, "externalProcessMonitorOutput": false } } diff --git a/tests/simulation/translator/ref/Flow360_om6wing_FS_with_turbulence_quantities.json b/tests/simulation/translator/ref/Flow360_om6wing_FS_with_turbulence_quantities.json index 4a84809ad..f589c8f74 100644 --- a/tests/simulation/translator/ref/Flow360_om6wing_FS_with_turbulence_quantities.json +++ b/tests/simulation/translator/ref/Flow360_om6wing_FS_with_turbulence_quantities.json @@ -191,7 +191,6 @@ "velocityScale": 1.0 }, "runControl": { - "shouldCheckStopCriterion": false, "externalProcessMonitorOutput": false } } diff --git a/tests/simulation/translator/ref/Flow360_om6wing_FS_with_vel.json b/tests/simulation/translator/ref/Flow360_om6wing_FS_with_vel.json index 7c410fd4f..0e8eb27f7 100644 --- a/tests/simulation/translator/ref/Flow360_om6wing_FS_with_vel.json +++ b/tests/simulation/translator/ref/Flow360_om6wing_FS_with_vel.json @@ -192,7 +192,6 @@ "velocityScale": 1.0 }, "runControl": { - "shouldCheckStopCriterion": false, "externalProcessMonitorOutput": false } } diff --git a/tests/simulation/translator/ref/Flow360_om6wing_FS_with_vel_expression.json b/tests/simulation/translator/ref/Flow360_om6wing_FS_with_vel_expression.json index 8e553d8cb..8a622afb4 100644 --- a/tests/simulation/translator/ref/Flow360_om6wing_FS_with_vel_expression.json +++ b/tests/simulation/translator/ref/Flow360_om6wing_FS_with_vel_expression.json @@ -192,7 +192,6 @@ "velocityScale": 1.0 }, "runControl": { - "shouldCheckStopCriterion": false, "externalProcessMonitorOutput": false } } diff --git a/tests/simulation/translator/ref/Flow360_om6wing_SA_with_modified_C_w2.json b/tests/simulation/translator/ref/Flow360_om6wing_SA_with_modified_C_w2.json index d3736d3f2..3bd7548f2 100644 --- a/tests/simulation/translator/ref/Flow360_om6wing_SA_with_modified_C_w2.json +++ b/tests/simulation/translator/ref/Flow360_om6wing_SA_with_modified_C_w2.json @@ -187,7 +187,6 @@ "velocityScale": 1.0 }, "runControl": { - "shouldCheckStopCriterion": false, "externalProcessMonitorOutput": false } } diff --git a/tests/simulation/translator/ref/Flow360_om6wing_SST_with_modified_C_sigma_omega1.json b/tests/simulation/translator/ref/Flow360_om6wing_SST_with_modified_C_sigma_omega1.json index 26a9e0940..94a877de5 100644 --- a/tests/simulation/translator/ref/Flow360_om6wing_SST_with_modified_C_sigma_omega1.json +++ b/tests/simulation/translator/ref/Flow360_om6wing_SST_with_modified_C_sigma_omega1.json @@ -179,13 +179,12 @@ "v": "v", "w": "w" }, - "userDefinedFields":[], + "userDefinedFields": [], "usingLiquidAsMaterial": false, "outputRescale": { "velocityScale": 1.0 }, "runControl": { - "shouldCheckStopCriterion": false, "externalProcessMonitorOutput": false } -} +} \ No newline at end of file diff --git a/tests/simulation/translator/ref/Flow360_om6wing_stopping_criterion_and_moving_statistic.json b/tests/simulation/translator/ref/Flow360_om6wing_stopping_criterion_and_moving_statistic.json index 9380d0191..ed6caa8f5 100644 --- a/tests/simulation/translator/ref/Flow360_om6wing_stopping_criterion_and_moving_statistic.json +++ b/tests/simulation/translator/ref/Flow360_om6wing_stopping_criterion_and_moving_statistic.json @@ -84,9 +84,18 @@ "velocityScale": 1.0 }, "runControl": { - "shouldCheckStopCriterion": true, "externalProcessMonitorOutput": true, - "monitorProcessorHash": "4581ab2e4847a22e8e1af690f2f4d57da76110b57c3ba528eabe7b2ad93e4927" + "monitorProcessorHash": "4581ab2e4847a22e8e1af690f2f4d57da76110b57c3ba528eabe7b2ad93e4927", + "stoppingCriteria": [ + { + "monitoredColumn": "point_legacy1_Point1_Helicity_mean", + "monitoredFileName": "monitor_point_legacy1_moving_statistic_v2.csv", + "sourceToFlow360Coefficient": 6.9594121562924376e-06, + "sourceToFlow360Offset": 0.0, + "tolerance": 0.0001298626308364169, + "toleranceWindowSize": null + } + ] }, "sliceOutput": { "animationFrequency": -1, diff --git a/tests/simulation/translator/ref/Flow360_om6wing_streamlines.json b/tests/simulation/translator/ref/Flow360_om6wing_streamlines.json index b70cba656..28fd93878 100644 --- a/tests/simulation/translator/ref/Flow360_om6wing_streamlines.json +++ b/tests/simulation/translator/ref/Flow360_om6wing_streamlines.json @@ -252,7 +252,6 @@ "startAverageIntegrationStep": -1 }, "runControl": { - "shouldCheckStopCriterion": false, "externalProcessMonitorOutput": false } } diff --git a/tests/simulation/translator/ref/Flow360_om6wing_wall_model.json b/tests/simulation/translator/ref/Flow360_om6wing_wall_model.json index 0bc0d75ad..b8817424d 100644 --- a/tests/simulation/translator/ref/Flow360_om6wing_wall_model.json +++ b/tests/simulation/translator/ref/Flow360_om6wing_wall_model.json @@ -153,7 +153,6 @@ "velocityScale": 1.0 }, "runControl": { - "shouldCheckStopCriterion": false, "externalProcessMonitorOutput": false } } diff --git a/tests/simulation/translator/ref/Flow360_periodic_euler_vortex.json b/tests/simulation/translator/ref/Flow360_periodic_euler_vortex.json index e1ff81994..5979025eb 100644 --- a/tests/simulation/translator/ref/Flow360_periodic_euler_vortex.json +++ b/tests/simulation/translator/ref/Flow360_periodic_euler_vortex.json @@ -94,13 +94,12 @@ "outputFormat": "paraview", "startAverageIntegrationStep": -1 }, - "userDefinedFields":[], + "userDefinedFields": [], "usingLiquidAsMaterial": false, "outputRescale": { "velocityScale": 1.0 }, "runControl": { - "shouldCheckStopCriterion": false, "externalProcessMonitorOutput": false } -} +} \ No newline at end of file diff --git a/tests/simulation/translator/ref/Flow360_plateASI.json b/tests/simulation/translator/ref/Flow360_plateASI.json index 7fc0a8c27..06a3b2b81 100644 --- a/tests/simulation/translator/ref/Flow360_plateASI.json +++ b/tests/simulation/translator/ref/Flow360_plateASI.json @@ -195,7 +195,6 @@ "velocityScale": 1.0 }, "runControl": { - "shouldCheckStopCriterion": false, "externalProcessMonitorOutput": false } } diff --git a/tests/simulation/translator/ref/Flow360_porous_jump.json b/tests/simulation/translator/ref/Flow360_porous_jump.json index 751ab1e19..28df59614 100644 --- a/tests/simulation/translator/ref/Flow360_porous_jump.json +++ b/tests/simulation/translator/ref/Flow360_porous_jump.json @@ -72,7 +72,6 @@ "velocityScale": 1.0 }, "runControl": { - "shouldCheckStopCriterion": false, "externalProcessMonitorOutput": false }, "surfaceOutput": { diff --git a/tests/simulation/translator/ref/Flow360_porous_media_box.json b/tests/simulation/translator/ref/Flow360_porous_media_box.json index 2e02e24d9..f50e9432f 100644 --- a/tests/simulation/translator/ref/Flow360_porous_media_box.json +++ b/tests/simulation/translator/ref/Flow360_porous_media_box.json @@ -200,8 +200,7 @@ "velocityScale": 1.0 }, "runControl": { - "shouldCheckStopCriterion": false, "externalProcessMonitorOutput": true, "monitorProcessorHash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" } -} +} \ No newline at end of file diff --git a/tests/simulation/translator/ref/Flow360_porous_media_volume_zone.json b/tests/simulation/translator/ref/Flow360_porous_media_volume_zone.json index 3f7243c3f..f578628d1 100644 --- a/tests/simulation/translator/ref/Flow360_porous_media_volume_zone.json +++ b/tests/simulation/translator/ref/Flow360_porous_media_volume_zone.json @@ -191,8 +191,7 @@ "velocityScale": 1.0 }, "runControl": { - "shouldCheckStopCriterion": false, "externalProcessMonitorOutput": true, "monitorProcessorHash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" } -} +} \ No newline at end of file diff --git a/tests/simulation/translator/ref/Flow360_restart_manipulation_v2.json b/tests/simulation/translator/ref/Flow360_restart_manipulation_v2.json index cb4915a67..3a03bcaa8 100644 --- a/tests/simulation/translator/ref/Flow360_restart_manipulation_v2.json +++ b/tests/simulation/translator/ref/Flow360_restart_manipulation_v2.json @@ -90,7 +90,6 @@ "velocityScale": 1.0 }, "runControl": { - "shouldCheckStopCriterion": false, "externalProcessMonitorOutput": false } } \ No newline at end of file diff --git a/tests/simulation/translator/ref/Flow360_symmetryBC.json b/tests/simulation/translator/ref/Flow360_symmetryBC.json index 10b9b1f02..46a99a8ad 100644 --- a/tests/simulation/translator/ref/Flow360_symmetryBC.json +++ b/tests/simulation/translator/ref/Flow360_symmetryBC.json @@ -132,7 +132,6 @@ "velocityScale": 1.0 }, "runControl": { - "shouldCheckStopCriterion": false, "externalProcessMonitorOutput": false } } \ No newline at end of file diff --git a/tests/simulation/translator/ref/Flow360_tutorial_2dcrm.json b/tests/simulation/translator/ref/Flow360_tutorial_2dcrm.json index f0f4772fe..8397f5612 100644 --- a/tests/simulation/translator/ref/Flow360_tutorial_2dcrm.json +++ b/tests/simulation/translator/ref/Flow360_tutorial_2dcrm.json @@ -113,7 +113,6 @@ "velocityScale": 1.0 }, "runControl": { - "shouldCheckStopCriterion": false, "externalProcessMonitorOutput": false } } diff --git a/tests/simulation/translator/ref/Flow360_udf.json b/tests/simulation/translator/ref/Flow360_udf.json index 3643f5b2e..99407caf0 100644 --- a/tests/simulation/translator/ref/Flow360_udf.json +++ b/tests/simulation/translator/ref/Flow360_udf.json @@ -96,7 +96,6 @@ "velocityScale": 1.0 }, "runControl": { - "shouldCheckStopCriterion": false, "externalProcessMonitorOutput": false } } \ No newline at end of file diff --git a/tests/simulation/translator/ref/Flow360_user_variable.json b/tests/simulation/translator/ref/Flow360_user_variable.json index 70a45ad47..b49c92a03 100644 --- a/tests/simulation/translator/ref/Flow360_user_variable.json +++ b/tests/simulation/translator/ref/Flow360_user_variable.json @@ -369,7 +369,6 @@ } }, "runControl": { - "shouldCheckStopCriterion": false, "externalProcessMonitorOutput": false } } \ No newline at end of file diff --git a/tests/simulation/translator/ref/Flow360_user_variable_heat.json b/tests/simulation/translator/ref/Flow360_user_variable_heat.json index e6d31e325..d0ac8d756 100644 --- a/tests/simulation/translator/ref/Flow360_user_variable_heat.json +++ b/tests/simulation/translator/ref/Flow360_user_variable_heat.json @@ -129,7 +129,6 @@ } }, "runControl": { - "shouldCheckStopCriterion": false, "externalProcessMonitorOutput": false } } \ No newline at end of file diff --git a/tests/simulation/translator/ref/Flow360_user_variable_isosurface.json b/tests/simulation/translator/ref/Flow360_user_variable_isosurface.json index 84b44a3d6..f878932df 100644 --- a/tests/simulation/translator/ref/Flow360_user_variable_isosurface.json +++ b/tests/simulation/translator/ref/Flow360_user_variable_isosurface.json @@ -195,7 +195,6 @@ ], "usingLiquidAsMaterial": false, "runControl": { - "shouldCheckStopCriterion": false, "externalProcessMonitorOutput": false } } diff --git a/tests/simulation/translator/ref/Flow360_vortex_propagation.json b/tests/simulation/translator/ref/Flow360_vortex_propagation.json index 8edb46b2e..9981192c3 100644 --- a/tests/simulation/translator/ref/Flow360_vortex_propagation.json +++ b/tests/simulation/translator/ref/Flow360_vortex_propagation.json @@ -92,13 +92,12 @@ "outputFormat": "paraview", "startAverageIntegrationStep": -1 }, - "userDefinedFields":[], + "userDefinedFields": [], "usingLiquidAsMaterial": false, "outputRescale": { "velocityScale": 1.0 }, "runControl": { - "shouldCheckStopCriterion": false, "externalProcessMonitorOutput": false } -} +} \ No newline at end of file diff --git a/tests/simulation/translator/ref/Flow360_xv15_bet_disk_nested_rotation.json b/tests/simulation/translator/ref/Flow360_xv15_bet_disk_nested_rotation.json index 29b85e50d..d2969fe4b 100644 --- a/tests/simulation/translator/ref/Flow360_xv15_bet_disk_nested_rotation.json +++ b/tests/simulation/translator/ref/Flow360_xv15_bet_disk_nested_rotation.json @@ -538,7 +538,6 @@ "velocityScale": 1.0 }, "runControl": { - "shouldCheckStopCriterion": false, "externalProcessMonitorOutput": true, "monitorProcessorHash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" } diff --git a/tests/simulation/translator/ref/Flow360_xv15_bet_disk_steady_airplane.json b/tests/simulation/translator/ref/Flow360_xv15_bet_disk_steady_airplane.json index f403b2de2..8623ceac4 100644 --- a/tests/simulation/translator/ref/Flow360_xv15_bet_disk_steady_airplane.json +++ b/tests/simulation/translator/ref/Flow360_xv15_bet_disk_steady_airplane.json @@ -496,7 +496,6 @@ "velocityScale": 1.0 }, "runControl": { - "shouldCheckStopCriterion": false, "externalProcessMonitorOutput": true, "monitorProcessorHash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" } diff --git a/tests/simulation/translator/ref/Flow360_xv15_bet_disk_steady_hover.json b/tests/simulation/translator/ref/Flow360_xv15_bet_disk_steady_hover.json index 3abfa78cf..86c66b814 100644 --- a/tests/simulation/translator/ref/Flow360_xv15_bet_disk_steady_hover.json +++ b/tests/simulation/translator/ref/Flow360_xv15_bet_disk_steady_hover.json @@ -496,7 +496,6 @@ "velocityScale": 1.0 }, "runControl": { - "shouldCheckStopCriterion": false, "externalProcessMonitorOutput": true, "monitorProcessorHash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" } diff --git a/tests/simulation/translator/ref/Flow360_xv15_bet_disk_unsteady_hover.json b/tests/simulation/translator/ref/Flow360_xv15_bet_disk_unsteady_hover.json index 34650ef08..c45c87e5a 100644 --- a/tests/simulation/translator/ref/Flow360_xv15_bet_disk_unsteady_hover.json +++ b/tests/simulation/translator/ref/Flow360_xv15_bet_disk_unsteady_hover.json @@ -501,7 +501,6 @@ "velocityScale": 1.0 }, "runControl": { - "shouldCheckStopCriterion": false, "externalProcessMonitorOutput": true, "monitorProcessorHash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" } diff --git a/tests/simulation/translator/ref/Flow360_xv15_bet_disk_unsteady_hover_UDD.json b/tests/simulation/translator/ref/Flow360_xv15_bet_disk_unsteady_hover_UDD.json index 4f225f27a..90c48aa0b 100644 --- a/tests/simulation/translator/ref/Flow360_xv15_bet_disk_unsteady_hover_UDD.json +++ b/tests/simulation/translator/ref/Flow360_xv15_bet_disk_unsteady_hover_UDD.json @@ -532,7 +532,6 @@ "velocityScale": 1.0 }, "runControl": { - "shouldCheckStopCriterion": false, "externalProcessMonitorOutput": true, "monitorProcessorHash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" } From 3111332fe9f1c1715cc8c10d503fefcb7224f117 Mon Sep 17 00:00:00 2001 From: Angran Date: Tue, 28 Oct 2025 16:00:02 +0000 Subject: [PATCH 13/30] add BET/AD/PM support in ForceOutput --- .../component/simulation/outputs/outputs.py | 58 ++++++++++++++----- 1 file changed, 44 insertions(+), 14 deletions(-) diff --git a/flow360/component/simulation/outputs/outputs.py b/flow360/component/simulation/outputs/outputs.py index e312e863d..6c2a0feb1 100644 --- a/flow360/component/simulation/outputs/outputs.py +++ b/flow360/component/simulation/outputs/outputs.py @@ -19,6 +19,11 @@ EntityListAllowingGhost, Wall, ) +from flow360.component.simulation.models.volume_models import ( + ActuatorDisk, + BETDisk, + PorousMedium, +) from flow360.component.simulation.outputs.output_entities import ( Isosurface, Point, @@ -61,6 +66,11 @@ ) from flow360.component.types import Axis +ForceOutputModelType = Annotated[ + Union[Wall, BETDisk, ActuatorDisk, PorousMedium], + pd.Field(discriminator="type"), +] + class UserDefinedField(Flow360BaseModel): """ @@ -679,9 +689,10 @@ class ForceOutput(_OutputBase): Define :class:`ForceOutput` to output total CL and CD on multiple wing surfaces. + >>> wall = fl.Wall(name = 'wing', surfaces=[volume_mesh['1'], volume_mesh["wing2"]]) >>> fl.ForceOutput( ... name="force_output_wings", - ... entities=[volume_mesh["wing1"], volume_mesh["wing2"]], + ... models=[wall], ... output_fields=["CL", "CD"] ... ) @@ -689,33 +700,35 @@ class ForceOutput(_OutputBase): """ name: str = pd.Field("Force output", description="Name of the force output.") - surface_models: List[Union[Wall, str]] = pd.Field( - description="List of surface models whose force contribution will be calculated.", - ) output_fields: UniqueItemList[ForceOutputCoefficientNames] = pd.Field( description="List of force coefficients. Including CL, CD, CFx, CFy, CFz, CMx, CMy, CMz. " "For surface forces, their SkinFriction/Pressure is also supported, such as CLSkinFriction and CLPressure." ) + models: List[Union[ForceOutputModelType, str]] = pd.Field( + description="List of surface/volume models whose force contribution will be calculated.", + ) moving_statistic: Optional[MovingStatistic] = pd.Field( None, description="The moving statistics used to monitor the output." ) output_type: Literal["ForceOutput"] = pd.Field("ForceOutput", frozen=True) - @pd.field_serializer("surface_models") - def serialize_models(self, v): + @pd.field_serializer("models") + def serialize_models(self, value, info: pd.FieldSerializationInfo): """Serialize only the model's id of the related object.""" + if isinstance(info.context, dict) and info.context.get("columnar_data_processor"): + return value model_ids = [] - for model in v: - if isinstance(model, Wall): + for model in value: + if isinstance(model, get_args(get_args(ForceOutputModelType)[0])): model_ids.append(model.private_attribute_id) continue model_ids.append(model) return model_ids - @pd.field_validator("surface_models", mode="before") + @pd.field_validator("models", mode="before") @classmethod - def _preprocess_models_with_id(cls, v): - """Deserialize string-format surface models as Wall objects.""" + def _preprocess_models_with_id(cls, value): + """Deserialize string-format models as model objects.""" def preprocess_single_model(model, validation_info): if not isinstance(model, str): @@ -726,16 +739,33 @@ def preprocess_single_model(model, validation_info): or validation_info.physics_model_dict.get(model) is None ): raise ValueError("The model does not exist in the models list.") - surface_model_dict = validation_info.physics_model_dict[model] - model = Wall.model_validate(surface_model_dict) + physics_model_dict = validation_info.physics_model_dict[model] + model = pd.TypeAdapter(ForceOutputModelType).validate_python(physics_model_dict) return model processed_models = [] validation_info = get_validation_info() - for model in v: + for model in value: processed_models.append(preprocess_single_model(model, validation_info)) return processed_models + @pd.field_validator("models", mode="after") + @classmethod + def _check_output_fields_with_volume_models_specified(cls, value, info: pd.ValidationInfo): + """Ensure the output field exists when volume models are specified.""" + if all(isinstance(model, Wall) for model in value): + return value + output_fields = info.data.get("output_fields", None) + if all( + field in ["CL", "CD", "CFx", "CFy", "CFz", "CMx", "CMy", "CMz"] + for field in output_fields.items + ): + return value + raise ValueError( + "When ActuatorDisk/BETDisk/PorousMedium is specified, " + "only CL, CD, CFx, CFy, CFz, CMx, CMy, CMz can be set as output_fields." + ) + class ProbeOutput(_OutputBase): """ From 2a1a31b1ef5ddd070ec67edf329ff7499a6bcca0 Mon Sep 17 00:00:00 2001 From: Angran Date: Tue, 28 Oct 2025 16:17:18 +0000 Subject: [PATCH 14/30] Fix unit test --- tests/simulation/translator/ref/Flow360_ghost_periodic.json | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/simulation/translator/ref/Flow360_ghost_periodic.json b/tests/simulation/translator/ref/Flow360_ghost_periodic.json index f66a567ad..416f1523e 100644 --- a/tests/simulation/translator/ref/Flow360_ghost_periodic.json +++ b/tests/simulation/translator/ref/Flow360_ghost_periodic.json @@ -133,7 +133,6 @@ }, "userDefinedFields": [], "runControl": { - "shouldCheckStopCriterion": false, "externalProcessMonitorOutput": false }, "usingLiquidAsMaterial": false, From 71dad7636e226406ffaae6e3c9e74a04febdfe05 Mon Sep 17 00:00:00 2001 From: Angran Date: Tue, 28 Oct 2025 16:17:43 +0000 Subject: [PATCH 15/30] dump a separate json for columnar data postprocessing --- flow360/component/simulation/services.py | 15 +++++++++- .../translator/solver_translator.py | 28 +++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/flow360/component/simulation/services.py b/flow360/component/simulation/services.py index f6b6f0dff..56a27f9dd 100644 --- a/flow360/component/simulation/services.py +++ b/flow360/component/simulation/services.py @@ -44,7 +44,10 @@ ) # Required for correct global scope initialization -from flow360.component.simulation.translator.solver_translator import get_solver_json +from flow360.component.simulation.translator.solver_translator import ( + get_columnar_data_processor_json, + get_solver_json, +) from flow360.component.simulation.translator.surface_meshing_translator import ( get_surface_meshing_json, ) @@ -699,6 +702,16 @@ def simulation_to_case_json(input_params: SimulationParams, mesh_unit): ) +def simulation_to_columnar_data_processor_json(input_params: SimulationParams, mesh_unit): + """Get JSON for case postprocessing from a given simulation JSON.""" + return _translate_simulation_json( + input_params, + mesh_unit, + "case postprocessing", + get_columnar_data_processor_json, + ) + + def _get_mesh_unit(params_as_dict: dict) -> str: if params_as_dict.get("private_attribute_asset_cache") is None: raise ValueError("[Internal] failed to acquire length unit from simulation settings.") diff --git a/flow360/component/simulation/translator/solver_translator.py b/flow360/component/simulation/translator/solver_translator.py index e54116b9b..892e59a5c 100644 --- a/flow360/component/simulation/translator/solver_translator.py +++ b/flow360/component/simulation/translator/solver_translator.py @@ -13,6 +13,7 @@ compute_udf_dimensionalization_factor, ) from flow360.component.simulation.framework.entity_base import EntityList +from flow360.component.simulation.framework.updater_utils import recursive_remove_key from flow360.component.simulation.models.material import Sutherland from flow360.component.simulation.models.solver_numerics import NoneSolver from flow360.component.simulation.models.surface_models import ( @@ -1986,3 +1987,30 @@ def get_solver_json( translated.update(input_params.private_attribute_dict) return translated + + +@preprocess_input +def get_columnar_data_processor_json( + input_params: SimulationParams, + # pylint: disable=no-member,unused-argument + mesh_unit: LengthType.Positive, +): + """ + Get the columnar data processor json from the simulation parameters. + """ + translated = {} + if not input_params.outputs: + return translated + monitor_outputs = [] + for output in input_params.outputs: + if not isinstance(output, get_args(get_args(MonitorOutputType)[0])): + continue + output_dict = output.model_dump( + exclude_none=True, + exclude="private_attribute_id", + context={"columnar_data_processor": True}, + ) + recursive_remove_key(output_dict, "private_attribute_id") + monitor_outputs.append(output_dict) + translated["outputs"] = monitor_outputs + return translated From bb590c2c1c22d9a308300eb69e19b0a05da27d6b Mon Sep 17 00:00:00 2001 From: Angran Date: Tue, 28 Oct 2025 19:58:12 +0000 Subject: [PATCH 16/30] Fix that stopping criterion without moving statistic in MonitorOutput will trigger external postprocessing. --- flow360/component/simulation/translator/solver_translator.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/flow360/component/simulation/translator/solver_translator.py b/flow360/component/simulation/translator/solver_translator.py index 892e59a5c..a08ba7049 100644 --- a/flow360/component/simulation/translator/solver_translator.py +++ b/flow360/component/simulation/translator/solver_translator.py @@ -1480,8 +1480,6 @@ def check_external_postprocessing_existence(params: SimulationParams): if not isinstance(output, ForceOutput) and output.moving_statistic is None: continue return True - if params.run_control and bool(params.run_control.stopping_criteria): - return True return False @@ -2005,6 +2003,8 @@ def get_columnar_data_processor_json( for output in input_params.outputs: if not isinstance(output, get_args(get_args(MonitorOutputType)[0])): continue + if not isinstance(output, ForceOutput) and output.moving_statistic is None: + continue output_dict = output.model_dump( exclude_none=True, exclude="private_attribute_id", From f53c17b485eea62bbff206887312aeea3ed97f62 Mon Sep 17 00:00:00 2001 From: Angran Date: Wed, 29 Oct 2025 19:24:42 +0000 Subject: [PATCH 17/30] update solver translator --- flow360/component/simulation/translator/solver_translator.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/flow360/component/simulation/translator/solver_translator.py b/flow360/component/simulation/translator/solver_translator.py index a08ba7049..e73aca3d7 100644 --- a/flow360/component/simulation/translator/solver_translator.py +++ b/flow360/component/simulation/translator/solver_translator.py @@ -13,7 +13,6 @@ compute_udf_dimensionalization_factor, ) from flow360.component.simulation.framework.entity_base import EntityList -from flow360.component.simulation.framework.updater_utils import recursive_remove_key from flow360.component.simulation.models.material import Sutherland from flow360.component.simulation.models.solver_numerics import NoneSolver from flow360.component.simulation.models.surface_models import ( @@ -2007,10 +2006,8 @@ def get_columnar_data_processor_json( continue output_dict = output.model_dump( exclude_none=True, - exclude="private_attribute_id", context={"columnar_data_processor": True}, ) - recursive_remove_key(output_dict, "private_attribute_id") monitor_outputs.append(output_dict) translated["outputs"] = monitor_outputs return translated From 3bfb357b0163cfe951d4ef327b7de7b46780296d Mon Sep 17 00:00:00 2001 From: Angran Date: Wed, 29 Oct 2025 21:40:29 +0000 Subject: [PATCH 18/30] Update hash calculation logic --- .../translator/solver_translator.py | 24 +++++++++++-------- ...opping_criterion_and_moving_statistic.json | 2 +- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/flow360/component/simulation/translator/solver_translator.py b/flow360/component/simulation/translator/solver_translator.py index e73aca3d7..db39afa19 100644 --- a/flow360/component/simulation/translator/solver_translator.py +++ b/flow360/component/simulation/translator/solver_translator.py @@ -1489,12 +1489,18 @@ def calculate_monitor_semaphore_hash(params: SimulationParams): for output in params.outputs: if not isinstance(output, get_args(get_args(MonitorOutputType)[0])): continue - if output.moving_statistic is None: - continue - json_string_list.append(json.dumps(dump_dict(output.moving_statistic))) - if params.run_control and params.run_control.stopping_criteria: - for criterion in params.run_control.stopping_criteria: - json_string_list.append(json.dumps(dump_dict(criterion))) + if isinstance(output, ForceOutput): + json_string_list.extend( + [ + json.dumps(dump_dict(model)) + for model in sorted( + output.models, key=lambda x: (x.type, x.name, x.private_attribute_id) + ) + ] + ) + json_string_list.extend(output.output_fields.items) + if output.moving_statistic is not None: + json_string_list.append(json.dumps(dump_dict(output.moving_statistic))) combined_string = "".join(sorted(json_string_list)) hasher = hashlib.sha256() hasher.update(combined_string.encode("utf-8")) @@ -1995,10 +2001,9 @@ def get_columnar_data_processor_json( """ Get the columnar data processor json from the simulation parameters. """ - translated = {} + translated = {"outputs": []} if not input_params.outputs: return translated - monitor_outputs = [] for output in input_params.outputs: if not isinstance(output, get_args(get_args(MonitorOutputType)[0])): continue @@ -2008,6 +2013,5 @@ def get_columnar_data_processor_json( exclude_none=True, context={"columnar_data_processor": True}, ) - monitor_outputs.append(output_dict) - translated["outputs"] = monitor_outputs + translated["outputs"].append(output_dict) return translated diff --git a/tests/simulation/translator/ref/Flow360_om6wing_stopping_criterion_and_moving_statistic.json b/tests/simulation/translator/ref/Flow360_om6wing_stopping_criterion_and_moving_statistic.json index ed6caa8f5..021a5e2cb 100644 --- a/tests/simulation/translator/ref/Flow360_om6wing_stopping_criterion_and_moving_statistic.json +++ b/tests/simulation/translator/ref/Flow360_om6wing_stopping_criterion_and_moving_statistic.json @@ -85,7 +85,7 @@ }, "runControl": { "externalProcessMonitorOutput": true, - "monitorProcessorHash": "4581ab2e4847a22e8e1af690f2f4d57da76110b57c3ba528eabe7b2ad93e4927", + "monitorProcessorHash": "358a3ff400394b995bfcf89b31f949f232c4bea45f9cea3e11bc9a4a713c9a78", "stoppingCriteria": [ { "monitoredColumn": "point_legacy1_Point1_Helicity_mean", From 467b04b81740a65c5d9fb3ac2a9f717c807211c6 Mon Sep 17 00:00:00 2001 From: Angran Date: Thu, 30 Oct 2025 21:18:02 +0000 Subject: [PATCH 19/30] Add validation and unit test for ForceOutput --- .../component/simulation/outputs/outputs.py | 22 +- .../data/simulation_force_output_webui.json | 999 ++++++++++++++++++ .../params/test_validators_output.py | 199 +++- 3 files changed, 1215 insertions(+), 5 deletions(-) create mode 100644 tests/simulation/params/data/simulation_force_output_webui.json diff --git a/flow360/component/simulation/outputs/outputs.py b/flow360/component/simulation/outputs/outputs.py index 6c2a0feb1..b42b41ca6 100644 --- a/flow360/component/simulation/outputs/outputs.py +++ b/flow360/component/simulation/outputs/outputs.py @@ -687,12 +687,13 @@ class ForceOutput(_OutputBase): Example ------- - Define :class:`ForceOutput` to output total CL and CD on multiple wing surfaces. + Define :class:`ForceOutput` to output total CL and CD on multiple wing surfaces and a BET disk. >>> wall = fl.Wall(name = 'wing', surfaces=[volume_mesh['1'], volume_mesh["wing2"]]) + >>> bet_disk = fl.BETDisk(...) >>> fl.ForceOutput( - ... name="force_output_wings", - ... models=[wall], + ... name="force_output", + ... models=[wall, bet_disk], ... output_fields=["CL", "CD"] ... ) @@ -738,7 +739,7 @@ def preprocess_single_model(model, validation_info): or validation_info.physics_model_dict is None or validation_info.physics_model_dict.get(model) is None ): - raise ValueError("The model does not exist in the models list.") + raise ValueError("The model does not exist in simulation params' models list.") physics_model_dict = validation_info.physics_model_dict[model] model = pd.TypeAdapter(ForceOutputModelType).validate_python(physics_model_dict) return model @@ -749,6 +750,19 @@ def preprocess_single_model(model, validation_info): processed_models.append(preprocess_single_model(model, validation_info)) return processed_models + @pd.field_validator("models", mode="after") + @classmethod + def _check_duplicate_models(cls, value): + """Ensure no duplicate models are specified.""" + model_ids = [] + for model in value: + model_id = model if isinstance(model, str) else model.private_attribute_id + if model_id not in model_ids: + model_ids.append(model_id) + continue + raise ValueError("Duplicate models are not allowed in the same `ForceOutput`.") + return value + @pd.field_validator("models", mode="after") @classmethod def _check_output_fields_with_volume_models_specified(cls, value, info: pd.ValidationInfo): diff --git a/tests/simulation/params/data/simulation_force_output_webui.json b/tests/simulation/params/data/simulation_force_output_webui.json new file mode 100644 index 000000000..56ec3ddcb --- /dev/null +++ b/tests/simulation/params/data/simulation_force_output_webui.json @@ -0,0 +1,999 @@ +{ + "version": "25.7.6b0", + "unit_system": { + "name": "SI" + }, + "reference_geometry": { + "moment_center": { + "value": [ + 0.0, + 0.0, + 0.0 + ], + "units": "m" + }, + "moment_length": { + "value": 0.6460682372650963, + "units": "m" + }, + "area": { + "type_name": "number", + "value": 0.748844455929999, + "units": "m**2" + } + }, + "operating_condition": { + "type_name": "AerospaceCondition", + "private_attribute_constructor": "from_mach", + "private_attribute_input_cache": { + "mach": 0.84, + "alpha": { + "value": 3.06, + "units": "degree" + }, + "beta": { + "value": 0.0, + "units": "degree" + }, + "thermal_state": { + "type_name": "ThermalState", + "private_attribute_constructor": "default", + "private_attribute_input_cache": {}, + "temperature": { + "value": 288.15, + "units": "K" + }, + "density": { + "value": 1.225, + "units": "kg/m**3" + }, + "material": { + "type": "air", + "name": "air", + "dynamic_viscosity": { + "reference_viscosity": { + "value": 1.9328492090409794e-05, + "units": "kg/(m*s)" + }, + "reference_temperature": { + "value": 288.15, + "units": "K" + }, + "effective_temperature": { + "value": 110.4, + "units": "K" + } + } + } + } + }, + "alpha": { + "value": 3.06, + "units": "degree" + }, + "beta": { + "value": 0.0, + "units": "degree" + }, + "velocity_magnitude": { + "type_name": "number", + "value": 285.84696487889875, + "units": "m/s" + }, + "thermal_state": { + "type_name": "ThermalState", + "private_attribute_constructor": "default", + "private_attribute_input_cache": {}, + "temperature": { + "value": 288.15, + "units": "K" + }, + "density": { + "value": 1.225, + "units": "kg/m**3" + }, + "material": { + "type": "air", + "name": "air", + "dynamic_viscosity": { + "reference_viscosity": { + "value": 1.9328492090409794e-05, + "units": "kg/(m*s)" + }, + "reference_temperature": { + "value": 288.15, + "units": "K" + }, + "effective_temperature": { + "value": 110.4, + "units": "K" + } + } + } + } + }, + "models": [ + { + "material": { + "type": "air", + "name": "air", + "dynamic_viscosity": { + "reference_viscosity": { + "value": 1.716e-05, + "units": "Pa*s" + }, + "reference_temperature": { + "value": 273.15, + "units": "K" + }, + "effective_temperature": { + "value": 110.4, + "units": "K" + } + } + }, + "initial_condition": { + "type_name": "NavierStokesInitialCondition", + "rho": "rho", + "u": "u", + "v": "v", + "w": "w", + "p": "p" + }, + "private_attribute_id": "5031c301-045b-4373-8e0c-ca875f4f204a", + "type": "Fluid", + "navier_stokes_solver": { + "absolute_tolerance": 1e-09, + "relative_tolerance": 0.0, + "order_of_accuracy": 2, + "equation_evaluation_frequency": 1, + "linear_solver": { + "max_iterations": 25 + }, + "CFL_multiplier": 1.0, + "kappa_MUSCL": -1.0, + "numerical_dissipation_factor": 1.0, + "limit_velocity": false, + "limit_pressure_density": false, + "type_name": "Compressible", + "low_mach_preconditioner": false, + "update_jacobian_frequency": 4, + "max_force_jac_update_physical_steps": 0 + }, + "turbulence_model_solver": { + "absolute_tolerance": 1e-08, + "relative_tolerance": 0.0, + "order_of_accuracy": 2, + "equation_evaluation_frequency": 4, + "linear_solver": { + "max_iterations": 15 + }, + "CFL_multiplier": 2.0, + "type_name": "SpalartAllmaras", + "reconstruction_gradient_limiter": 0.5, + "quadratic_constitutive_relation": false, + "modeling_constants": { + "type_name": "SpalartAllmarasConsts", + "C_DES": 0.72, + "C_d": 8.0, + "C_cb1": 0.1355, + "C_cb2": 0.622, + "C_sigma": 0.6666666666666666, + "C_v1": 7.1, + "C_vonKarman": 0.41, + "C_w2": 0.3, + "C_w4": 0.21, + "C_w5": 1.5, + "C_t3": 1.2, + "C_t4": 0.5, + "C_min_rd": 10.0 + }, + "update_jacobian_frequency": 4, + "max_force_jac_update_physical_steps": 0, + "rotation_correction": false, + "low_reynolds_correction": false + }, + "transition_model_solver": { + "type_name": "None" + } + }, + { + "type": "Wall", + "entities": { + "stored_entities": [ + { + "private_attribute_entity_type_name": "Surface", + "private_attribute_id": "1", + "name": "1", + "private_attribute_full_name": "1", + "private_attribute_is_interface": false, + "private_attribute_sub_components": [] + } + ] + }, + "private_attribute_id": "639cd8f5-79c3-4f08-a88f-b44ff8c8f43c", + "name": "wing", + "use_wall_function": false, + "heat_spec": { + "value": { + "value": 0.0, + "units": "W/m**2" + }, + "type_name": "HeatFlux" + }, + "roughness_height": { + "value": 0.0, + "units": "m" + } + }, + { + "type": "SlipWall", + "entities": { + "stored_entities": [ + { + "private_attribute_entity_type_name": "Surface", + "private_attribute_id": "2", + "name": "2", + "private_attribute_full_name": "2", + "private_attribute_is_interface": false, + "private_attribute_sub_components": [] + } + ] + }, + "private_attribute_id": "d517b40d-cab9-49df-b260-9291937bd86b", + "name": "symmetry" + }, + { + "type": "Freestream", + "entities": { + "stored_entities": [ + { + "private_attribute_entity_type_name": "Surface", + "private_attribute_id": "3", + "name": "3", + "private_attribute_full_name": "3", + "private_attribute_is_interface": false, + "private_attribute_sub_components": [] + } + ] + }, + "private_attribute_id": "58a41587-1ad2-48d6-afbe-9e8bafc2a51d", + "name": "Freestream" + }, + { + "name": "Porous medium", + "type": "PorousMedium", + "entities": { + "stored_entities": [ + { + "private_attribute_entity_type_name": "Box", + "private_attribute_id": "07c017de-b078-48c6-bd65-f6bc11b321a2", + "name": "box", + "private_attribute_zone_boundary_names": { + "items": [] + }, + "type_name": "Box", + "private_attribute_constructor": "from_principal_axes", + "private_attribute_input_cache": { + "axes": [ + [ + 0.0, + 1.0, + 0.0 + ], + [ + 0.0, + 0.0, + 1.0 + ] + ], + "center": { + "value": [ + 0.0, + 0.0, + 0.0 + ], + "units": "m" + }, + "size": { + "value": [ + 0.2, + 0.3, + 2.0 + ], + "units": "m" + }, + "name": "box" + }, + "center": { + "value": [ + 0.0, + 0.0, + 0.0 + ], + "units": "m" + }, + "size": { + "value": [ + 0.2, + 0.3, + 2.0 + ], + "units": "m" + }, + "axis_of_rotation": [ + -0.5773502691896256, + -0.5773502691896256, + -0.577350269189626 + ], + "angle_of_rotation": { + "value": -2.0943951023931953, + "units": "rad" + } + } + ] + }, + "darcy_coefficient": { + "value": [ + 1000000.0, + 0.0, + 0.0 + ], + "units": "m**(-2)" + }, + "forchheimer_coefficient": { + "value": [ + 1.0, + 0.0, + 0.0 + ], + "units": "1/m" + }, + "volumetric_heat_source": { + "value": 0.0, + "units": "kg/(m*s**3)" + }, + "private_attribute_id": "42a99183-65eb-45eb-956a-7b41cae69efd" + } + ], + "time_stepping": { + "type_name": "Steady", + "max_steps": 2000, + "CFL": { + "type": "ramp", + "initial": 5.0, + "final": 200.0, + "ramp_steps": 40 + } + }, + "user_defined_fields": [ + { + "type_name": "UserDefinedField", + "name": "Sidewash", + "expression": "Sidewash = atan(primitiveVars[1]/abs(primitiveVars[0]))" + }, + { + "type_name": "UserDefinedField", + "name": "Upwash", + "expression": "Upwash = atan(primitiveVars[2]/abs(primitiveVars[0]))" + }, + { + "type_name": "UserDefinedField", + "name": "Helicity", + "expression": "double vorticity[3];vorticity[0] = gradPrimitive[3][1] - gradPrimitive[2][2];vorticity[1] = gradPrimitive[1][2] - gradPrimitive[3][0];vorticity[2] = gradPrimitive[2][0] - gradPrimitive[1][1];double velocity[3];velocity[0] = primitiveVars[1];velocity[1] = primitiveVars[2];velocity[2] = primitiveVars[3];Helicity = dot(velocity, vorticity);" + } + ], + "outputs": [ + { + "output_fields": { + "items": [ + "primitiveVars", + "residualNavierStokes", + "residualTurbulence", + "Mach", + "Sidewash", + "Upwash", + "Helicity" + ] + }, + "private_attribute_id": "84906625-9279-4cb4-8812-dafb831b2cfa", + "frequency": -1, + "frequency_offset": 0, + "output_format": "paraview", + "name": "Volume output", + "output_type": "VolumeOutput" + }, + { + "output_fields": { + "items": [ + "nuHat" + ] + }, + "private_attribute_id": "b3b6f8f8-2d49-40be-8ad9-ed368f5bffcd", + "frequency": -1, + "frequency_offset": 0, + "output_format": "paraview", + "name": "Surface output", + "entities": { + "stored_entities": [ + { + "private_attribute_entity_type_name": "Surface", + "private_attribute_id": "3", + "name": "3", + "private_attribute_full_name": "3", + "private_attribute_is_interface": false, + "private_attribute_sub_components": [] + }, + { + "private_attribute_entity_type_name": "Surface", + "private_attribute_id": "2", + "name": "2", + "private_attribute_full_name": "2", + "private_attribute_is_interface": false, + "private_attribute_sub_components": [] + }, + { + "private_attribute_entity_type_name": "Surface", + "private_attribute_id": "1", + "name": "1", + "private_attribute_full_name": "1", + "private_attribute_is_interface": false, + "private_attribute_sub_components": [] + } + ] + }, + "write_single_file": false, + "output_type": "SurfaceOutput" + }, + { + "output_fields": { + "items": [ + "primitiveVars", + "vorticity", + "T", + "s", + "Cp", + "mut", + "mutRatio", + "Mach" + ] + }, + "private_attribute_id": "4ad16520-e355-431e-8b2d-d0512a9dee92", + "frequency": -1, + "frequency_offset": 0, + "output_format": "paraview", + "name": "Slice output", + "entities": { + "stored_entities": [ + { + "private_attribute_entity_type_name": "Slice", + "private_attribute_id": "2553b0de-97cc-481e-b9a7-05d39e57f7d8", + "name": "sliceName_1", + "normal": [ + 0.0, + 1.0, + 0.0 + ], + "origin": { + "value": [ + 0.0, + 0.56413, + 0.0 + ], + "units": "m" + } + } + ] + }, + "output_type": "SliceOutput" + }, + { + "output_fields": { + "items": [ + { + "name": "Cp_SI", + "type_name": "UserVariable" + }, + { + "name": "velocity_SI", + "type_name": "UserVariable" + } + ] + }, + "private_attribute_id": "44ab7715-e9bf-4495-8bc5-a41e418d7326", + "name": "Streamline output", + "entities": { + "stored_entities": [ + { + "private_attribute_entity_type_name": "Point", + "private_attribute_id": "37083fc7-2aef-4d8a-aa63-e47491f334c3", + "name": "point_streamline", + "location": { + "value": [ + 0.0, + 1.0, + 0.04 + ], + "units": "m" + } + }, + { + "private_attribute_entity_type_name": "PointArray", + "private_attribute_id": "36662ed5-ce40-4456-8f91-bb8372169ce8", + "name": "pointarray_streamline", + "start": { + "value": [ + 0.0, + 0.0, + 0.2 + ], + "units": "m" + }, + "end": { + "value": [ + 0.0, + 1.0, + 0.2 + ], + "units": "m" + }, + "number_of_points": 20 + }, + { + "private_attribute_entity_type_name": "PointArray2D", + "private_attribute_id": "1d5db652-4fb8-4bcf-938e-ac49638e3a41", + "name": "pointarray2d_streamline", + "origin": { + "value": [ + 0.0, + 0.0, + -0.2 + ], + "units": "m" + }, + "u_axis_vector": { + "value": [ + 0.0, + 1.4, + 0.0 + ], + "units": "m" + }, + "v_axis_vector": { + "value": [ + 0.0, + 0.0, + 0.4 + ], + "units": "m" + }, + "u_number_of_points": 10, + "v_number_of_points": 10 + } + ] + }, + "output_type": "StreamlineOutput" + }, + { + "output_fields": { + "items": [ + { + "name": "Helicity_user", + "type_name": "UserVariable" + } + ] + }, + "private_attribute_id": "b0d034e3-87ce-4b79-8c5a-d22b7386000c", + "name": "point_legacy1", + "entities": { + "stored_entities": [ + { + "private_attribute_entity_type_name": "Point", + "private_attribute_id": "b4f78cfe-f42d-47c9-94f5-d7afdb616a06", + "name": "Point_1", + "location": { + "value": [ + -0.026642, + 0.56614, + 0.0 + ], + "units": "m" + } + } + ] + }, + "moving_statistic": { + "moving_window_size": 200, + "method": "mean", + "start_step": 0, + "type_name": "MovingStatistic" + }, + "output_type": "ProbeOutput" + }, + { + "output_fields": { + "items": [ + { + "name": "Mach_SI", + "type_name": "UserVariable" + } + ] + }, + "private_attribute_id": "9c72b148-84ff-4e78-82d7-8d4c5d11ebd3", + "name": "point_legacy2", + "entities": { + "stored_entities": [ + { + "private_attribute_entity_type_name": "Point", + "private_attribute_id": "5d0433c0-b202-4c14-bdde-e8e993b47f9e", + "name": "Point_1", + "location": { + "value": [ + -0.026642, + 0.56614, + 0.0 + ], + "units": "m" + } + } + ] + }, + "moving_statistic": { + "moving_window_size": 100, + "method": "std", + "start_step": 0, + "type_name": "MovingStatistic" + }, + "output_type": "ProbeOutput" + }, + { + "output_fields": { + "items": [ + "CL", + "CFx", + "CFySkinFriction" + ] + }, + "private_attribute_id": "61863d9a-baf6-499b-87d3-509a2cea2b56", + "name": "forceOutput2", + "models": [ + "639cd8f5-79c3-4f08-a88f-b44ff8c8f43c", + "639cd8f5-79c3-4f08-a88f-b44ff8c8f43c" + ], + "moving_statistic": { + "moving_window_size": 100, + "method": "mean", + "start_step": 0, + "type_name": "MovingStatistic" + }, + "output_type": "ForceOutput" + }, + { + "output_fields": { + "items": [ + "CL", + "CFx", + "CFySkinFriction" + ] + }, + "private_attribute_id": "61863d9a-baf6-499b-87d3-509a2cea2b51", + "name": "forceOutput", + "models": [ + "639cd8f5-79c3-4f08-a88f-b44ff8c8f43c", + "42a99183-65eb-45eb-956a-7b41cae69efd" + ], + "output_type": "ForceOutput" + }, + { + "output_fields": { + "items": [ + "CL", + "CFx", + "CFySkinFriction" + ] + }, + "private_attribute_id": "61863d9a-baf6-499b-87d3-509a2cea2b51", + "name": "forceOutput", + "models": [ + "639cd8f5-79c3-4f08-a88f-b44ff8c8f43c", + "111" + ], + "output_type": "ForceOutput" + } + ], + "run_control": { + "stopping_criteria": [ + { + "name": "Criterion_Helicity", + "monitor_field": { + "name": "Helicity_user", + "type_name": "UserVariable" + }, + "monitor_output": "b0d034e3-87ce-4b79-8c5a-d22b7386000c", + "tolerance": { + "type_name": "number", + "value": 0.005, + "units": "cm/s**2" + }, + "tolerance_window_size": 3, + "type_name": "StoppingCriterion" + }, + { + "name": "Criterion_Mach", + "monitor_field": { + "name": "Mach_SI", + "type_name": "UserVariable" + }, + "monitor_output": "9c72b148-84ff-4e78-82d7-8d4c5d11ebd3", + "tolerance": { + "type_name": "number", + "value": 1e-05 + }, + "type_name": "StoppingCriterion" + }, + { + "name": "Criterion_ForceOutput", + "monitor_field": "CL", + "monitor_output": "61863d9a-baf6-499b-87d3-509a2cea2b56", + "tolerance": { + "type_name": "number", + "value": 0.265 + }, + "type_name": "StoppingCriterion" + } + ], + "type_name": "RunControl" + }, + "private_attribute_asset_cache": { + "project_length_unit": { + "value": 0.8059, + "units": "m" + }, + "project_entity_info": { + "draft_entities": [ + { + "private_attribute_entity_type_name": "Point", + "private_attribute_id": "37083fc7-2aef-4d8a-aa63-e47491f334c3", + "name": "point_streamline", + "location": { + "value": [ + 0.0, + 1.0, + 0.04 + ], + "units": "m" + } + }, + { + "private_attribute_entity_type_name": "Point", + "private_attribute_id": "b4f78cfe-f42d-47c9-94f5-d7afdb616a06", + "name": "Point_1", + "location": { + "value": [ + -0.026642, + 0.56614, + 0.0 + ], + "units": "m" + } + }, + { + "private_attribute_entity_type_name": "PointArray", + "private_attribute_id": "36662ed5-ce40-4456-8f91-bb8372169ce8", + "name": "pointarray_streamline", + "start": { + "value": [ + 0.0, + 0.0, + 0.2 + ], + "units": "m" + }, + "end": { + "value": [ + 0.0, + 1.0, + 0.2 + ], + "units": "m" + }, + "number_of_points": 20 + }, + { + "private_attribute_entity_type_name": "PointArray2D", + "private_attribute_id": "1d5db652-4fb8-4bcf-938e-ac49638e3a41", + "name": "pointarray2d_streamline", + "origin": { + "value": [ + 0.0, + 0.0, + -0.2 + ], + "units": "m" + }, + "u_axis_vector": { + "value": [ + 0.0, + 1.4, + 0.0 + ], + "units": "m" + }, + "v_axis_vector": { + "value": [ + 0.0, + 0.0, + 0.4 + ], + "units": "m" + }, + "u_number_of_points": 10, + "v_number_of_points": 10 + }, + { + "private_attribute_entity_type_name": "Slice", + "private_attribute_id": "2553b0de-97cc-481e-b9a7-05d39e57f7d8", + "name": "sliceName_1", + "normal": [ + 0.0, + 1.0, + 0.0 + ], + "origin": { + "value": [ + 0.0, + 0.56413, + 0.0 + ], + "units": "m" + } + }, + { + "private_attribute_entity_type_name": "Box", + "private_attribute_id": "07c017de-b078-48c6-bd65-f6bc11b321a2", + "name": "box", + "private_attribute_zone_boundary_names": { + "items": [] + }, + "type_name": "Box", + "private_attribute_constructor": "from_principal_axes", + "private_attribute_input_cache": { + "axes": [ + [ + 0.0, + 1.0, + 0.0 + ], + [ + 0.0, + 0.0, + 1.0 + ] + ], + "center": { + "value": [ + 0.0, + 0.0, + 0.0 + ], + "units": "m" + }, + "size": { + "value": [ + 0.2, + 0.3, + 2.0 + ], + "units": "m" + }, + "name": "box" + }, + "center": { + "value": [ + 0.0, + 0.0, + 0.0 + ], + "units": "m" + }, + "size": { + "value": [ + 0.2, + 0.3, + 2.0 + ], + "units": "m" + }, + "axis_of_rotation": [ + -0.5773502691896256, + -0.5773502691896256, + -0.577350269189626 + ], + "angle_of_rotation": { + "value": -2.0943951023931953, + "units": "rad" + } + } + ], + "ghost_entities": [], + "type_name": "VolumeMeshEntityInfo", + "zones": [ + { + "private_attribute_entity_type_name": "GenericVolume", + "private_attribute_id": "1", + "name": "1", + "private_attribute_zone_boundary_names": { + "items": [ + "1", + "2", + "3" + ] + }, + "private_attribute_full_name": "1" + } + ], + "boundaries": [ + { + "private_attribute_entity_type_name": "Surface", + "private_attribute_id": "3", + "name": "3", + "private_attribute_full_name": "3", + "private_attribute_is_interface": false, + "private_attribute_sub_components": [] + }, + { + "private_attribute_entity_type_name": "Surface", + "private_attribute_id": "2", + "name": "2", + "private_attribute_full_name": "2", + "private_attribute_is_interface": false, + "private_attribute_sub_components": [] + }, + { + "private_attribute_entity_type_name": "Surface", + "private_attribute_id": "1", + "name": "1", + "private_attribute_full_name": "1", + "private_attribute_is_interface": false, + "private_attribute_sub_components": [] + } + ] + }, + "use_inhouse_mesher": false, + "use_geometry_AI": false, + "variable_context": [ + { + "name": "Helicity_user", + "value": { + "type_name": "expression", + "expression": "solution.velocity[0] * solution.vorticity[0] + solution.velocity[1] * solution.vorticity[1] + solution.velocity[2] * solution.vorticity[2]" + }, + "post_processing": true + }, + { + "name": "Mach_SI", + "value": { + "type_name": "expression", + "expression": "solution.Mach" + }, + "post_processing": true + }, + { + "name": "Cp_SI", + "value": { + "type_name": "expression", + "expression": "solution.Cp" + }, + "post_processing": true + }, + { + "name": "velocity_SI", + "value": { + "type_name": "expression", + "expression": "solution.velocity" + }, + "post_processing": true + } + ] + } +} \ No newline at end of file diff --git a/tests/simulation/params/test_validators_output.py b/tests/simulation/params/test_validators_output.py index c7f3652c4..9c6c56646 100644 --- a/tests/simulation/params/test_validators_output.py +++ b/tests/simulation/params/test_validators_output.py @@ -14,7 +14,7 @@ SpalartAllmaras, ) from flow360.component.simulation.models.surface_models import Wall -from flow360.component.simulation.models.volume_models import Fluid +from flow360.component.simulation.models.volume_models import Fluid, PorousMedium from flow360.component.simulation.outputs.output_entities import Point from flow360.component.simulation.outputs.outputs import ( AeroAcousticOutput, @@ -44,9 +44,19 @@ from flow360.component.simulation.user_code.core.types import UserVariable from flow360.component.simulation.user_code.functions import math from flow360.component.simulation.user_code.variables import solution +from flow360.component.simulation.validation.validation_context import ( + CASE, + ParamsValidationInfo, + ValidationContext, +) from flow360.component.volume_mesh import VolumeMeshV2 +@pytest.fixture(autouse=True) +def change_test_dir(request, monkeypatch): + monkeypatch.chdir(request.fspath.dirname) + + @pytest.fixture() def reset_context(): clear_context() @@ -643,6 +653,193 @@ def test_output_frequency_settings_in_steady_simulation(): "ctx": {"relevant_for": ["Case"]}, }, ] + + +def test_force_output_with_wall_models(): + """Test ForceOutput with Wall models works correctly.""" + wall_1 = Wall(entities=Surface(name="fluid/wing1")) + wall_2 = Wall(entities=Surface(name="fluid/wing2")) + + with imperial_unit_system: + SimulationParams( + models=[Fluid(), wall_1, wall_2], + outputs=[ + fl.ForceOutput( + name="force_output", + models=[wall_1, wall_2], + output_fields=["CL", "CD", "CMx"], + ) + ], + ) + + # Test with extended force coefficients (SkinFriction/Pressure) + with imperial_unit_system: + SimulationParams( + models=[Fluid(), wall_1], + outputs=[ + fl.ForceOutput( + name="force_output", + models=[wall_1], + output_fields=["CLSkinFriction", "CLPressure", "CDSkinFriction"], + ) + ], + ) + + +def test_force_output_with_surface_and_volume_models(): + """Test ForceOutput with volume models (BETDisk, ActuatorDisk, PorousMedium).""" + wall_1 = Wall(entities=Surface(name="fluid/wing")) + with imperial_unit_system: + porous_zone = fl.Box.from_principal_axes( + name="box", + axes=[[0, 1, 0], [0, 0, 1]], + center=[0, 0, 0] * fl.u.m, + size=[0.2, 0.3, 2] * fl.u.m, + ) + porous_medium = PorousMedium( + entities=[porous_zone], + darcy_coefficient=[1e6, 0, 0], + forchheimer_coefficient=[1, 0, 0], + volumetric_heat_source=0, + ) + + # Valid case: only basic force coefficients + with imperial_unit_system: + SimulationParams( + models=[Fluid(), wall_1, porous_medium], + outputs=[ + fl.ForceOutput( + name="force_output", + models=[wall_1, porous_medium], + output_fields=["CL", "CD", "CFx", "CFy", "CFz", "CMx", "CMy", "CMz"], + ) + ], + ) + + with pytest.raises( + ValueError, + match=re.escape( + "When ActuatorDisk/BETDisk/PorousMedium is specified, " + "only CL, CD, CFx, CFy, CFz, CMx, CMy, CMz can be set as output_fields." + ), + ): + with imperial_unit_system: + SimulationParams( + models=[Fluid(), wall_1, porous_medium], + outputs=[ + fl.ForceOutput( + name="force_output", + models=[wall_1, porous_medium], + output_fields=["CL", "CLSkinFriction"], + ) + ], + ) + + +def test_force_output_duplicate_models(): + """Test that ForceOutput rejects duplicate models.""" + wall_1 = Wall(entities=Surface(name="fluid/wing")) + + with pytest.raises( + ValueError, + match=re.escape("Duplicate models are not allowed in the same `ForceOutput`."), + ): + with imperial_unit_system: + SimulationParams( + models=[Fluid(), wall_1], + outputs=[ + fl.ForceOutput( + name="force_output", + models=[wall_1, wall_1], + output_fields=["CL", "CD"], + ) + ], + ) + + +def test_force_output_nonexistent_model(): + """Test that ForceOutput rejects models not in SimulationParams' models list.""" + wall_1 = Wall(entities=Surface(name="fluid/wing1")) + wall_2 = Wall(entities=Surface(name="fluid/wing2")) + + non_wall2_context = ParamsValidationInfo({}, []) + non_wall2_context.physics_model_dict = {wall_1.private_attribute_id: wall_1.model_dump()} + + with ValidationContext(CASE, non_wall2_context), pytest.raises( + ValueError, + match=re.escape("The model does not exist in simulation params' models list."), + ): + with imperial_unit_system: + SimulationParams( + models=[Fluid(), wall_1], + outputs=[ + fl.ForceOutput( + name="force_output", + models=[wall_2.private_attribute_id], + output_fields=["CL", "CD"], + ) + ], + ) + + +def test_force_output_with_moving_statistic(): + """Test ForceOutput with moving statistics.""" + wall_1 = Wall(entities=Surface(name="fluid/wing")) + + with imperial_unit_system: + SimulationParams( + models=[Fluid(), wall_1], + outputs=[ + fl.ForceOutput( + name="force_output", + models=[wall_1], + output_fields=["CL", "CD"], + moving_statistic=fl.MovingStatistic( + method="mean", moving_window_size=20, start_step=100 + ), + ) + ], + ) + + +def test_force_output_with_model_id(): + # [Frontend] Simulating loading a ForceOutput object with the id of models, + # ensure the validation for models works + with open("data/simulation_force_output_webui.json", "r") as fh: + data = json.load(fh) + + _, errors, _ = validate_model( + params_as_dict=data, validated_by=ValidationCalledBy.LOCAL, root_item_type="VolumeMesh" + ) + print(errors) + expected_errors = [ + { + "type": "value_error", + "loc": ("outputs", 6, "models"), + "msg": "Value error, Duplicate models are not allowed in the same `ForceOutput`.", + "ctx": {"relevant_for": ["Case"]}, + }, + { + "type": "value_error", + "loc": ("outputs", 7, "models"), + "msg": "Value error, When ActuatorDisk/BETDisk/PorousMedium is specified, " + "only CL, CD, CFx, CFy, CFz, CMx, CMy, CMz can be set as output_fields.", + "ctx": {"relevant_for": ["Case"]}, + }, + { + "type": "value_error", + "loc": ("outputs", 8, "models"), + "msg": "Value error, The model does not exist in simulation params' models list.", + "ctx": {"relevant_for": ["Case"]}, + }, + { + "type": "value_error", + "loc": ("run_control", "stopping_criteria", 2, "monitor_output", "models"), + "msg": "Value error, Duplicate models are not allowed in the same `ForceOutput`.", + "ctx": {"relevant_for": ["Case"]}, + }, + ] + assert len(errors) == len(expected_errors) for err, exp_err in zip(errors, expected_errors): assert err["loc"] == exp_err["loc"] From 5521585f6bf177b5e447d32fc3070405a5c66ba8 Mon Sep 17 00:00:00 2001 From: Angran Date: Sat, 1 Nov 2025 00:53:18 +0000 Subject: [PATCH 20/30] Address comments --- flow360/component/results/case_results.py | 64 ++----------- flow360/component/results/results_utils.py | 69 ++++---------- .../simulation/outputs/output_fields.py | 91 ++++++++++++++----- 3 files changed, 94 insertions(+), 130 deletions(-) diff --git a/flow360/component/results/case_results.py b/flow360/component/results/case_results.py index c972ab784..05549da16 100644 --- a/flow360/component/results/case_results.py +++ b/flow360/component/results/case_results.py @@ -7,7 +7,7 @@ import re from enum import Enum from pathlib import Path -from typing import Callable, Dict, List, Optional +from typing import Callable, Dict, List, Optional, get_args import numpy as np import pydantic as pd @@ -23,43 +23,22 @@ ResultTarGZModel, ) from flow360.component.results.results_utils import ( - _CD, + BETDiskCSVHeaderOperation, + DiskCoefficientsComputation, + PorousMediumCoefficientsComputation, +) +from flow360.component.simulation.conversion import unit_converter as unit_converter_v2 +from flow360.component.simulation.outputs.output_fields import ( _CD_PER_STRIP, - _CD_PRESSURE, - _CD_SKIN_FRICTION, - _CL, - _CL_PRESSURE, - _CL_SKIN_FRICTION, _CUMULATIVE_CD_CURVE, _HEAT_FLUX, _X, _Y, - BETDiskCSVHeaderOperation, - DiskCoefficientsComputation, - PorousMediumCoefficientsComputation, - _CFx, + ForceOutputCoefficientNames, _CFx_PER_SPAN, - _CFx_PRESSURE, - _CFx_SKIN_FRICTION, - _CFy, - _CFy_PRESSURE, - _CFy_SKIN_FRICTION, - _CFz, _CFz_PER_SPAN, - _CFz_PRESSURE, - _CFz_SKIN_FRICTION, - _CMx, - _CMx_PRESSURE, - _CMx_SKIN_FRICTION, - _CMy, _CMy_PER_SPAN, - _CMy_PRESSURE, - _CMy_SKIN_FRICTION, - _CMz, - _CMz_PRESSURE, - _CMz_SKIN_FRICTION, ) -from flow360.component.simulation.conversion import unit_converter as unit_converter_v2 from flow360.component.simulation.simulation_params import SimulationParams from flow360.component.simulation.unit_system import ( Flow360UnitSystem, @@ -185,32 +164,7 @@ class SurfaceForcesResultCSVModel(PerEntityResultCSVModel, TimeSeriesResultCSVMo remote_file_name: str = pd.Field(CaseDownloadable.SURFACE_FORCES.value, frozen=True) - _variables: List[str] = [ - _CL, - _CD, - _CFx, - _CFy, - _CFz, - _CMx, - _CMy, - _CMz, - _CL_PRESSURE, - _CD_PRESSURE, - _CFx_PRESSURE, - _CFy_PRESSURE, - _CFz_PRESSURE, - _CMx_PRESSURE, - _CMy_PRESSURE, - _CMz_PRESSURE, - _CL_SKIN_FRICTION, - _CD_SKIN_FRICTION, - _CFx_SKIN_FRICTION, - _CFy_SKIN_FRICTION, - _CFz_SKIN_FRICTION, - _CMx_SKIN_FRICTION, - _CMy_SKIN_FRICTION, - _CMz_SKIN_FRICTION, - ] + _variables: List[str] = list(get_args(ForceOutputCoefficientNames)) def _preprocess(self, filter_physical_steps_only: bool = True, include_time: bool = True): """ diff --git a/flow360/component/results/results_utils.py b/flow360/component/results/results_utils.py index cdbc36125..2a5974436 100644 --- a/flow360/component/results/results_utils.py +++ b/flow360/component/results/results_utils.py @@ -14,55 +14,21 @@ ResultCSVModel, ) from flow360.component.simulation.models.volume_models import BETDisk +from flow360.component.simulation.outputs.output_fields import ( + _CD, + _CL, + _CFx, + _CFy, + _CFz, + _CMx, + _CMy, + _CMz, +) from flow360.component.simulation.simulation_params import SimulationParams from flow360.component.simulation.user_code.core.types import Expression from flow360.exceptions import Flow360ValueError from flow360.log import log -# pylint:disable=invalid-name -_CL = "CL" -_CD = "CD" -_CFx = "CFx" -_CFy = "CFy" -_CFz = "CFz" -_CMx = "CMx" -_CMy = "CMy" -_CMz = "CMz" -_CL_PRESSURE = "CLPressure" -_CD_PRESSURE = "CDPressure" -_CFx_PRESSURE = "CFxPressure" -_CFy_PRESSURE = "CFyPressure" -_CFz_PRESSURE = "CFzPressure" -_CMx_PRESSURE = "CMxPressure" -_CMy_PRESSURE = "CMyPressure" -_CMz_PRESSURE = "CMzPressure" -_CL_SKIN_FRICTION = "CLSkinFriction" -_CD_SKIN_FRICTION = "CDSkinFriction" -_CFx_SKIN_FRICTION = "CFxSkinFriction" -_CFy_SKIN_FRICTION = "CFySkinFriction" -_CFz_SKIN_FRICTION = "CFzSkinFriction" -_CMx_SKIN_FRICTION = "CMxSkinFriction" -_CMy_SKIN_FRICTION = "CMySkinFriction" -_CMz_SKIN_FRICTION = "CMzSkinFriction" -_CL_VISCOUS = "CLViscous" -_CD_VISCOUS = "CDViscous" -_CFx_VISCOUS = "CFxViscous" -_CFy_VISCOUS = "CFyViscous" -_CFz_VISCOUS = "CFzViscous" -_CMx_VISCOUS = "CMxViscous" -_CMy_VISCOUS = "CMyViscous" -_CMz_VISCOUS = "CMzViscous" -_HEAT_TRANSFER = "HeatTransfer" -_HEAT_FLUX = "HeatFlux" -_X = "X" -_Y = "Y" -_CUMULATIVE_CD_CURVE = "Cumulative_CD_Curve" -_CD_PER_STRIP = "CD_per_strip" -_CFx_PER_SPAN = "CFx_per_span" -_CFz_PER_SPAN = "CFz_per_span" -_CMy_PER_SPAN = "CMy_per_span" - - # Static utilities for aerodynamic coefficient computations. # Provides helper methods for computing aerodynamic coefficients using @@ -408,6 +374,7 @@ def compute_coefficients_static( for zone_name in PorousMediumCoefficientsComputation._iter_zones(values): PorousMediumCoefficientsComputation._init_zone_output(out, zone_name) + # pylint: disable=invalid-name for CF, CM, CL_val, CD_val in iterate_step_values_func(zone_name, {}, env, values): out[f"{zone_name}_{_CFx}"].append(CF[0]) out[f"{zone_name}_{_CFy}"].append(CF[1]) @@ -432,7 +399,7 @@ class BETDiskCSVHeaderOperation: @staticmethod def format_headers( - BETCSVModel: ResultCSVModel, + BETCSVModel: ResultCSVModel, # pylint: disable=invalid-name params: SimulationParams, pattern: str = "$BETName_$CylinderName", ) -> LocalResultCSVModel: @@ -468,15 +435,15 @@ def format_headers( disk_rename_map = {} - diskCount = 0 + disk_count = 0 for disk in bet_disks: for disk_local_index, cylinder in enumerate(disk.entities.stored_entities): new_name = pattern.replace("$BETName", disk.name) new_name = new_name.replace("$CylinderName", cylinder.name) new_name = new_name.replace("$DiskLocalIndex", str(disk_local_index)) - new_name = new_name.replace("$DiskGlobalIndex", str(diskCount)) - disk_rename_map[f"Disk{diskCount}"] = new_name - diskCount = diskCount + 1 + new_name = new_name.replace("$DiskGlobalIndex", str(disk_count)) + disk_rename_map[f"Disk{disk_count}"] = new_name + disk_count = disk_count + 1 for header, values in csv_data.items(): matched = False @@ -487,5 +454,5 @@ def format_headers( break if not matched: new_csv[header] = values - newModel = LocalResultCSVModel().from_dict(new_csv) - return newModel + new_model = LocalResultCSVModel().from_dict(new_csv) + return new_model diff --git a/flow360/component/simulation/outputs/output_fields.py b/flow360/component/simulation/outputs/output_fields.py index d32d723d6..06d15da64 100644 --- a/flow360/component/simulation/outputs/output_fields.py +++ b/flow360/component/simulation/outputs/output_fields.py @@ -32,6 +32,49 @@ ) from flow360.component.simulation.unit_system import u +# pylint:disable=invalid-name +_CD = "CD" +_CL = "CL" +_CFx = "CFx" +_CFy = "CFy" +_CFz = "CFz" +_CMx = "CMx" +_CMy = "CMy" +_CMz = "CMz" +_CD_PRESSURE = "CDPressure" +_CL_PRESSURE = "CLPressure" +_CFx_PRESSURE = "CFxPressure" +_CFy_PRESSURE = "CFyPressure" +_CFz_PRESSURE = "CFzPressure" +_CMx_PRESSURE = "CMxPressure" +_CMy_PRESSURE = "CMyPressure" +_CMz_PRESSURE = "CMzPressure" +_CD_SKIN_FRICTION = "CDSkinFriction" +_CL_SKIN_FRICTION = "CLSkinFriction" +_CFx_SKIN_FRICTION = "CFxSkinFriction" +_CFy_SKIN_FRICTION = "CFySkinFriction" +_CFz_SKIN_FRICTION = "CFzSkinFriction" +_CMx_SKIN_FRICTION = "CMxSkinFriction" +_CMy_SKIN_FRICTION = "CMySkinFriction" +_CMz_SKIN_FRICTION = "CMzSkinFriction" +_CL_VISCOUS = "CLViscous" +_CD_VISCOUS = "CDViscous" +_CFx_VISCOUS = "CFxViscous" +_CFy_VISCOUS = "CFyViscous" +_CFz_VISCOUS = "CFzViscous" +_CMx_VISCOUS = "CMxViscous" +_CMy_VISCOUS = "CMyViscous" +_CMz_VISCOUS = "CMzViscous" +_HEAT_TRANSFER = "HeatTransfer" +_HEAT_FLUX = "HeatFlux" +_X = "X" +_Y = "Y" +_CUMULATIVE_CD_CURVE = "Cumulative_CD_Curve" +_CD_PER_STRIP = "CD_per_strip" +_CFx_PER_SPAN = "CFx_per_span" +_CFz_PER_SPAN = "CFz_per_span" +_CMy_PER_SPAN = "CMy_per_span" + # Coefficient of pressure # Coefficient of total pressure # Gradient of primitive solution @@ -211,30 +254,30 @@ ] ForceOutputCoefficientNames = Literal[ - "CD", - "CL", - "CFx", - "CFy", - "CFz", - "CMx", - "CMy", - "CMz", - "CDPressure", - "CLPressure", - "CFxPressure", - "CFyPressure", - "CFzPressure", - "CMxPressure", - "CMyPressure", - "CMzPressure", - "CDSkinFriction", - "CLSkinFriction", - "CFxSkinFriction", - "CFySkinFriction", - "CFzSkinFriction", - "CMxSkinFriction", - "CMySkinFriction", - "CMzSkinFriction", + _CL, + _CD, + _CFx, + _CFy, + _CFz, + _CMx, + _CMy, + _CMz, + _CL_PRESSURE, + _CD_PRESSURE, + _CFx_PRESSURE, + _CFy_PRESSURE, + _CFz_PRESSURE, + _CMx_PRESSURE, + _CMy_PRESSURE, + _CMz_PRESSURE, + _CL_SKIN_FRICTION, + _CD_SKIN_FRICTION, + _CFx_SKIN_FRICTION, + _CFy_SKIN_FRICTION, + _CFz_SKIN_FRICTION, + _CMx_SKIN_FRICTION, + _CMy_SKIN_FRICTION, + _CMz_SKIN_FRICTION, ] # pylint: disable=no-member _FIELD_UNIT_MAPPING = { From 97e7d4cac3c4229bf58b5e682cd9b735446844fc Mon Sep 17 00:00:00 2001 From: Angran Date: Sat, 1 Nov 2025 00:57:29 +0000 Subject: [PATCH 21/30] Enable pylint at the correct location --- flow360/component/simulation/outputs/output_fields.py | 1 + 1 file changed, 1 insertion(+) diff --git a/flow360/component/simulation/outputs/output_fields.py b/flow360/component/simulation/outputs/output_fields.py index 06d15da64..88b34b69c 100644 --- a/flow360/component/simulation/outputs/output_fields.py +++ b/flow360/component/simulation/outputs/output_fields.py @@ -74,6 +74,7 @@ _CFx_PER_SPAN = "CFx_per_span" _CFz_PER_SPAN = "CFz_per_span" _CMy_PER_SPAN = "CMy_per_span" +# pylint:enable=invalid-name # Coefficient of pressure # Coefficient of total pressure From 3dedc25697bd742ed9ac2fd6d5daba7876a572f0 Mon Sep 17 00:00:00 2001 From: Angran Date: Mon, 3 Nov 2025 14:23:45 +0000 Subject: [PATCH 22/30] Remove unnecessary str input for ForceOutput and StoppingCriterion --- flow360/component/simulation/outputs/outputs.py | 4 ++-- .../component/simulation/run_control/stopping_criterion.py | 6 +----- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/flow360/component/simulation/outputs/outputs.py b/flow360/component/simulation/outputs/outputs.py index b42b41ca6..a7af962a3 100644 --- a/flow360/component/simulation/outputs/outputs.py +++ b/flow360/component/simulation/outputs/outputs.py @@ -705,7 +705,7 @@ class ForceOutput(_OutputBase): description="List of force coefficients. Including CL, CD, CFx, CFy, CFz, CMx, CMy, CMz. " "For surface forces, their SkinFriction/Pressure is also supported, such as CLSkinFriction and CLPressure." ) - models: List[Union[ForceOutputModelType, str]] = pd.Field( + models: List[ForceOutputModelType] = pd.Field( description="List of surface/volume models whose force contribution will be calculated.", ) moving_statistic: Optional[MovingStatistic] = pd.Field( @@ -756,7 +756,7 @@ def _check_duplicate_models(cls, value): """Ensure no duplicate models are specified.""" model_ids = [] for model in value: - model_id = model if isinstance(model, str) else model.private_attribute_id + model_id = model.private_attribute_id if model_id not in model_ids: model_ids.append(model_id) continue diff --git a/flow360/component/simulation/run_control/stopping_criterion.py b/flow360/component/simulation/run_control/stopping_criterion.py index 5d6a913df..26c4b2849 100644 --- a/flow360/component/simulation/run_control/stopping_criterion.py +++ b/flow360/component/simulation/run_control/stopping_criterion.py @@ -69,9 +69,7 @@ class StoppingCriterion(Flow360BaseModel): description="The field to be monitored. This field must be " "present in the `output_fields` of `monitor_output`." ) - monitor_output: Union[MonitorOutputType, str] = pd.Field( - description="The output to be monitored." - ) + monitor_output: MonitorOutputType = pd.Field(description="The output to be monitored.") tolerance: ValueOrExpression[Union[UnytQuantity, float]] = pd.Field( description="The tolerance threshold of this criterion." ) @@ -150,8 +148,6 @@ def _check_single_point_in_probe_output(cls, v): @classmethod def _check_field_exists_in_monitor_output(cls, v, info: pd.ValidationInfo): """Ensure the monitor field exist in the monitor output.""" - if isinstance(v, str): - return v monitor_field = info.data.get("monitor_field", None) if monitor_field not in v.output_fields.items: raise ValueError("The monitor field does not exist in the monitor output.") From 4e859617cbcdc0978c1ba6bc4ec405e4e809d9c3 Mon Sep 17 00:00:00 2001 From: Angran Date: Tue, 4 Nov 2025 14:53:59 +0000 Subject: [PATCH 23/30] Move parse_model_dict forward to ensure the multiconstructor models are expanded before validation --- flow360/component/simulation/services.py | 14 +- .../data/simulation_force_output_webui.json | 371 ++++++++++-------- .../params/test_validators_output.py | 7 +- 3 files changed, 213 insertions(+), 179 deletions(-) diff --git a/flow360/component/simulation/services.py b/flow360/component/simulation/services.py index 56a27f9dd..efab2a54c 100644 --- a/flow360/component/simulation/services.py +++ b/flow360/component/simulation/services.py @@ -478,14 +478,22 @@ def validate_model( # pylint: disable=too-many-locals updated_param_as_dict ) + project_length_unit_dict = updated_param_as_dict.get( + "private_attribute_asset_cache", {} + ).get("project_length_unit", None) + parse_model_info = ParamsValidationInfo( + {"private_attribute_asset_cache": {"project_length_unit": project_length_unit_dict}}, + [], + ) + with ValidationContext(levels=validation_levels_to_use, info=parse_model_info): + # Multi-constructor model support + updated_param_as_dict = parse_model_dict(updated_param_as_dict, globals()) + additional_info = ParamsValidationInfo( param_as_dict=updated_param_as_dict, referenced_expressions=referenced_expressions, ) - with ValidationContext(levels=validation_levels_to_use, info=additional_info): - # Multi-constructor model support - updated_param_as_dict = parse_model_dict(updated_param_as_dict, globals()) validated_param = SimulationParams(file_content=updated_param_as_dict) except pd.ValidationError as err: validation_errors = err.errors() diff --git a/tests/simulation/params/data/simulation_force_output_webui.json b/tests/simulation/params/data/simulation_force_output_webui.json index 56ec3ddcb..431e0423f 100644 --- a/tests/simulation/params/data/simulation_force_output_webui.json +++ b/tests/simulation/params/data/simulation_force_output_webui.json @@ -114,6 +114,7 @@ }, "models": [ { + "_id": "f94a410b-31d4-4696-97f6-db4ba8d75fcc", "material": { "type": "air", "name": "air", @@ -140,7 +141,7 @@ "w": "w", "p": "p" }, - "private_attribute_id": "5031c301-045b-4373-8e0c-ca875f4f204a", + "private_attribute_id": "f94a410b-31d4-4696-97f6-db4ba8d75fcc", "type": "Fluid", "navier_stokes_solver": { "absolute_tolerance": 1e-09, @@ -198,6 +199,7 @@ } }, { + "_id": "639cd8f5-79c3-4f08-a88f-b44ff8c8f43c", "type": "Wall", "entities": { "stored_entities": [ @@ -244,6 +246,7 @@ "name": "symmetry" }, { + "_id": "58a41587-1ad2-48d6-afbe-9e8bafc2a51d", "type": "Freestream", "entities": { "stored_entities": [ @@ -261,6 +264,7 @@ "name": "Freestream" }, { + "_id": "42a99183-65eb-45eb-956a-7b41cae69efd", "name": "Porous medium", "type": "PorousMedium", "entities": { @@ -354,6 +358,183 @@ "units": "kg/(m*s**3)" }, "private_attribute_id": "42a99183-65eb-45eb-956a-7b41cae69efd" + }, + { + "_id": "38c82a7f-b0cf-4b38-a93f-31fd1561a946", + "name": "BET11222", + "type": "BETDisk", + "private_attribute_constructor": "from_xrotor", + "rotation_direction_rule": "leftHand", + "number_of_blades": null, + "tip_gap": "inf", + "blade_line_chord": { + "value": 0, + "units": "m" + }, + "type_name": "BETDisk", + "private_attribute_input_cache": { + "number_of_blades": 3, + "file": { + "content": "XROTOR VERSION: 7.54\nxv15_fromXrotor\n! Rho Vso Rmu Alt\n 1.00 342.00 0.17170E-04 2000.0\n! Rad Vel Adv Rake\n 150.00 1.0 4.23765e-3 0.0000\n! XI0 XIW\n 0.13592 0.0000\n! Naero\n 5\n! Xisection\n 0.09\n! A0deg dCLdA CLmax CLmin\n -6.5 4.00 1.2500 -0.0000\n! dCLdAstall dCLstall Cmconst Mcrit\n 0.10000 0.10000 -0.10000 0.75000\n! CDmin CLCDmin dCDdCL^2\n 0.075000E-01 0.00000 0.40000E-02\n! REref REexp\n 0.30000E+06 -0.70000\n! Xisection\n 0.17\n! A0deg dCLdA CLmax CLmin\n -6.0 6.0 1.300 -0.55000\n! dCLdAstall dCLstall Cmconst Mcrit\n 0.10000 0.10000 -0.10000 0.75000\n! CDmin CLCDmin dCDdCL^2\n 0.075000E-01 0.10000 0.40000E-02\n! REref REexp\n 0.30000E+06 -0.70000\n! Xisection\n 0.51\n! A0deg dCLdA CLmax CLmin\n-1.0 6.00 1.400 -1.4000\n! dCLdAstall dCLstall Cmconst Mcrit\n 0.10000 0.10000 -0.10000 0.75000\n! CDmin CLCDmin dCDdCL^2\n 0.05000E-01 0.10000 0.40000E-02\n! REref REexp\n 0.30000E+06 -0.70000\n! Xisection\n 0.8\n! A0deg dCLdA CLmax CLmin\n -1.0 6.0 1.600 -1.500\n! dCLdAstall dCLstall Cmconst Mcrit\n 0.10000 0.10000 -0.10000 0.75000\n! CDmin CLCDmin dCDdCL^2\n 0.03000E-01 0.10000 0.40000E-02\n! REref REexp\n 0.30000E+06 -0.70000\n! Xisection\n 1.0\n! A0deg dCLdA CLmax CLmin\n -1 6.0 1.0 -1.8000\n! dCLdAstall dCLstall Cmconst Mcrit\n 0.10000 0.10000 -0.10000 0.75000\n! CDmin CLCDmin dCDdCL^2\n 0.04000E-01 0.10000 0.40000E-02\n! REref REexp\n 0.30000E+06 -0.70000\n!LVDuct LDuct LWind\n T F F\n! II Nblds\n 63 3\n! r/R C/R Beta0deg Ubody\n0.023003943\t0.113428403\t33.27048712\t0\n0.039116227\t0.113428403\t32.37853609\t0\n0.060384093\t0.113428403\t31.42712165\t0\n0.083584992\t0.113428403\t30.65409742\t0\n0.093024104\t0.113428403\t30.13214089\t0\n0.108253499\t0.113428403\t29.41523066\t0\n0.122239679\t0.111608996\t28.75567325\t0\n0.140577109\t0.10978959\t27.89538097\t0\n0.159536634\t0.107970183\t26.69097178\t0\n0.172590692\t0.106150776\t25.88803232\t0\n0.182536671\t0.10433137\t25.25715132\t0\n0.192482739\t0.102511963\t24.5689175\t0\n0.20305028\t0.100692556\t23.93803649\t0\n0.214861124\t0.09887315\t23.19244985\t0\n0.227293664\t0.097053743\t22.36083398\t0\n0.239415201\t0.095234336\t21.67260016\t0\n0.25153696\t0.093414933\t20.84098429\t0\n0.264591196\t0.0934138\t19.92333919\t0\n0.27609148\t0.093412667\t19.03437051\t0\n0.286348329\t0.093411533\t18.34613668\t0\n0.297848614\t0.0934104\t17.457168\t0\n0.309659147\t0.093409267\t16.91231622\t0\n0.322713117\t0.093408133\t16.16672958\t0\n0.334834565\t0.093407\t15.53584858\t0\n0.345091281\t0.093405867\t14.93364398\t0\n0.356591344\t0.093404733\t14.18805734\t0\n0.367158885\t0.0934036\t13.55717634\t0\n0.375240265\t0.093402467\t12.86894251\t0\n0.385497114\t0.093401333\t12.18070869\t0\n0.396375525\t0.0934002\t11.49247487\t0\n0.406632108\t0.093399067\t10.9762995\t0\n0.419063938\t0.093397933\t10.60350618\t0\n0.436468714\t0.0933968\t9.94394877\t0\n0.450765676\t0.093395667\t9.28439136\t0\n0.465373463\t0.093394533\t8.59615753\t0\n0.476873349\t0.0933934\t7.96527653\t0\n0.489927141\t0.093392267\t7.33439553\t0\n0.497075821\t0.093391133\t6.87557298\t0\n0.511683032\t0.09339\t6.56013248\t0\n0.529087542\t0.093388867\t6.07263352\t0\n0.544627496\t0.093387733\t5.49910533\t0\n0.557680933\t0.0933866\t5.0976356\t0\n0.569491245\t0.093385467\t4.69616587\t0\n0.585652762\t0.093384333\t4.12263769\t0\n0.596841423\t0.0933832\t3.77852078\t0\n0.609273165\t0.093382067\t3.46308028\t0\n0.628853143\t0.093380933\t2.97558132\t0\n0.664905287\t0.0933798\t2.0005834\t0\n0.686349777\t0.093378667\t1.62779008\t0\n0.69940317\t0.093377533\t1.25499676\t0\n0.711213304\t0.0933764\t0.96823267\t0\n0.726753081\t0.093375267\t0.50941012\t0\n0.745400849\t0.093374133 -0.064118065 0\n0.762805314\t0.093373 -0.522940614 0\n0.797303462\t0.093371867\t-1.44058571 0\n0.842057794\t0.093370733 -2.61631849 0\n0.877177195\t0.0933696\t-3.333228722 0\n0.91385068\t0.093368467\t-4.164844591 0\n0.936227735\t0.093367333\t-4.681019957 0\n0.957361443\t0.0933662 -5.053813278 0\n0.977873766\t0.093365067 -5.541312235 0\n0.989683856\t0.093363933 -5.799399919 0\n1\t0.0933634\t-6.1\t0\n! URDuct\n 1.0000", + "file_path": "xrotorTest.xrotor", + "type_name": "XRotorFile" + }, + "length_unit": { + "value": 1, + "units": "m" + }, + "angle_unit": { + "value": 1, + "units": "degree" + }, + "name": "BET11222", + "rotation_direction_rule": "leftHand", + "omega": { + "value": 10, + "units": "rad/s" + }, + "chord_ref": { + "value": 3, + "units": "m" + }, + "tip_gap": "inf", + "blade_line_chord": { + "value": 0, + "units": "m" + }, + "entities": { + "stored_entities": [ + { + "private_attribute_id": "9e2d69cd-460e-456d-b0df-b232be7198a3", + "name": "Cylinder", + "private_attribute_entity_type_name": "Cylinder", + "axis": [ + 0, + 1, + 0 + ], + "center": { + "value": [ + 0, + -10, + 0 + ], + "units": "cm" + }, + "height": { + "value": 1, + "units": "cm" + }, + "inner_radius": { + "value": 0, + "units": "m" + }, + "outer_radius": { + "value": 1, + "units": "cm" + } + }, + { + "private_attribute_id": "41cc394a-0ac6-4e0f-97f9-e1971edacbe3", + "name": "Cylinder3", + "private_attribute_entity_type_name": "Cylinder", + "axis": [ + 0, + 1, + 0 + ], + "center": { + "value": [ + 0, + -8, + 0 + ], + "units": "cm" + }, + "height": { + "value": 1, + "units": "cm" + }, + "inner_radius": { + "value": 0, + "units": "m" + }, + "outer_radius": { + "value": 1, + "units": "cm" + } + } + ] + }, + "n_loading_nodes": 3 + }, + "private_attribute_id": "38c82a7f-b0cf-4b38-a93f-31fd1561a946" + }, + { + "_id": "f929834a-0adb-4b42-a880-3d034edce883", + "name": "Actuator disk1", + "type": "ActuatorDisk", + "force_per_area": { + "radius": { + "value": [ + 0, + 1, + 10 + ], + "units": "m" + }, + "thrust": { + "value": [ + 1, + 2, + 1 + ], + "units": "N/m**2" + }, + "circumferential": { + "value": [ + 0, + 20, + 1 + ], + "units": "N/m**2" + } + }, + "entities": { + "stored_entities": [ + { + "private_attribute_id": "c007c4ff-65af-41d9-847b-e0058f31ae51", + "name": "ad1", + "private_attribute_entity_type_name": "Cylinder", + "axis": [ + 0, + 0, + 1 + ], + "center": { + "value": [ + 10, + 0, + 0 + ], + "units": "cm" + }, + "height": { + "value": 1, + "units": "cm" + }, + "inner_radius": { + "value": 0, + "units": "m" + }, + "outer_radius": { + "value": 1, + "units": "cm" + } + } + ] + }, + "private_attribute_id": "f929834a-0adb-4b42-a880-3d034edce883" } ], "time_stepping": { @@ -397,183 +578,13 @@ ] }, "private_attribute_id": "84906625-9279-4cb4-8812-dafb831b2cfa", + "_id": "84906625-9279-4cb4-8812-dafb831b2cfa", "frequency": -1, "frequency_offset": 0, "output_format": "paraview", "name": "Volume output", "output_type": "VolumeOutput" }, - { - "output_fields": { - "items": [ - "nuHat" - ] - }, - "private_attribute_id": "b3b6f8f8-2d49-40be-8ad9-ed368f5bffcd", - "frequency": -1, - "frequency_offset": 0, - "output_format": "paraview", - "name": "Surface output", - "entities": { - "stored_entities": [ - { - "private_attribute_entity_type_name": "Surface", - "private_attribute_id": "3", - "name": "3", - "private_attribute_full_name": "3", - "private_attribute_is_interface": false, - "private_attribute_sub_components": [] - }, - { - "private_attribute_entity_type_name": "Surface", - "private_attribute_id": "2", - "name": "2", - "private_attribute_full_name": "2", - "private_attribute_is_interface": false, - "private_attribute_sub_components": [] - }, - { - "private_attribute_entity_type_name": "Surface", - "private_attribute_id": "1", - "name": "1", - "private_attribute_full_name": "1", - "private_attribute_is_interface": false, - "private_attribute_sub_components": [] - } - ] - }, - "write_single_file": false, - "output_type": "SurfaceOutput" - }, - { - "output_fields": { - "items": [ - "primitiveVars", - "vorticity", - "T", - "s", - "Cp", - "mut", - "mutRatio", - "Mach" - ] - }, - "private_attribute_id": "4ad16520-e355-431e-8b2d-d0512a9dee92", - "frequency": -1, - "frequency_offset": 0, - "output_format": "paraview", - "name": "Slice output", - "entities": { - "stored_entities": [ - { - "private_attribute_entity_type_name": "Slice", - "private_attribute_id": "2553b0de-97cc-481e-b9a7-05d39e57f7d8", - "name": "sliceName_1", - "normal": [ - 0.0, - 1.0, - 0.0 - ], - "origin": { - "value": [ - 0.0, - 0.56413, - 0.0 - ], - "units": "m" - } - } - ] - }, - "output_type": "SliceOutput" - }, - { - "output_fields": { - "items": [ - { - "name": "Cp_SI", - "type_name": "UserVariable" - }, - { - "name": "velocity_SI", - "type_name": "UserVariable" - } - ] - }, - "private_attribute_id": "44ab7715-e9bf-4495-8bc5-a41e418d7326", - "name": "Streamline output", - "entities": { - "stored_entities": [ - { - "private_attribute_entity_type_name": "Point", - "private_attribute_id": "37083fc7-2aef-4d8a-aa63-e47491f334c3", - "name": "point_streamline", - "location": { - "value": [ - 0.0, - 1.0, - 0.04 - ], - "units": "m" - } - }, - { - "private_attribute_entity_type_name": "PointArray", - "private_attribute_id": "36662ed5-ce40-4456-8f91-bb8372169ce8", - "name": "pointarray_streamline", - "start": { - "value": [ - 0.0, - 0.0, - 0.2 - ], - "units": "m" - }, - "end": { - "value": [ - 0.0, - 1.0, - 0.2 - ], - "units": "m" - }, - "number_of_points": 20 - }, - { - "private_attribute_entity_type_name": "PointArray2D", - "private_attribute_id": "1d5db652-4fb8-4bcf-938e-ac49638e3a41", - "name": "pointarray2d_streamline", - "origin": { - "value": [ - 0.0, - 0.0, - -0.2 - ], - "units": "m" - }, - "u_axis_vector": { - "value": [ - 0.0, - 1.4, - 0.0 - ], - "units": "m" - }, - "v_axis_vector": { - "value": [ - 0.0, - 0.0, - 0.4 - ], - "units": "m" - }, - "u_number_of_points": 10, - "v_number_of_points": 10 - } - ] - }, - "output_type": "StreamlineOutput" - }, { "output_fields": { "items": [ @@ -699,6 +710,22 @@ "111" ], "output_type": "ForceOutput" + }, + { + "output_fields": { + "items": [ + "CL", + "CFx" + ] + }, + "private_attribute_id": "61863d9a-baf6-499b-87d3-509a2cea2b51", + "name": "forceOutput", + "models": [ + "639cd8f5-79c3-4f08-a88f-b44ff8c8f43c", + "f929834a-0adb-4b42-a880-3d034edce883", + "38c82a7f-b0cf-4b38-a93f-31fd1561a946" + ], + "output_type": "ForceOutput" } ], "run_control": { diff --git a/tests/simulation/params/test_validators_output.py b/tests/simulation/params/test_validators_output.py index 9c6c56646..53ccbbab4 100644 --- a/tests/simulation/params/test_validators_output.py +++ b/tests/simulation/params/test_validators_output.py @@ -811,24 +811,23 @@ def test_force_output_with_model_id(): _, errors, _ = validate_model( params_as_dict=data, validated_by=ValidationCalledBy.LOCAL, root_item_type="VolumeMesh" ) - print(errors) expected_errors = [ { "type": "value_error", - "loc": ("outputs", 6, "models"), + "loc": ("outputs", 3, "models"), "msg": "Value error, Duplicate models are not allowed in the same `ForceOutput`.", "ctx": {"relevant_for": ["Case"]}, }, { "type": "value_error", - "loc": ("outputs", 7, "models"), + "loc": ("outputs", 4, "models"), "msg": "Value error, When ActuatorDisk/BETDisk/PorousMedium is specified, " "only CL, CD, CFx, CFy, CFz, CMx, CMy, CMz can be set as output_fields.", "ctx": {"relevant_for": ["Case"]}, }, { "type": "value_error", - "loc": ("outputs", 8, "models"), + "loc": ("outputs", 5, "models"), "msg": "Value error, The model does not exist in simulation params' models list.", "ctx": {"relevant_for": ["Case"]}, }, From 0c6d93891d6b956946be4ffef2b91a47d8e11df7 Mon Sep 17 00:00:00 2001 From: Angran Date: Fri, 7 Nov 2025 20:33:00 +0000 Subject: [PATCH 24/30] update solver translator for more flexible dataset selection of stopping criterion --- .../simulation/translator/solver_translator.py | 17 ++++++++--------- ...stopping_criterion_and_moving_statistic.json | 2 +- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/flow360/component/simulation/translator/solver_translator.py b/flow360/component/simulation/translator/solver_translator.py index db39afa19..b5a437815 100644 --- a/flow360/component/simulation/translator/solver_translator.py +++ b/flow360/component/simulation/translator/solver_translator.py @@ -1513,26 +1513,25 @@ def get_stop_criterion_settings(criterion: StoppingCriterion, params: Simulation def get_criterion_monitored_file_info(monitor_output, monitor_field): monitor_output_name = monitor_output.name.replace("/", "_") monitored_column = None - monitored_csv_filename = None + monitored_dataset_name = None if isinstance(monitor_output, (ProbeOutput, SurfaceProbeOutput)): point = monitor_output.entities.stored_entities[0] monitored_column = f"{monitor_output.name}_{point.name}_{str(monitor_field)}" - monitored_csv_filename = f"monitor_{monitor_output_name}" + monitored_dataset_name = f"monitor_{monitor_output_name}" if isinstance(monitor_output, SurfaceIntegralOutput): monitored_column = f"{str(monitor_field)}_integral" monitor_output_processed = [monitor_output.copy()] process_user_variables_for_integral(monitor_output_processed) monitor_field = monitor_output_processed[0].output_fields.items[0] - monitored_csv_filename = f"monitor_{monitor_output_name}" + monitored_dataset_name = f"monitor_{monitor_output_name}" if isinstance(monitor_output, ForceOutput): monitored_column = f"total{monitor_field}" - monitored_csv_filename = f"force_output_{monitor_output_name}" + monitored_dataset_name = f"force_output_{monitor_output_name}" if monitor_output.moving_statistic is not None: monitored_column += f"_{monitor_output.moving_statistic.method}" - monitored_csv_filename += "_moving_statistic" - monitored_csv_filename += "_v2.csv" - return monitored_csv_filename, monitored_column + monitored_dataset_name += "_moving_statistic" + return monitored_dataset_name, monitored_column def get_criterion_tolerance_info(criterion_tolerance, monitor_field, params): flow360_unit_system = params.flow360_unit_system @@ -1561,7 +1560,7 @@ def get_criterion_tolerance_info(criterion_tolerance, monitor_field, params): return criterion_tolerance_nondim, coeff_source_to_flow360, offset_source_to_flow360 - criterion_csv_filename, criterion_column = get_criterion_monitored_file_info( + criterion_dataset_name, criterion_column = get_criterion_monitored_file_info( monitor_output=criterion.monitor_output, monitor_field=criterion.monitor_field ) criterion_tolerance_nondim, coeff_source_to_flow360, offset_source_to_flow360 = ( @@ -1574,7 +1573,7 @@ def get_criterion_tolerance_info(criterion_tolerance, monitor_field, params): return { "monitoredColumn": criterion_column, - "monitoredFileName": criterion_csv_filename, + "monitoredDatasetName": criterion_dataset_name, "tolerance": criterion_tolerance_nondim, "toleranceWindowSize": criterion.tolerance_window_size, "sourceToFlow360Coefficient": coeff_source_to_flow360, diff --git a/tests/simulation/translator/ref/Flow360_om6wing_stopping_criterion_and_moving_statistic.json b/tests/simulation/translator/ref/Flow360_om6wing_stopping_criterion_and_moving_statistic.json index 021a5e2cb..bec2c2971 100644 --- a/tests/simulation/translator/ref/Flow360_om6wing_stopping_criterion_and_moving_statistic.json +++ b/tests/simulation/translator/ref/Flow360_om6wing_stopping_criterion_and_moving_statistic.json @@ -89,7 +89,7 @@ "stoppingCriteria": [ { "monitoredColumn": "point_legacy1_Point1_Helicity_mean", - "monitoredFileName": "monitor_point_legacy1_moving_statistic_v2.csv", + "monitoredDatasetName": "monitor_point_legacy1_moving_statistic", "sourceToFlow360Coefficient": 6.9594121562924376e-06, "sourceToFlow360Offset": 0.0, "tolerance": 0.0001298626308364169, From c3ff30f5b8e85c1d2244b2a0ec6b4bd9c69ea2fb Mon Sep 17 00:00:00 2001 From: Angran Date: Wed, 12 Nov 2025 19:05:51 +0000 Subject: [PATCH 25/30] Address comments --- .../component/simulation/framework/param_utils.py | 7 +++++++ flow360/component/simulation/outputs/outputs.py | 14 ++++++-------- .../simulation/run_control/stopping_criterion.py | 7 +++---- 3 files changed, 16 insertions(+), 12 deletions(-) diff --git a/flow360/component/simulation/framework/param_utils.py b/flow360/component/simulation/framework/param_utils.py index 61eddb15e..0a3675162 100644 --- a/flow360/component/simulation/framework/param_utils.py +++ b/flow360/component/simulation/framework/param_utils.py @@ -230,3 +230,10 @@ def _set_boundary_full_name_with_zone_name( continue with model_attribute_unlock(surface, "private_attribute_full_name"): surface.private_attribute_full_name = f"{give_zone_name}/{surface.name}" + + +def serialize_model_obj_to_id(model_obj: Flow360BaseModel) -> str: + """Serialize a model object to its id.""" + if hasattr(model_obj, "private_attribute_id"): + return model_obj.private_attribute_id + raise ValueError(f"The model object {model_obj} cannot be serialized to id.") diff --git a/flow360/component/simulation/outputs/outputs.py b/flow360/component/simulation/outputs/outputs.py index a7af962a3..9ca36a6fa 100644 --- a/flow360/component/simulation/outputs/outputs.py +++ b/flow360/component/simulation/outputs/outputs.py @@ -14,6 +14,7 @@ from flow360.component.simulation.framework.base_model import Flow360BaseModel from flow360.component.simulation.framework.entity_base import EntityList, generate_uuid from flow360.component.simulation.framework.expressions import StringExpression +from flow360.component.simulation.framework.param_utils import serialize_model_obj_to_id from flow360.component.simulation.framework.unique_list import UniqueItemList from flow360.component.simulation.models.surface_models import ( EntityListAllowingGhost, @@ -656,7 +657,7 @@ class SurfaceIntegralOutput(_OutputBase): description="List of output variables, only the :class:`UserDefinedField` is allowed." ) moving_statistic: Optional[MovingStatistic] = pd.Field( - None, description="The moving statistics used to monitor the output." + None, description="When specified, report moving statistics of the fields instead." ) output_type: Literal["SurfaceIntegralOutput"] = pd.Field("SurfaceIntegralOutput", frozen=True) @@ -709,7 +710,7 @@ class ForceOutput(_OutputBase): description="List of surface/volume models whose force contribution will be calculated.", ) moving_statistic: Optional[MovingStatistic] = pd.Field( - None, description="The moving statistics used to monitor the output." + None, description="When specified, report moving statistics of the fields instead." ) output_type: Literal["ForceOutput"] = pd.Field("ForceOutput", frozen=True) @@ -720,10 +721,7 @@ def serialize_models(self, value, info: pd.FieldSerializationInfo): return value model_ids = [] for model in value: - if isinstance(model, get_args(get_args(ForceOutputModelType)[0])): - model_ids.append(model.private_attribute_id) - continue - model_ids.append(model) + model_ids.append(serialize_model_obj_to_id(model_obj=model)) return model_ids @pd.field_validator("models", mode="before") @@ -838,7 +836,7 @@ class ProbeOutput(_OutputBase): " and :class:`UserDefinedField`." ) moving_statistic: Optional[MovingStatistic] = pd.Field( - None, description="The moving statistics used to monitor the output." + None, description="When specified, report moving statistics of the fields instead." ) output_type: Literal["ProbeOutput"] = pd.Field("ProbeOutput", frozen=True) @@ -905,7 +903,7 @@ class SurfaceProbeOutput(_OutputBase): " :ref:`variables specific to SurfaceOutput` and :class:`UserDefinedField`." ) moving_statistic: Optional[MovingStatistic] = pd.Field( - None, description="The moving statistics used to monitor the output." + None, description="When specified, report moving statistics of the fields instead." ) output_type: Literal["SurfaceProbeOutput"] = pd.Field("SurfaceProbeOutput", frozen=True) diff --git a/flow360/component/simulation/run_control/stopping_criterion.py b/flow360/component/simulation/run_control/stopping_criterion.py index 26c4b2849..f13a72ca8 100644 --- a/flow360/component/simulation/run_control/stopping_criterion.py +++ b/flow360/component/simulation/run_control/stopping_criterion.py @@ -1,11 +1,12 @@ """Module for setting up the stopping criterion of simulation.""" -from typing import List, Literal, Optional, Union, get_args +from typing import List, Literal, Optional, Union import pydantic as pd import flow360.component.simulation.units as u from flow360.component.simulation.framework.base_model import Flow360BaseModel +from flow360.component.simulation.framework.param_utils import serialize_model_obj_to_id from flow360.component.simulation.outputs.output_entities import Point from flow360.component.simulation.outputs.output_fields import _FIELD_IS_SCALAR_MAPPING from flow360.component.simulation.outputs.outputs import ( @@ -101,9 +102,7 @@ def preprocess( @pd.field_serializer("monitor_output") def serialize_monitor_output(self, v): """Serialize only the output's id of the related object.""" - if isinstance(v, get_args(get_args(MonitorOutputType)[0])): - return v.private_attribute_id - return v + return serialize_model_obj_to_id(model_obj=v) @pd.field_validator("monitor_field", mode="after") @classmethod From 53c48cc1ccfe7c22f6853bea3a76412aff6b08dd Mon Sep 17 00:00:00 2001 From: Angran Date: Fri, 14 Nov 2025 16:21:03 +0000 Subject: [PATCH 26/30] Fix unit test after rebase --- flow360/component/results/results_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flow360/component/results/results_utils.py b/flow360/component/results/results_utils.py index 2a5974436..dc0d3b095 100644 --- a/flow360/component/results/results_utils.py +++ b/flow360/component/results/results_utils.py @@ -127,7 +127,7 @@ def _get_lift_drag_direction(params: SimulationParams): def _get_dynamic_pressure_in_flow360_unit(params: SimulationParams): - # pylint:disable=protected-access + # pylint:disable=protected-access,invalid-name v_ref = params.reference_velocity From 1423b4b729bd5960fb5f1f7547a044f49a66da5e Mon Sep 17 00:00:00 2001 From: Angran Date: Fri, 14 Nov 2025 17:26:14 +0000 Subject: [PATCH 27/30] Moving statistic improvement based on QA testing --- .../component/simulation/outputs/outputs.py | 39 +- .../component/simulation/simulation_params.py | 6 + .../validation/validation_output.py | 40 ++ .../params/test_validators_output.py | 398 ++++++++++++++++-- 4 files changed, 417 insertions(+), 66 deletions(-) diff --git a/flow360/component/simulation/outputs/outputs.py b/flow360/component/simulation/outputs/outputs.py index 9ca36a6fa..8c1cb86e8 100644 --- a/flow360/component/simulation/outputs/outputs.py +++ b/flow360/component/simulation/outputs/outputs.py @@ -135,11 +135,19 @@ class MovingStatistic(Flow360BaseModel): :class:`ProbeOutput`, :class:`SurfaceProbeOutput`, :class:`SurfaceIntegralOutput` and :class:`ForceOutput`. + Notes + ----- + - The window size is defined by the number of data points recorded in the output. + - For steady simulations, the solver typically outputs a data point once every **10 pseudo steps**. + This means a :py:attr:`moving_window_size`=10 would cover 100 pseudo steps. + Thus, the :py:attr:`start_step` value is automatically rounded up to + the nearest multiple of 10 for steady simulations. + Example ------- Define a moving statistic to compute the standard deviation in a moving window of - 10 steps, with the initial 100 steps skipped. + 10 data points, with the initial 100 steps skipped. >>> fl.MovingStatistic( ... moving_window_size=10, @@ -150,35 +158,24 @@ class MovingStatistic(Flow360BaseModel): ==== """ - moving_window_size: pd.PositiveInt = pd.Field( + moving_window_size: pd.StrictInt = pd.Field( 10, - description="The number of pseudo/physical steps to compute moving statistics. " - "For steady simulation, the moving_window_size should be a multiple of 10.", + ge=2, + description="The size of the moving window in data points over which the " + "statistic is calculated. Must be greater than or equal to 2.", ) method: Literal["mean", "min", "max", "std", "deviation"] = pd.Field( - "mean", description="The type of moving statistics used to monitor the output." + "mean", description="The statistical method to apply to the data within the moving window." ) start_step: pd.NonNegativeInt = pd.Field( 0, - description="The number of pseudo/physical steps to skip before computing the moving statistics. " - "For steady simulation, the moving_window_size should be a multiple of 10.", + description="The number of steps (pseudo or physical) to skip at the beginning of the " + "simulation before the moving statistics calculation starts. For steady " + "simulations, this value is automatically rounded up to the nearest multiple of 10, " + "as the solver outputs data every 10 pseudo steps.", ) type_name: Literal["MovingStatistic"] = pd.Field("MovingStatistic", frozen=True) - @pd.field_validator("moving_window_size", "start_step", mode="after") - @classmethod - def _check_moving_window_for_steady_simulation(cls, value): - validation_info = get_validation_info() - if ( - validation_info - and validation_info.time_stepping == TimeSteppingType.STEADY - and value % 10 != 0 - ): - raise ValueError( - "For steady simulation, the number of steps should be a multiple of 10." - ) - return value - class _OutputBase(Flow360BaseModel): output_fields: UniqueItemList[str] = pd.Field() diff --git a/flow360/component/simulation/simulation_params.py b/flow360/component/simulation/simulation_params.py index cef212a40..2e7917ea4 100644 --- a/flow360/component/simulation/simulation_params.py +++ b/flow360/component/simulation/simulation_params.py @@ -88,6 +88,7 @@ from flow360.component.simulation.utils import model_attribute_unlock from flow360.component.simulation.validation.validation_output import ( _check_aero_acoustics_observer_time_step_size, + _check_moving_statistic_applicability, _check_output_fields, _check_output_fields_valid_given_transition_model, _check_output_fields_valid_given_turbulence_model, @@ -585,6 +586,11 @@ def check_time_average_output(params): """Only allow TimeAverage output field in the unsteady simulations""" return _check_time_average_output(params) + @pd.model_validator(mode="after") + def check_moving_statistic_applicability(params): + """Check moving statistic settings are applicable to the simulation time stepping set up.""" + return _check_moving_statistic_applicability(params) + def _register_assigned_entities(self, registry: EntityRegistry) -> EntityRegistry: """Recursively register all entities listed in EntityList to the asset cache.""" # pylint: disable=no-member diff --git a/flow360/component/simulation/validation/validation_output.py b/flow360/component/simulation/validation/validation_output.py index 1f56ec09b..8c525fdd7 100644 --- a/flow360/component/simulation/validation/validation_output.py +++ b/flow360/component/simulation/validation/validation_output.py @@ -2,6 +2,7 @@ Validation for output parameters """ +import math from typing import List, Literal, Union, get_args, get_origin from flow360.component.simulation.models.volume_models import Fluid @@ -14,6 +15,9 @@ ) from flow360.component.simulation.time_stepping.time_stepping import Steady from flow360.component.simulation.user_code.core.types import Expression +from flow360.component.simulation.validation.validation_utils import ( + customize_model_validator_error, +) def _check_output_fields(params): @@ -276,3 +280,39 @@ def _check_unique_surface_volume_probe_entity_names(params): active_entity_names.add(entity.name) return params + + +def _check_moving_statistic_applicability(params): + + if not params.time_stepping: + return params + + if not params.outputs: + return params + + is_steady = isinstance(params.time_stepping, Steady) + max_steps = params.time_stepping.max_steps if is_steady else params.time_stepping.steps + + for output_index, output in enumerate(params.outputs): + if not hasattr(output, "moving_statistic") or output.moving_statistic is None: + continue + moving_window_size_in_step = ( + output.moving_statistic.moving_window_size * 10 + if is_steady + else output.moving_statistic.moving_window_size + ) + start_step = ( + math.ceil(output.moving_statistic.start_step / 10) * 10 + if is_steady + else output.moving_statistic.start_step + ) + if moving_window_size_in_step + start_step > max_steps: + raise customize_model_validator_error( + model_instance=params, + relative_location=("outputs", output_index, "moving_statistic"), + message="`moving_statistic`'s moving_window_size + start_step exceeds " + "the total number of steps in the simulation.", + input_value=output.moving_statistic.model_dump(), + ) + + return params diff --git a/tests/simulation/params/test_validators_output.py b/tests/simulation/params/test_validators_output.py index 53ccbbab4..6f36e20b6 100644 --- a/tests/simulation/params/test_validators_output.py +++ b/tests/simulation/params/test_validators_output.py @@ -2,11 +2,32 @@ import os import re +import pydantic import pytest import flow360 as fl import flow360.component.simulation.units as u -from flow360.component.simulation.entity_info import VolumeMeshEntityInfo + + +def assert_validation_error_contains( + error: pydantic.ValidationError, + expected_loc: tuple, + expected_msg_contains: str, +): + """Helper function to assert validation error properties for moving_statistic tests""" + errors = error.errors() + # Find the error with matching location + matching_errors = [e for e in errors if e["loc"] == expected_loc] + assert ( + len(matching_errors) == 1 + ), f"Expected 1 error at {expected_loc}, found {len(matching_errors)}" + assert expected_msg_contains in matching_errors[0]["msg"], ( + f"Expected '{expected_msg_contains}' in error message, " + f"but got: '{matching_errors[0]['msg']}'" + ) + assert matching_errors[0]["type"] == "value_error" + + from flow360.component.simulation.framework.param_utils import AssetCache from flow360.component.simulation.models.solver_numerics import ( KOmegaSST, @@ -331,70 +352,357 @@ def test_duplicate_surface_usage(): ) -def test_moving_statitic_validator(): - wall_1 = Surface(name="wall_1", private_attribute_is_interface=False) - asset_cache = AssetCache( - project_length_unit="m", - project_entity_info=VolumeMeshEntityInfo(boundaries=[wall_1]), +def test_check_moving_statistic_applicability_steady_valid(): + """Test moving_statistic with steady simulation - valid case.""" + wall_1 = Wall(entities=Surface(name="fluid/wing")) + + # Valid: window_size=10 (becomes 100 steps) + start_step=100 (becomes 100) = 200 <= 5000 + with imperial_unit_system: + SimulationParams( + models=[Fluid(), wall_1], + time_stepping=Steady(max_steps=5000), + outputs=[ + fl.ForceOutput( + name="force_output", + models=[wall_1], + output_fields=["CL", "CD"], + moving_statistic=MovingStatistic( + method="mean", moving_window_size=10, start_step=100 + ), + ) + ], + ) + + # Valid: window_size=5 (becomes 50 steps) + start_step=50 (becomes 50) = 100 <= 1000 + with imperial_unit_system: + SimulationParams( + models=[Fluid(), wall_1], + time_stepping=Steady(max_steps=1000), + outputs=[ + ProbeOutput( + name="probe_output", + probe_points=[Point(name="point_1", location=[1, 2, 3] * u.m)], + output_fields=["Cp"], + moving_statistic=MovingStatistic( + method="std", moving_window_size=5, start_step=50 + ), + ) + ], + ) + + +def test_check_moving_statistic_applicability_steady_invalid(): + """Test moving_statistic with steady simulation - invalid case.""" + wall_1 = Wall(entities=Surface(name="fluid/wing")) + + # Invalid: window_size=50 (becomes 500 steps) + start_step=4600 (becomes 4600) = 5100 > 5000 + with pytest.raises(pydantic.ValidationError) as exc_info: + with imperial_unit_system: + SimulationParams( + models=[Fluid(), wall_1], + time_stepping=Steady(max_steps=5000), + outputs=[ + fl.ForceOutput( + name="force_output", + models=[wall_1], + output_fields=["CL", "CD"], + moving_statistic=MovingStatistic( + method="mean", moving_window_size=50, start_step=4600 + ), + ) + ], + ) + + assert_validation_error_contains( + exc_info.value, + expected_loc=("outputs", 0, "moving_statistic"), + expected_msg_contains="`moving_statistic`'s moving_window_size + start_step exceeds " + "the total number of steps in the simulation.", ) - with SI_unit_system: - monitored_variable = UserVariable( - name="Helicity_MONITOR", - value=math.dot(solution.velocity, solution.vorticity), + # Invalid: window_size=20 (becomes 200 steps) + start_step=850 (becomes 850) = 1060 > 1000 + with pytest.raises(pydantic.ValidationError) as exc_info: + with imperial_unit_system: + SimulationParams( + models=[Fluid(), wall_1], + time_stepping=Steady(max_steps=1000), + outputs=[ + ProbeOutput( + name="probe_output", + probe_points=[Point(name="point_1", location=[1, 2, 3] * u.m)], + output_fields=["Cp"], + moving_statistic=MovingStatistic( + method="std", moving_window_size=20, start_step=850 + ), + ) + ], + ) + + assert_validation_error_contains( + exc_info.value, + expected_loc=("outputs", 0, "moving_statistic"), + expected_msg_contains="`moving_statistic`'s moving_window_size + start_step exceeds " + "the total number of steps in the simulation.", + ) + + +def test_check_moving_statistic_applicability_unsteady_valid(): + """Test moving_statistic with unsteady simulation - valid case.""" + wall_1 = Wall(entities=Surface(name="fluid/wing")) + + # Valid: window_size=100 + start_step=200 = 300 <= 1000 + with imperial_unit_system: + SimulationParams( + models=[Fluid(), wall_1], + time_stepping=Unsteady(steps=1000, step_size=1e-3), + outputs=[ + fl.ForceOutput( + name="force_output", + models=[wall_1], + output_fields=["CL", "CD"], + moving_statistic=MovingStatistic( + method="mean", moving_window_size=100, start_step=200 + ), + ) + ], ) - params = SimulationParams( - time_stepping=Steady(max_steps=5000), - models=[Fluid(), Wall(entities=wall_1)], + + # Valid: window_size=50 + start_step=50 = 100 <= 500 + with imperial_unit_system: + SimulationParams( + models=[Fluid(), wall_1], + time_stepping=Unsteady(steps=500, step_size=1e-3), outputs=[ ProbeOutput( - name="point_legacy2", - output_fields=["Mach", monitored_variable], - probe_points=Point(name="Point1", location=(-0.026642, 0.56614, 0) * u.m), - moving_statistic=MovingStatistic(method="std", moving_window_size=15), + name="probe_output", + probe_points=[Point(name="point_1", location=[1, 2, 3] * u.m)], + output_fields=["Cp"], + moving_statistic=MovingStatistic( + method="std", moving_window_size=50, start_step=50 + ), ) ], - private_attribute_asset_cache=asset_cache, ) - params, errors, _ = validate_model( - validated_by=ValidationCalledBy.LOCAL, - params_as_dict=params.model_dump(mode="json"), - root_item_type="VolumeMesh", - validation_level="Case", + +def test_check_moving_statistic_applicability_unsteady_invalid(): + """Test moving_statistic with unsteady simulation - invalid case.""" + wall_1 = Wall(entities=Surface(name="fluid/wing")) + + # Invalid: window_size=500 + start_step=600 = 1100 > 1000 + with pytest.raises(pydantic.ValidationError) as exc_info: + with imperial_unit_system: + SimulationParams( + models=[Fluid(), wall_1], + time_stepping=Unsteady(steps=1000, step_size=1e-3), + outputs=[ + fl.ForceOutput( + name="force_output", + models=[wall_1], + output_fields=["CL", "CD"], + moving_statistic=MovingStatistic( + method="mean", moving_window_size=500, start_step=600 + ), + ) + ], + ) + + assert_validation_error_contains( + exc_info.value, + expected_loc=("outputs", 0, "moving_statistic"), + expected_msg_contains="`moving_statistic`'s moving_window_size + start_step exceeds " + "the total number of steps in the simulation.", ) - assert len(errors) == 1 - assert ( - errors[0]["msg"] == "Value error, For steady simulation, " - "the number of steps should be a multiple of 10." + + # Invalid: window_size=200 + start_step=350 = 550 > 500 + with pytest.raises(pydantic.ValidationError) as exc_info: + with imperial_unit_system: + SimulationParams( + models=[Fluid(), wall_1], + time_stepping=Unsteady(steps=500, step_size=1e-3), + outputs=[ + ProbeOutput( + name="probe_output", + probe_points=[Point(name="point_1", location=[1, 2, 3] * u.m)], + output_fields=["Cp"], + moving_statistic=MovingStatistic( + method="std", moving_window_size=200, start_step=350 + ), + ) + ], + ) + + assert_validation_error_contains( + exc_info.value, + expected_loc=("outputs", 0, "moving_statistic"), + expected_msg_contains="`moving_statistic`'s moving_window_size + start_step exceeds " + "the total number of steps in the simulation.", ) - with SI_unit_system: - monitored_variable = UserVariable( - name="Helicity_MONITOR", - value=math.dot(solution.velocity, solution.vorticity), + +def test_check_moving_statistic_applicability_steady_edge_cases(): + """Test moving_statistic with steady simulation - edge cases for rounding.""" + wall_1 = Wall(entities=Surface(name="fluid/wing")) + + # Edge case: start_step=47 rounds up to 50, window_size=10 becomes 100, total=150 <= 200 + with imperial_unit_system: + SimulationParams( + models=[Fluid(), wall_1], + time_stepping=Steady(max_steps=200), + outputs=[ + fl.ForceOutput( + name="force_output", + models=[wall_1], + output_fields=["CL", "CD"], + moving_statistic=MovingStatistic( + method="mean", moving_window_size=10, start_step=47 + ), + ) + ], ) - params = SimulationParams( - time_stepping=Steady(max_steps=5000), - models=[Fluid(), Wall(entities=wall_1)], + + # Edge case: start_step=99 rounds up to 100, window_size=5 becomes 50, total=150 <= 200 + with imperial_unit_system: + SimulationParams( + models=[Fluid(), wall_1], + time_stepping=Steady(max_steps=200), outputs=[ ProbeOutput( - name="point_legacy2", - output_fields=["Mach", monitored_variable], - probe_points=Point(name="Point1", location=(-0.026642, 0.56614, 0) * u.m), - moving_statistic=MovingStatistic(method="std", moving_window_size=20), + name="probe_output", + probe_points=[Point(name="point_1", location=[1, 2, 3] * u.m)], + output_fields=["Cp"], + moving_statistic=MovingStatistic( + method="std", moving_window_size=5, start_step=99 + ), ) ], - private_attribute_asset_cache=asset_cache, ) - _, errors, _ = validate_model( - validated_by=ValidationCalledBy.LOCAL, - params_as_dict=params.model_dump(mode="json"), - root_item_type="VolumeMesh", - validation_level="Case", + # Edge case: start_step=100 (already multiple of 10), window_size=10 becomes 100, total=200 <= 200 + with imperial_unit_system: + SimulationParams( + models=[Fluid(), wall_1], + time_stepping=Steady(max_steps=200), + outputs=[ + fl.ForceOutput( + name="force_output", + models=[wall_1], + output_fields=["CL", "CD"], + moving_statistic=MovingStatistic( + method="mean", moving_window_size=10, start_step=100 + ), + ) + ], + ) + + +def test_check_moving_statistic_applicability_multiple_outputs(): + """ + Test moving_statistic with multiple outputs - captures ALL errors from different outputs. + + The validation function collects all errors from all invalid outputs and raises them together. + This follows Pydantic's pattern of collecting errors from list items. + """ + wall_1 = Wall(entities=Surface(name="fluid/wing")) + uv_surface1 = UserVariable( + name="uv_surface1", value=math.dot(solution.velocity, solution.CfVec) + ) + + # Multiple outputs with errors - ALL errors should be collected + # All 4 outputs have invalid moving_statistic (500 + 600 = 1100 > 1000) + with pytest.raises(pydantic.ValidationError) as exc_info: + with imperial_unit_system: + SimulationParams( + models=[Fluid(), wall_1], + time_stepping=Unsteady(steps=1000, step_size=1e-3), + outputs=[ + fl.ForceOutput( + name="force_output", + models=[wall_1], + output_fields=["CL", "CD"], + moving_statistic=MovingStatistic( + method="mean", moving_window_size=100, start_step=600 + ), + ), + ProbeOutput( + name="probe_output", + probe_points=[Point(name="point_1", location=[1, 2, 3] * u.m)], + output_fields=["Cp"], + moving_statistic=MovingStatistic( + method="std", moving_window_size=100, start_step=600 + ), + ), + SurfaceIntegralOutput( + entities=Surface(name="fluid/wing"), + output_fields=[uv_surface1], + moving_statistic=MovingStatistic( + method="mean", moving_window_size=500, start_step=600 + ), + ), + SurfaceProbeOutput( + name="surface_probe_output", + probe_points=[Point(name="point_1", location=[1, 2, 3] * u.m)], + output_fields=["Cp"], + target_surfaces=[Surface(name="fluid/wing")], + moving_statistic=MovingStatistic( + method="mean", moving_window_size=100, start_step=600 + ), + ), + ], + ) + + assert len(exc_info.value.errors()) == 1 + assert_validation_error_contains( + exc_info.value, + expected_loc=("outputs", 2, "moving_statistic"), + expected_msg_contains="`moving_statistic`'s moving_window_size + start_step exceeds " + "the total number of steps in the simulation.", ) - assert errors is None + + +def test_check_moving_statistic_applicability_no_moving_statistic(): + """Test that outputs without moving_statistic are not validated.""" + wall_1 = Wall(entities=Surface(name="fluid/wing")) + + # Should pass - no moving_statistic specified + with imperial_unit_system: + SimulationParams( + models=[Fluid(), wall_1], + time_stepping=Steady(max_steps=1000), + outputs=[ + fl.ForceOutput( + name="force_output", + models=[wall_1], + output_fields=["CL", "CD"], + ), + ProbeOutput( + name="probe_output", + probe_points=[Point(name="point_1", location=[1, 2, 3] * u.m)], + output_fields=["Cp"], + ), + ], + ) + + +def test_check_moving_statistic_applicability_no_time_stepping(): + """Test that function returns early when no time_stepping is provided.""" + wall_1 = Wall(entities=Surface(name="fluid/wing")) + + # Should pass - no time_stepping specified + with imperial_unit_system: + SimulationParams( + models=[Fluid(), wall_1], + outputs=[ + fl.ForceOutput( + name="force_output", + models=[wall_1], + output_fields=["CL", "CD"], + moving_statistic=MovingStatistic( + method="mean", moving_window_size=100, start_step=200 + ), + ) + ], + ) def test_duplicate_probe_names(): From d05b38baf1abe113c73774b9ceb20057dbf50cc8 Mon Sep 17 00:00:00 2001 From: Angran Date: Mon, 17 Nov 2025 16:59:55 +0000 Subject: [PATCH 28/30] Add note for standard deviation computation --- flow360/component/simulation/outputs/outputs.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/flow360/component/simulation/outputs/outputs.py b/flow360/component/simulation/outputs/outputs.py index 8c1cb86e8..ad4683c65 100644 --- a/flow360/component/simulation/outputs/outputs.py +++ b/flow360/component/simulation/outputs/outputs.py @@ -142,6 +142,9 @@ class MovingStatistic(Flow360BaseModel): This means a :py:attr:`moving_window_size`=10 would cover 100 pseudo steps. Thus, the :py:attr:`start_step` value is automatically rounded up to the nearest multiple of 10 for steady simulations. + - When :py:attr:`method` is set to ``"std"``, the standard deviation is computed as a + **sample standard deviation** normalized by :math:`n-1` (Bessel's correction), where :math:`n` + is the number of data points in the moving window. Example ------- From 681ed25bc0bb36383215f982c672645a1f59b703 Mon Sep 17 00:00:00 2001 From: Angran Date: Tue, 18 Nov 2025 16:08:06 +0000 Subject: [PATCH 29/30] Add description to explain the use of tolerance window size in stopping criterion check --- .../component/simulation/run_control/stopping_criterion.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/flow360/component/simulation/run_control/stopping_criterion.py b/flow360/component/simulation/run_control/stopping_criterion.py index f13a72ca8..a04224333 100644 --- a/flow360/component/simulation/run_control/stopping_criterion.py +++ b/flow360/component/simulation/run_control/stopping_criterion.py @@ -76,8 +76,8 @@ class StoppingCriterion(Flow360BaseModel): ) tolerance_window_size: Optional[int] = pd.Field( None, - description="The number of data points from the monitor_output to be used " - "to check whether the deviation of the monitored field is below tolerance or not. " + description="The number of data points from the monitor_output to be used to check whether " + "the :math:`|max-min|/2` of the monitored field within this window is below tolerance or not. " "If not set, the criterion will directly compare the latest value with tolerance.", ge=2, ) From 6aec51ccfb7e524e7028969944e9e8d41e309caa Mon Sep 17 00:00:00 2001 From: Angran Date: Wed, 19 Nov 2025 19:08:17 +0000 Subject: [PATCH 30/30] Add stopping criterion support for imported surface integral --- .../translator/solver_translator.py | 28 +++++++++++++++++-- .../translator/ref/Flow360_user_variable.json | 3 +- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/flow360/component/simulation/translator/solver_translator.py b/flow360/component/simulation/translator/solver_translator.py index b5a437815..ac0ea3e29 100644 --- a/flow360/component/simulation/translator/solver_translator.py +++ b/flow360/component/simulation/translator/solver_translator.py @@ -1476,7 +1476,19 @@ def check_external_postprocessing_existence(params: SimulationParams): for output in params.outputs: if not isinstance(output, get_args(get_args(MonitorOutputType)[0])): continue - if not isinstance(output, ForceOutput) and output.moving_statistic is None: + if ( + isinstance(output, (ProbeOutput, SurfaceProbeOutput)) + and output.moving_statistic is None + ): + continue + if ( + isinstance(output, SurfaceIntegralOutput) + and output.moving_statistic is None + and all( + not isinstance(surface, ImportedSurface) + for surface in output.entities.stored_entities + ) + ): continue return True return False @@ -2006,7 +2018,19 @@ def get_columnar_data_processor_json( for output in input_params.outputs: if not isinstance(output, get_args(get_args(MonitorOutputType)[0])): continue - if not isinstance(output, ForceOutput) and output.moving_statistic is None: + if ( + isinstance(output, (ProbeOutput, SurfaceProbeOutput)) + and output.moving_statistic is None + ): + continue + if ( + isinstance(output, SurfaceIntegralOutput) + and output.moving_statistic is None + and all( + not isinstance(surface, ImportedSurface) + for surface in output.entities.stored_entities + ) + ): continue output_dict = output.model_dump( exclude_none=True, diff --git a/tests/simulation/translator/ref/Flow360_user_variable.json b/tests/simulation/translator/ref/Flow360_user_variable.json index b49c92a03..9f76bfaa0 100644 --- a/tests/simulation/translator/ref/Flow360_user_variable.json +++ b/tests/simulation/translator/ref/Flow360_user_variable.json @@ -369,6 +369,7 @@ } }, "runControl": { - "externalProcessMonitorOutput": false + "externalProcessMonitorOutput": true, + "monitorProcessorHash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" } } \ No newline at end of file