From 8e75294206d74346a477c0c98d34fce41ea13caf Mon Sep 17 00:00:00 2001 From: Ben <106089368+benflexcompute@users.noreply.github.com> Date: Fri, 27 Mar 2026 15:54:43 -0400 Subject: [PATCH] feat(BETDisk): add collective_pitch field [SCFD-7370] (#1938) Co-authored-by: Claude Opus 4.6 (1M context) --- .../simulation/models/volume_models.py | 27 ++++++++++ .../translator/solver_translator.py | 7 ++- tests/simulation/converter/ref/ref_c81.json | 1 + tests/simulation/converter/ref/ref_dfdc.json | 1 + .../converter/ref/ref_single_bet_disk.json | 2 + tests/simulation/converter/ref/ref_xfoil.json | 1 + .../simulation/converter/ref/ref_xrotor.json | 1 + .../converter/test_bet_translator.py | 28 ++++++++++ .../translator/test_betdisk_translation.py | 53 +++++++++++++++++++ 9 files changed, 120 insertions(+), 1 deletion(-) diff --git a/flow360/component/simulation/models/volume_models.py b/flow360/component/simulation/models/volume_models.py index 9be0a9b13..5c71dbb43 100644 --- a/flow360/component/simulation/models/volume_models.py +++ b/flow360/component/simulation/models/volume_models.py @@ -748,6 +748,7 @@ class BETDiskCache(Flow360BaseModel): number_of_blades: Optional[pd.StrictInt] = None initial_blade_direction: Optional[Axis] = None blade_line_chord: Optional[LengthType.NonNegative] = None + collective_pitch: Optional[AngleType] = None class BETDisk(MultiConstructorBaseModel): @@ -819,6 +820,11 @@ class BETDisk(MultiConstructorBaseModel): + "Must be orthogonal to the rotation axis (Cylinder.axis). Only the direction is used—the " + "vector need not be unit length. Must be specified for unsteady BET Line (blade_line_chord > 0).", ) + collective_pitch: Optional[AngleType] = pd.Field( + None, + description="Collective pitch angle applied as a uniform offset to all blade twist values. " + + "Positive value increases the angle of attack at every radial station.", + ) tip_gap: Union[Literal["inf"], LengthType.NonNegative] = pd.Field( "inf", description="Dimensional distance between blade tip and solid bodies to " @@ -912,6 +918,7 @@ def check_bet_disk_3d_coefficients_in_polars(self): "number_of_blades", "entities", "initial_blade_direction", + "collective_pitch", mode="after", ) @classmethod @@ -1009,6 +1016,7 @@ def from_c81( angle_unit: AngleType, initial_blade_direction: Optional[Axis] = None, blade_line_chord: LengthType.NonNegative = 0 * u.m, + collective_pitch: Optional[AngleType] = None, name: str = "BET disk", ): """Constructs a :class: `BETDisk` instance from a given C81 file and additional inputs. @@ -1038,6 +1046,8 @@ def from_c81( Only direction matters (need not be a unit vector). Required for unsteady BET Line. blade_line_chord: LengthType.NonNegative Dimensional chord used in unsteady BET simulation. Defaults to ``0 * u.m``. + collective_pitch: AngleType, optional + Collective pitch angle applied as a uniform offset to all blade twist values. Returns @@ -1077,6 +1087,8 @@ def from_c81( number_of_blades=number_of_blades, name=name, ) + if collective_pitch is not None: + params["collective_pitch"] = collective_pitch return cls(**params) @@ -1095,6 +1107,7 @@ def from_dfdc( angle_unit: AngleType, initial_blade_direction: Optional[Axis] = None, blade_line_chord: LengthType.NonNegative = 0 * u.m, + collective_pitch: Optional[AngleType] = None, name: str = "BET disk", ): """Constructs a :class: `BETDisk` instance from a given DFDC file and additional inputs. @@ -1122,6 +1135,8 @@ def from_dfdc( Only direction matters (need not be a unit vector). Required for unsteady BET Line. blade_line_chord: LengthType.NonNegative Dimensional chord used in unsteady BET simulation. Defaults to ``0 * u.m``. + collective_pitch: AngleType, optional + Collective pitch angle applied as a uniform offset to all blade twist values. Returns @@ -1158,6 +1173,8 @@ def from_dfdc( length_unit=length_unit, name=name, ) + if collective_pitch is not None: + params["collective_pitch"] = collective_pitch return cls(**params) @@ -1177,6 +1194,7 @@ def from_xfoil( number_of_blades: pd.StrictInt, initial_blade_direction: Optional[Axis], blade_line_chord: LengthType.NonNegative = 0 * u.m, + collective_pitch: Optional[AngleType] = None, name: str = "BET disk", ): """Constructs a :class: `BETDisk` instance from a given XROTOR file and additional inputs. @@ -1206,6 +1224,8 @@ def from_xfoil( Only direction matters (need not be a unit vector). Required for unsteady BET Line. blade_line_chord: LengthType.NonNegative Dimensional chord used in unsteady BET simulation. Defaults to ``0 * u.m``. + collective_pitch: AngleType, optional + Collective pitch angle applied as a uniform offset to all blade twist values. Returns @@ -1247,6 +1267,8 @@ def from_xfoil( number_of_blades=number_of_blades, name=name, ) + if collective_pitch is not None: + params["collective_pitch"] = collective_pitch return cls(**params) @@ -1265,6 +1287,7 @@ def from_xrotor( angle_unit: AngleType, initial_blade_direction: Optional[Axis] = None, blade_line_chord: LengthType.NonNegative = 0 * u.m, + collective_pitch: Optional[AngleType] = None, name: str = "BET disk", ): """Constructs a :class: `BETDisk` instance from a given XROTOR file and additional inputs. @@ -1292,6 +1315,8 @@ def from_xrotor( Only direction matters (need not be a unit vector). Required for unsteady BET Line. blade_line_chord: LengthType.NonNegative Dimensional chord used in unsteady BET simulation. Defaults to ``0 * u.m``. + collective_pitch: AngleType, optional + Collective pitch angle applied as a uniform offset to all blade twist values. Returns @@ -1328,6 +1353,8 @@ def from_xrotor( length_unit=length_unit, name=name, ) + if collective_pitch is not None: + params["collective_pitch"] = collective_pitch return cls(**params) diff --git a/flow360/component/simulation/translator/solver_translator.py b/flow360/component/simulation/translator/solver_translator.py index e994c6bb7..0fc97fa29 100644 --- a/flow360/component/simulation/translator/solver_translator.py +++ b/flow360/component/simulation/translator/solver_translator.py @@ -1367,10 +1367,15 @@ def bet_disk_translator(model: BETDisk, is_unsteady: bool): """BET disk translator""" model_dict = convert_tuples_to_lists(remove_units_in_dict(dump_dict(model))) model_dict["alphas"] = [alpha.to("degree").value.item() for alpha in model.alphas] + collective_pitch_deg = ( + model.collective_pitch.to("degree").value.item() + if model.collective_pitch is not None + else 0 + ) model_dict["twists"] = [ { "radius": bet_twist.radius.value.item(), - "twist": bet_twist.twist.to("degree").value.item(), + "twist": bet_twist.twist.to("degree").value.item() + collective_pitch_deg, } for bet_twist in model.twists ] diff --git a/tests/simulation/converter/ref/ref_c81.json b/tests/simulation/converter/ref/ref_c81.json index 749476624..cd6249d1d 100644 --- a/tests/simulation/converter/ref/ref_c81.json +++ b/tests/simulation/converter/ref/ref_c81.json @@ -744,6 +744,7 @@ } } ], + "collective_pitch": null, "entities": { "selectors": null, "stored_entities": [ diff --git a/tests/simulation/converter/ref/ref_dfdc.json b/tests/simulation/converter/ref/ref_dfdc.json index 7ab783207..f3dba0060 100644 --- a/tests/simulation/converter/ref/ref_dfdc.json +++ b/tests/simulation/converter/ref/ref_dfdc.json @@ -1041,6 +1041,7 @@ } } ], + "collective_pitch": null, "entities": { "selectors": null, "stored_entities": [ diff --git a/tests/simulation/converter/ref/ref_single_bet_disk.json b/tests/simulation/converter/ref/ref_single_bet_disk.json index 595681a97..f806c977f 100644 --- a/tests/simulation/converter/ref/ref_single_bet_disk.json +++ b/tests/simulation/converter/ref/ref_single_bet_disk.json @@ -415,6 +415,7 @@ } } ], + "collective_pitch": null, "entities": { "selectors": null, "stored_entities": [ @@ -472,6 +473,7 @@ "units": "cm", "value": 14.0 }, + "collective_pitch": null, "entities": { "selectors": null, "stored_entities": [ diff --git a/tests/simulation/converter/ref/ref_xfoil.json b/tests/simulation/converter/ref/ref_xfoil.json index 70176204f..83b9daabe 100644 --- a/tests/simulation/converter/ref/ref_xfoil.json +++ b/tests/simulation/converter/ref/ref_xfoil.json @@ -745,6 +745,7 @@ } } ], + "collective_pitch": null, "entities": { "selectors": null, "stored_entities": [ diff --git a/tests/simulation/converter/ref/ref_xrotor.json b/tests/simulation/converter/ref/ref_xrotor.json index 3e2b8b128..13f8fb4d6 100644 --- a/tests/simulation/converter/ref/ref_xrotor.json +++ b/tests/simulation/converter/ref/ref_xrotor.json @@ -1041,6 +1041,7 @@ } } ], + "collective_pitch": null, "entities": { "selectors": null, "stored_entities": [ diff --git a/tests/simulation/converter/test_bet_translator.py b/tests/simulation/converter/test_bet_translator.py index 08a9ff696..e7e691d8a 100644 --- a/tests/simulation/converter/test_bet_translator.py +++ b/tests/simulation/converter/test_bet_translator.py @@ -484,6 +484,34 @@ def test_xfoil_params(): assertions.assertEqual(refbetFlow360["radius"], bet.entities.stored_entities[0].outer_radius) +def test_collective_pitch_persists_through_from_xrotor(): + """collective_pitch set via from_xrotor should survive input cache round-trip.""" + with fl.SI_unit_system: + bet_cylinder = fl.Cylinder( + name="BET_cylinder", center=[0, 0, 0], axis=[0, 0, 1], outer_radius=3.81, height=15 + ) + prepending_path = os.path.dirname(os.path.abspath(__file__)) + disk = fl.BETDisk.from_xrotor( + file=fl.XROTORFile( + file_path=os.path.join(prepending_path, "data", "xv15_like_twist0.xrotor") + ), + rotation_direction_rule="leftHand", + omega=0.0046 * fl.u.deg / fl.u.s, + chord_ref=14 * fl.u.m, + n_loading_nodes=20, + entities=bet_cylinder, + angle_unit=fl.u.deg, + length_unit=fl.u.m, + collective_pitch=5 * fl.u.deg, + ) + assert disk.collective_pitch is not None + assert disk.collective_pitch.to("degree").value.item() == pytest.approx(5.0) + + # Verify it's in the input cache + cache = disk.private_attribute_input_cache + assert cache.collective_pitch is not None + + def test_file_model(): """ Test the C81File model's construction, immutability, and serialization. diff --git a/tests/simulation/translator/test_betdisk_translation.py b/tests/simulation/translator/test_betdisk_translation.py index d0e035704..344a45cf7 100644 --- a/tests/simulation/translator/test_betdisk_translation.py +++ b/tests/simulation/translator/test_betdisk_translation.py @@ -70,3 +70,56 @@ def test_betdisk_steady_excludes_internal_fields(): assert "initialBladeDirection" not in bet_item assert "bladeLineChord" in bet_item and bet_item["bladeLineChord"] == 0 + + +def test_betdisk_collective_pitch_offsets_twists(): + """collective_pitch should be added to every twist value in translated output.""" + rpm = 588.50450 + params = create_param_base() + bet_disk_no_pitch = createBETDiskSteady( + cylinder_entity=_create_test_cylinder(), pitch_in_degree=0, rpm=rpm + ) + bet_disk_with_pitch = bet_disk_no_pitch.model_copy(update={"collective_pitch": 5 * u.deg}) + + params.models.append(bet_disk_no_pitch) + params.time_stepping = createSteadyTimeStepping() + translated_no_pitch = get_solver_json(params, mesh_unit=1 * u.inch) + + params.models = [m for m in params.models if not isinstance(m, type(bet_disk_no_pitch))] + params.models.append(bet_disk_with_pitch) + translated_with_pitch = get_solver_json(params, mesh_unit=1 * u.inch) + + twists_no_pitch = translated_no_pitch["BETDisks"][0]["twists"] + twists_with_pitch = translated_with_pitch["BETDisks"][0]["twists"] + + for original, offset in zip(twists_no_pitch, twists_with_pitch): + assert offset["twist"] == pytest.approx(original["twist"] + 5.0) + assert offset["radius"] == original["radius"] + + +def test_betdisk_collective_pitch_none_matches_zero(): + """collective_pitch=None should produce identical output to no pitch offset.""" + rpm = 588.50450 + params = create_param_base() + bet_disk = createBETDiskSteady( + cylinder_entity=_create_test_cylinder(), pitch_in_degree=0, rpm=rpm + ) + assert bet_disk.collective_pitch is None + + params.models.append(bet_disk) + params.time_stepping = createSteadyTimeStepping() + translated = get_solver_json(params, mesh_unit=1 * u.inch) + + twists = translated["BETDisks"][0]["twists"] + assert "collectivePitch" not in translated["BETDisks"][0] + assert len(twists) > 0 + + +def test_betdisk_collective_pitch_excluded_from_serialization_when_none(): + """collective_pitch=None should not appear in serialized JSON (exclude_none).""" + bet_disk = createBETDiskSteady( + cylinder_entity=_create_test_cylinder(), pitch_in_degree=0, rpm=588.0 + ) + dumped = bet_disk.model_dump(exclude_none=True) + assert "collectivePitch" not in dumped + assert "collective_pitch" not in dumped