Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions flow360/component/simulation/models/volume_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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 "
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down
7 changes: 6 additions & 1 deletion flow360/component/simulation/translator/solver_translator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
]
Expand Down
1 change: 1 addition & 0 deletions tests/simulation/converter/ref/ref_c81.json
Original file line number Diff line number Diff line change
Expand Up @@ -744,6 +744,7 @@
}
}
],
"collective_pitch": null,
"entities": {
"selectors": null,
"stored_entities": [
Expand Down
1 change: 1 addition & 0 deletions tests/simulation/converter/ref/ref_dfdc.json
Original file line number Diff line number Diff line change
Expand Up @@ -1041,6 +1041,7 @@
}
}
],
"collective_pitch": null,
"entities": {
"selectors": null,
"stored_entities": [
Expand Down
2 changes: 2 additions & 0 deletions tests/simulation/converter/ref/ref_single_bet_disk.json
Original file line number Diff line number Diff line change
Expand Up @@ -415,6 +415,7 @@
}
}
],
"collective_pitch": null,
"entities": {
"selectors": null,
"stored_entities": [
Expand Down Expand Up @@ -472,6 +473,7 @@
"units": "cm",
"value": 14.0
},
"collective_pitch": null,
"entities": {
"selectors": null,
"stored_entities": [
Expand Down
1 change: 1 addition & 0 deletions tests/simulation/converter/ref/ref_xfoil.json
Original file line number Diff line number Diff line change
Expand Up @@ -745,6 +745,7 @@
}
}
],
"collective_pitch": null,
"entities": {
"selectors": null,
"stored_entities": [
Expand Down
1 change: 1 addition & 0 deletions tests/simulation/converter/ref/ref_xrotor.json
Original file line number Diff line number Diff line change
Expand Up @@ -1041,6 +1041,7 @@
}
}
],
"collective_pitch": null,
"entities": {
"selectors": null,
"stored_entities": [
Expand Down
28 changes: 28 additions & 0 deletions tests/simulation/converter/test_bet_translator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
53 changes: 53 additions & 0 deletions tests/simulation/translator/test_betdisk_translation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading