diff --git a/flow360/component/results/base_results.py b/flow360/component/results/base_results.py index 1a09154b7..cade1b70e 100644 --- a/flow360/component/results/base_results.py +++ b/flow360/component/results/base_results.py @@ -25,7 +25,7 @@ from flow360.component.simulation.models.surface_models import BoundaryBase from flow360.component.simulation.simulation_params import SimulationParams from flow360.component.v1.flow360_params import Flow360Params -from flow360.exceptions import Flow360ValueError +from flow360.exceptions import Flow360TypeError, Flow360ValueError from flow360.log import log # pylint: disable=consider-using-with @@ -753,3 +753,16 @@ def reload_data(self, filter_physical_steps_only: bool = False, include_time: bo filter_physical_steps_only=filter_physical_steps_only, include_time=include_time ) self._filtered_sum() + + +class LocalResultCSVModel(ResultCSVModel): + """ + CSV Model with no remote file that cannot be downloaded used for locally working with csv data + """ + + remote_file_name: Optional[str] = None + + def download( + self, to_file: str = None, to_folder: str = ".", overwrite: bool = False, **kwargs + ): + raise Flow360TypeError("Cannot download csv from LocalResultCSVModel") diff --git a/flow360/component/results/case_results.py b/flow360/component/results/case_results.py index f90cf359c..ecc1e3218 100644 --- a/flow360/component/results/case_results.py +++ b/flow360/component/results/case_results.py @@ -1,5 +1,7 @@ """Case results module""" +# pylint: disable=too-many-lines + from __future__ import annotations import re @@ -15,6 +17,7 @@ _PHYSICAL_STEP, _PSEUDO_STEP, _TIME, + LocalResultCSVModel, PerEntityResultCSVModel, ResultBaseModel, ResultCSVModel, @@ -32,6 +35,7 @@ _HEAT_FLUX, _X, _Y, + BETDiskCSVHeaderOperation, DiskCoefficientsComputation, PorousMediumCoefficientsComputation, _CFx, @@ -815,6 +819,26 @@ def to_base(self, base: str, params: Flow360Params = None): self.values["ForceUnits"] = bet.force_x.units self.values["MomentUnits"] = bet.moment_x.units + def format_headers( + self, params: SimulationParams, pattern: str = "$BETName_$CylinderName" + ) -> LocalResultCSVModel: + """ + Renames the header entries from Disk{i}_ to based on an input user pattern + such as $BETName_$CylinderName + Parameters + ---------- + params : SimulationParams + Simulation parameters + pattern : str + Pattern string to rename header entries. Available patterns + [$BETName, $CylinderName, $DiskLocalIndex, $DiskGlobalIndex] + Returns + ------- + LocalResultCSVModel + Model containing csv with updated header + """ + return BETDiskCSVHeaderOperation.format_headers(self, params, pattern) + def compute_coefficients(self, params: SimulationParams) -> BETDiskCoefficientsCSVModel: """ Compute disk coefficients from BET disk forces and moments. @@ -877,6 +901,26 @@ class BETDiskCoefficientsCSVModel(ResultCSVModel): remote_file_name: str = pd.Field("bet_disk_coefficients_v2.csv", frozen=True) + def format_headers( + self, params: SimulationParams, pattern: str = "$BETName_$CylinderName" + ) -> LocalResultCSVModel: + """ + Renames the header entries from Disk{i}_ to based on an input user pattern + such as $BETName_$CylinderName + Parameters + ---------- + params : SimulationParams + Simulation parameters + pattern : str + Pattern string to rename header entries. Available patterns + [$BETName, $CylinderName, $DiskLocalIndex, $DiskGlobalIndex] + Returns + ------- + LocalResultCSVModel + Model containing csv with updated header + """ + return BETDiskCSVHeaderOperation.format_headers(self, params, pattern) + class PorousMediumResultCSVModel(OptionallyDownloadableResultCSVModel): """Model for handling porous medium CSV results.""" @@ -953,3 +997,23 @@ class BETForcesRadialDistributionResultCSVModel(OptionallyDownloadableResultCSVM CaseDownloadable.BET_FORCES_RADIAL_DISTRIBUTION.value, frozen=True ) _err_msg = "Case does not have any BET disks." + + def format_headers( + self, params: SimulationParams, pattern: str = "$BETName_$CylinderName" + ) -> LocalResultCSVModel: + """ + Renames the header entries from Disk{i}_ to based on an input user pattern + such as $BETName_$CylinderName + Parameters + ---------- + params : SimulationParams + Simulation parameters + pattern : str + Pattern string to rename header entries. Available patterns + [$BETName, $CylinderName, $DiskLocalIndex, $DiskGlobalIndex] + Returns + ------- + LocalResultCSVModel + Model containing csv with updated header + """ + return BETDiskCSVHeaderOperation.format_headers(self, params, pattern) diff --git a/flow360/component/results/results_utils.py b/flow360/component/results/results_utils.py index fe201867c..232b6d3fb 100644 --- a/flow360/component/results/results_utils.py +++ b/flow360/component/results/results_utils.py @@ -7,7 +7,13 @@ import numpy as np -from flow360.component.results.base_results import _PHYSICAL_STEP, _PSEUDO_STEP +from flow360.component.results.base_results import ( + _PHYSICAL_STEP, + _PSEUDO_STEP, + LocalResultCSVModel, + ResultCSVModel, +) +from flow360.component.simulation.models.volume_models import BETDisk from flow360.component.simulation.simulation_params import SimulationParams from flow360.exceptions import Flow360ValueError from flow360.log import log @@ -410,3 +416,73 @@ def compute_coefficients_static( out[f"{zone_name}_{_CL}"].append(CL_val) return coefficients_model_class().from_dict(out) + + +class BETDiskCSVHeaderOperation: + # pylint:disable=too-few-public-methods + """ + Static utilities for renaming BET disk csv output headers to include the name of the BET disk. + + This class provides only static methods and should not be instantiated or subclassed. + All methods are self-contained and require explicit parameters. + """ + + @staticmethod + def format_headers( + BETCSVModel: ResultCSVModel, + params: SimulationParams, + pattern: str = "$BETName_$CylinderName", + ) -> LocalResultCSVModel: + """ + renames the header entries in a BET csv file from Disk{x}_ based on input pattern + $Default option is $BETName_$CylinderName + + pattern can take [$BETName, $CylinderName, $DiskLocalIndex, $DiskGlobalIndex] + Parameters + ---------- + BETCSVModel : ResultCSVModle + Model containing csv entries + params : SimulationParams + Simulation parameters + pattern : str + Pattern string to rename header entries. Available patterns + [$BETName, $CylinderName, $DiskLocalIndex, $DiskGlobalIndex] + Returns + ------- + LocalResultCSVModel + Model containing csv with updated header + """ + # pylint:disable=too-many-locals + bet_disks = [] + for model in params.models: + if isinstance(model, BETDisk): + bet_disks.append(model) + if not bet_disks: + raise ValueError("No BET Disks in params to rename header.") + + csv_data = BETCSVModel.values + new_csv = {} + + disk_rename_map = {} + + diskCount = 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 + + for header, values in csv_data.items(): + matched = False + for default_prefix, new_prefix in disk_rename_map.items(): + if header.startswith(default_prefix): + new_csv[new_prefix + header[len(default_prefix) :]] = values + matched = True + break + if not matched: + new_csv[header] = values + newModel = LocalResultCSVModel().from_dict(new_csv) + return newModel diff --git a/tests/simulation/results_processing/test_bet_disk_coefficients.py b/tests/simulation/results_processing/test_bet_disk_coefficients.py index bba945980..80edb5ed6 100644 --- a/tests/simulation/results_processing/test_bet_disk_coefficients.py +++ b/tests/simulation/results_processing/test_bet_disk_coefficients.py @@ -6,6 +6,7 @@ import flow360 as fl from flow360.component.results.case_results import BETForcesResultCSVModel from flow360.component.simulation.framework.param_utils import AssetCache +from flow360.component.simulation.models.volume_models import BETDisk from flow360.component.simulation.services import ValidationCalledBy, validate_model from .test_helpers import compute_freestream_direction, compute_lift_direction diff --git a/tests/simulation/results_processing/test_bet_disk_header_rename.py b/tests/simulation/results_processing/test_bet_disk_header_rename.py new file mode 100644 index 000000000..ceacbb0a6 --- /dev/null +++ b/tests/simulation/results_processing/test_bet_disk_header_rename.py @@ -0,0 +1,204 @@ +import json +import os + +import numpy as np + +import flow360 as fl +from flow360.component.results.case_results import BETForcesResultCSVModel +from flow360.component.simulation.framework.param_utils import AssetCache +from flow360.component.simulation.models.volume_models import BETDisk +from flow360.component.simulation.services import ValidationCalledBy, validate_model + +from .test_helpers import compute_freestream_direction, compute_lift_direction + + +def test_bet_disk_simple_header_rename(): + # Prepare a simple BET disk CSV with one timestep + csv_path = os.path.join( + os.path.dirname(__file__), + os.path.pardir, + "data", + "coeff_simple", + "results", + "bet_forces_v2.csv", + ) + csv_path = os.path.abspath(csv_path) + + # Simple params: liquid, explicit V_ref, nonzero alpha/beta, off-axis disk and offset center + alpha, beta = 5.0, 10.0 + axis_tuple = (2.0, 1.0, 1.0) + center_tuple = (0.5, -0.2, 0.3) + + with fl.SI_unit_system: + params = fl.SimulationParams( + reference_geometry=fl.ReferenceGeometry( + moment_center=(0, 0, 0) * fl.u.m, + moment_length=1 * fl.u.m, + area=2.0 * fl.u.m**2, + ), + operating_condition=fl.LiquidOperatingCondition( + velocity_magnitude=10 * fl.u.m / fl.u.s, + reference_velocity_magnitude=10 * fl.u.m / fl.u.s, + alpha=alpha * fl.u.deg, + beta=beta * fl.u.deg, + ), + models=[ + fl.BETDisk( + entities=fl.Cylinder( + name="bet_disk", + center=center_tuple * fl.u.m, + axis=axis_tuple, + height=1 * fl.u.m, + outer_radius=1.0 * fl.u.m, + ), + rotation_direction_rule="leftHand", + number_of_blades=3, + omega=100 * fl.u.rpm, + chord_ref=14 * fl.u.inch, + n_loading_nodes=20, + mach_numbers=[0], + reynolds_numbers=[1000000], + twists=[fl.BETDiskTwist(radius=0 * fl.u.inch, twist=0 * fl.u.deg)], + chords=[fl.BETDiskChord(radius=0 * fl.u.inch, chord=14 * fl.u.inch)], + alphas=[-2, 0, 2] * fl.u.deg, + sectional_radiuses=[13.5, 25.5] * fl.u.inch, + sectional_polars=[ + fl.BETDiskSectionalPolar( + lift_coeffs=[[[0.1, 0.2, 0.3]]], # 1 Mach x 1 Re x 3 alphas + drag_coeffs=[[[0.01, 0.02, 0.03]]], + ), + fl.BETDiskSectionalPolar( + lift_coeffs=[[[0.15, 0.25, 0.35]]], # 1 Mach x 1 Re x 3 alphas + drag_coeffs=[[[0.015, 0.025, 0.035]]], + ), + ], + ) + ], + private_attribute_asset_cache=AssetCache(project_length_unit=1 * fl.u.m), + ) + + model = BETForcesResultCSVModel() + model.load_from_local(csv_path) + old_data = model.as_dict() + + new_csv = model.format_headers(params=params, pattern="$BETName_$CylinderName") + new_data = new_csv.as_dict() + + assert "BET disk_bet_disk_Force_x" in new_data + assert "BET disk_bet_disk_Force_y" in new_data + assert "BET disk_bet_disk_Force_z" in new_data + assert "BET disk_bet_disk_Moment_x" in new_data + assert "BET disk_bet_disk_Moment_y" in new_data + assert "BET disk_bet_disk_Moment_z" in new_data + + for header_name, value in new_data.items(): + old_key = header_name.replace("BET disk_bet_disk", "Disk0") + new_value = value[0] + old_value = old_data[old_key][0] + assert np.isclose(new_value, old_value, rtol=1e-6, atol=1e-12) + + +def test_bet_disk_real_case_header_rename(): + """ + Test BETDisk coefficient computation with real case data. + + This test uses CSV data and parameters from an actual Flow360 simulation + to verify that coefficient computation works correctly with real-world data. + """ + # Load CSV file + csv_path = os.path.join( + os.path.dirname(__file__), + os.path.pardir, + "data", + "real_case_coefficients", + "results", + "bet_forces_v2.csv", + ) + csv_path = os.path.abspath(csv_path) + + # Load reference coefficients + ref_path = os.path.join( + os.path.dirname(__file__), + os.path.pardir, + "data", + "real_case_coefficients", + "results", + "reference_coefficients.json", + ) + ref_path = os.path.abspath(ref_path) + + with open(ref_path, "r") as f: + reference_data = json.load(f) + + bet_disk_refs = reference_data["BETDisk"] + + # Load simulation params from JSON + params_path = os.path.join( + os.path.dirname(__file__), + os.path.pardir, + "data", + "real_case_coefficients", + "results", + "simulation_params.json", + ) + params_path = os.path.abspath(params_path) + + with open(params_path, "r") as f: + params_json = f.read() + + params_as_dict = json.loads(params_json) + + params, errors, warnings = validate_model( + params_as_dict=params_as_dict, + validated_by=ValidationCalledBy.LOCAL, + root_item_type=None, + ) + + assert errors is None, f"Validation errors: {errors}" + assert params is not None + + # Load CSV and compute coefficients + model = BETForcesResultCSVModel() + model.load_from_local(csv_path) + old_data = model.as_dict() + + new_csv = model.format_headers(params=params) + + new_data = new_csv.as_dict() + + bet_disks = [] + for model in params.models: + if isinstance(model, BETDisk): + bet_disks.append(model) + assert bet_disks != [] + + diskCount = 0 + disk_rename_map = {} + for i, disk in enumerate(bet_disks): + for j, cylinder in enumerate(disk.entities.stored_entities): + disk_name = f"{disk.name}_{cylinder.name}" + disk_rename_map[f"Disk{diskCount}"] = f"{disk_name}" + diskCount = diskCount + 1 + + assert "physical_step" in new_data + assert "pseudo_step" in new_data + + for old_key, old_value in old_data.items(): + found = False + new_disk_key = None + for old_name, new_name in disk_rename_map.items(): + if old_name in old_key: + found = True + new_disk_key = old_key.replace(old_name, new_name) + break + if not found: + new_disk_key = old_key + + assert new_disk_key in new_data + + new_value = new_data[new_disk_key] + + assert len(old_value) == len(new_value) + + for i in range(len(old_value)): + np.isclose(old_value[i], new_value[i], rtol=1e-6, atol=1e-12)