From 64988cd0b9a83c7899e1858f92d362b5f40908fb Mon Sep 17 00:00:00 2001 From: benflexcompute Date: Fri, 27 Mar 2026 15:04:05 -0400 Subject: [PATCH 1/2] feat(BETDisk): add collective_pitch field for uniform twist offset Adds an optional `collective_pitch` angle to BETDisk that applies a uniform offset to all blade twist values during solver translation. This allows users to adjust rotor collective pitch directly without modifying BET source files. The field defaults to None and is excluded from serialized JSON via exclude_none, preserving forward compatibility with older SDK versions. Ref: SCFD-7370 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../simulation/models/volume_models.py | 5 ++ .../translator/solver_translator.py | 7 ++- .../translator/test_betdisk_translation.py | 53 +++++++++++++++++++ 3 files changed, 64 insertions(+), 1 deletion(-) diff --git a/flow360/component/simulation/models/volume_models.py b/flow360/component/simulation/models/volume_models.py index 9be0a9b13..65222159a 100644 --- a/flow360/component/simulation/models/volume_models.py +++ b/flow360/component/simulation/models/volume_models.py @@ -819,6 +819,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 " diff --git a/flow360/component/simulation/translator/solver_translator.py b/flow360/component/simulation/translator/solver_translator.py index c85692882..2023dd615 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/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 From fcecb67e2dd9b272e22643a041bdae742f715f16 Mon Sep 17 00:00:00 2001 From: benflexcompute Date: Fri, 27 Mar 2026 15:32:08 -0400 Subject: [PATCH 2/2] feat(BETDisk): wire collective_pitch through constructors and input cache Ensure collective_pitch persists through multi-constructor round-trips (from_xrotor, from_c81, from_dfdc, from_xfoil) by adding it to: - BETDiskCache - _update_input_cache field validator list - All from_* constructor signatures Updates ref JSON files to include the new nullable field. Ref: SCFD-7370 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../simulation/models/volume_models.py | 22 +++++++++++++++ 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 +++++++++++++++++++ 7 files changed, 56 insertions(+) diff --git a/flow360/component/simulation/models/volume_models.py b/flow360/component/simulation/models/volume_models.py index 65222159a..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): @@ -917,6 +918,7 @@ def check_bet_disk_3d_coefficients_in_polars(self): "number_of_blades", "entities", "initial_blade_direction", + "collective_pitch", mode="after", ) @classmethod @@ -1014,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. @@ -1043,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 @@ -1082,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) @@ -1100,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. @@ -1127,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 @@ -1163,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) @@ -1182,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. @@ -1211,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 @@ -1252,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) @@ -1270,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. @@ -1297,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 @@ -1333,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/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.