Skip to content

Commit 8e75294

Browse files
benflexcomputeclaude
authored andcommitted
feat(BETDisk): add collective_pitch field [SCFD-7370] (#1938)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 92ac3de commit 8e75294

9 files changed

Lines changed: 120 additions & 1 deletion

File tree

flow360/component/simulation/models/volume_models.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -748,6 +748,7 @@ class BETDiskCache(Flow360BaseModel):
748748
number_of_blades: Optional[pd.StrictInt] = None
749749
initial_blade_direction: Optional[Axis] = None
750750
blade_line_chord: Optional[LengthType.NonNegative] = None
751+
collective_pitch: Optional[AngleType] = None
751752

752753

753754
class BETDisk(MultiConstructorBaseModel):
@@ -819,6 +820,11 @@ class BETDisk(MultiConstructorBaseModel):
819820
+ "Must be orthogonal to the rotation axis (Cylinder.axis). Only the direction is used—the "
820821
+ "vector need not be unit length. Must be specified for unsteady BET Line (blade_line_chord > 0).",
821822
)
823+
collective_pitch: Optional[AngleType] = pd.Field(
824+
None,
825+
description="Collective pitch angle applied as a uniform offset to all blade twist values. "
826+
+ "Positive value increases the angle of attack at every radial station.",
827+
)
822828
tip_gap: Union[Literal["inf"], LengthType.NonNegative] = pd.Field(
823829
"inf",
824830
description="Dimensional distance between blade tip and solid bodies to "
@@ -912,6 +918,7 @@ def check_bet_disk_3d_coefficients_in_polars(self):
912918
"number_of_blades",
913919
"entities",
914920
"initial_blade_direction",
921+
"collective_pitch",
915922
mode="after",
916923
)
917924
@classmethod
@@ -1009,6 +1016,7 @@ def from_c81(
10091016
angle_unit: AngleType,
10101017
initial_blade_direction: Optional[Axis] = None,
10111018
blade_line_chord: LengthType.NonNegative = 0 * u.m,
1019+
collective_pitch: Optional[AngleType] = None,
10121020
name: str = "BET disk",
10131021
):
10141022
"""Constructs a :class: `BETDisk` instance from a given C81 file and additional inputs.
@@ -1038,6 +1046,8 @@ def from_c81(
10381046
Only direction matters (need not be a unit vector). Required for unsteady BET Line.
10391047
blade_line_chord: LengthType.NonNegative
10401048
Dimensional chord used in unsteady BET simulation. Defaults to ``0 * u.m``.
1049+
collective_pitch: AngleType, optional
1050+
Collective pitch angle applied as a uniform offset to all blade twist values.
10411051
10421052
10431053
Returns
@@ -1077,6 +1087,8 @@ def from_c81(
10771087
number_of_blades=number_of_blades,
10781088
name=name,
10791089
)
1090+
if collective_pitch is not None:
1091+
params["collective_pitch"] = collective_pitch
10801092

10811093
return cls(**params)
10821094

@@ -1095,6 +1107,7 @@ def from_dfdc(
10951107
angle_unit: AngleType,
10961108
initial_blade_direction: Optional[Axis] = None,
10971109
blade_line_chord: LengthType.NonNegative = 0 * u.m,
1110+
collective_pitch: Optional[AngleType] = None,
10981111
name: str = "BET disk",
10991112
):
11001113
"""Constructs a :class: `BETDisk` instance from a given DFDC file and additional inputs.
@@ -1122,6 +1135,8 @@ def from_dfdc(
11221135
Only direction matters (need not be a unit vector). Required for unsteady BET Line.
11231136
blade_line_chord: LengthType.NonNegative
11241137
Dimensional chord used in unsteady BET simulation. Defaults to ``0 * u.m``.
1138+
collective_pitch: AngleType, optional
1139+
Collective pitch angle applied as a uniform offset to all blade twist values.
11251140
11261141
11271142
Returns
@@ -1158,6 +1173,8 @@ def from_dfdc(
11581173
length_unit=length_unit,
11591174
name=name,
11601175
)
1176+
if collective_pitch is not None:
1177+
params["collective_pitch"] = collective_pitch
11611178

11621179
return cls(**params)
11631180

@@ -1177,6 +1194,7 @@ def from_xfoil(
11771194
number_of_blades: pd.StrictInt,
11781195
initial_blade_direction: Optional[Axis],
11791196
blade_line_chord: LengthType.NonNegative = 0 * u.m,
1197+
collective_pitch: Optional[AngleType] = None,
11801198
name: str = "BET disk",
11811199
):
11821200
"""Constructs a :class: `BETDisk` instance from a given XROTOR file and additional inputs.
@@ -1206,6 +1224,8 @@ def from_xfoil(
12061224
Only direction matters (need not be a unit vector). Required for unsteady BET Line.
12071225
blade_line_chord: LengthType.NonNegative
12081226
Dimensional chord used in unsteady BET simulation. Defaults to ``0 * u.m``.
1227+
collective_pitch: AngleType, optional
1228+
Collective pitch angle applied as a uniform offset to all blade twist values.
12091229
12101230
12111231
Returns
@@ -1247,6 +1267,8 @@ def from_xfoil(
12471267
number_of_blades=number_of_blades,
12481268
name=name,
12491269
)
1270+
if collective_pitch is not None:
1271+
params["collective_pitch"] = collective_pitch
12501272

12511273
return cls(**params)
12521274

@@ -1265,6 +1287,7 @@ def from_xrotor(
12651287
angle_unit: AngleType,
12661288
initial_blade_direction: Optional[Axis] = None,
12671289
blade_line_chord: LengthType.NonNegative = 0 * u.m,
1290+
collective_pitch: Optional[AngleType] = None,
12681291
name: str = "BET disk",
12691292
):
12701293
"""Constructs a :class: `BETDisk` instance from a given XROTOR file and additional inputs.
@@ -1292,6 +1315,8 @@ def from_xrotor(
12921315
Only direction matters (need not be a unit vector). Required for unsteady BET Line.
12931316
blade_line_chord: LengthType.NonNegative
12941317
Dimensional chord used in unsteady BET simulation. Defaults to ``0 * u.m``.
1318+
collective_pitch: AngleType, optional
1319+
Collective pitch angle applied as a uniform offset to all blade twist values.
12951320
12961321
12971322
Returns
@@ -1328,6 +1353,8 @@ def from_xrotor(
13281353
length_unit=length_unit,
13291354
name=name,
13301355
)
1356+
if collective_pitch is not None:
1357+
params["collective_pitch"] = collective_pitch
13311358

13321359
return cls(**params)
13331360

flow360/component/simulation/translator/solver_translator.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1367,10 +1367,15 @@ def bet_disk_translator(model: BETDisk, is_unsteady: bool):
13671367
"""BET disk translator"""
13681368
model_dict = convert_tuples_to_lists(remove_units_in_dict(dump_dict(model)))
13691369
model_dict["alphas"] = [alpha.to("degree").value.item() for alpha in model.alphas]
1370+
collective_pitch_deg = (
1371+
model.collective_pitch.to("degree").value.item()
1372+
if model.collective_pitch is not None
1373+
else 0
1374+
)
13701375
model_dict["twists"] = [
13711376
{
13721377
"radius": bet_twist.radius.value.item(),
1373-
"twist": bet_twist.twist.to("degree").value.item(),
1378+
"twist": bet_twist.twist.to("degree").value.item() + collective_pitch_deg,
13741379
}
13751380
for bet_twist in model.twists
13761381
]

tests/simulation/converter/ref/ref_c81.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -744,6 +744,7 @@
744744
}
745745
}
746746
],
747+
"collective_pitch": null,
747748
"entities": {
748749
"selectors": null,
749750
"stored_entities": [

tests/simulation/converter/ref/ref_dfdc.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1041,6 +1041,7 @@
10411041
}
10421042
}
10431043
],
1044+
"collective_pitch": null,
10441045
"entities": {
10451046
"selectors": null,
10461047
"stored_entities": [

tests/simulation/converter/ref/ref_single_bet_disk.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -415,6 +415,7 @@
415415
}
416416
}
417417
],
418+
"collective_pitch": null,
418419
"entities": {
419420
"selectors": null,
420421
"stored_entities": [
@@ -472,6 +473,7 @@
472473
"units": "cm",
473474
"value": 14.0
474475
},
476+
"collective_pitch": null,
475477
"entities": {
476478
"selectors": null,
477479
"stored_entities": [

tests/simulation/converter/ref/ref_xfoil.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -745,6 +745,7 @@
745745
}
746746
}
747747
],
748+
"collective_pitch": null,
748749
"entities": {
749750
"selectors": null,
750751
"stored_entities": [

tests/simulation/converter/ref/ref_xrotor.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1041,6 +1041,7 @@
10411041
}
10421042
}
10431043
],
1044+
"collective_pitch": null,
10441045
"entities": {
10451046
"selectors": null,
10461047
"stored_entities": [

tests/simulation/converter/test_bet_translator.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -484,6 +484,34 @@ def test_xfoil_params():
484484
assertions.assertEqual(refbetFlow360["radius"], bet.entities.stored_entities[0].outer_radius)
485485

486486

487+
def test_collective_pitch_persists_through_from_xrotor():
488+
"""collective_pitch set via from_xrotor should survive input cache round-trip."""
489+
with fl.SI_unit_system:
490+
bet_cylinder = fl.Cylinder(
491+
name="BET_cylinder", center=[0, 0, 0], axis=[0, 0, 1], outer_radius=3.81, height=15
492+
)
493+
prepending_path = os.path.dirname(os.path.abspath(__file__))
494+
disk = fl.BETDisk.from_xrotor(
495+
file=fl.XROTORFile(
496+
file_path=os.path.join(prepending_path, "data", "xv15_like_twist0.xrotor")
497+
),
498+
rotation_direction_rule="leftHand",
499+
omega=0.0046 * fl.u.deg / fl.u.s,
500+
chord_ref=14 * fl.u.m,
501+
n_loading_nodes=20,
502+
entities=bet_cylinder,
503+
angle_unit=fl.u.deg,
504+
length_unit=fl.u.m,
505+
collective_pitch=5 * fl.u.deg,
506+
)
507+
assert disk.collective_pitch is not None
508+
assert disk.collective_pitch.to("degree").value.item() == pytest.approx(5.0)
509+
510+
# Verify it's in the input cache
511+
cache = disk.private_attribute_input_cache
512+
assert cache.collective_pitch is not None
513+
514+
487515
def test_file_model():
488516
"""
489517
Test the C81File model's construction, immutability, and serialization.

tests/simulation/translator/test_betdisk_translation.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,3 +70,56 @@ def test_betdisk_steady_excludes_internal_fields():
7070

7171
assert "initialBladeDirection" not in bet_item
7272
assert "bladeLineChord" in bet_item and bet_item["bladeLineChord"] == 0
73+
74+
75+
def test_betdisk_collective_pitch_offsets_twists():
76+
"""collective_pitch should be added to every twist value in translated output."""
77+
rpm = 588.50450
78+
params = create_param_base()
79+
bet_disk_no_pitch = createBETDiskSteady(
80+
cylinder_entity=_create_test_cylinder(), pitch_in_degree=0, rpm=rpm
81+
)
82+
bet_disk_with_pitch = bet_disk_no_pitch.model_copy(update={"collective_pitch": 5 * u.deg})
83+
84+
params.models.append(bet_disk_no_pitch)
85+
params.time_stepping = createSteadyTimeStepping()
86+
translated_no_pitch = get_solver_json(params, mesh_unit=1 * u.inch)
87+
88+
params.models = [m for m in params.models if not isinstance(m, type(bet_disk_no_pitch))]
89+
params.models.append(bet_disk_with_pitch)
90+
translated_with_pitch = get_solver_json(params, mesh_unit=1 * u.inch)
91+
92+
twists_no_pitch = translated_no_pitch["BETDisks"][0]["twists"]
93+
twists_with_pitch = translated_with_pitch["BETDisks"][0]["twists"]
94+
95+
for original, offset in zip(twists_no_pitch, twists_with_pitch):
96+
assert offset["twist"] == pytest.approx(original["twist"] + 5.0)
97+
assert offset["radius"] == original["radius"]
98+
99+
100+
def test_betdisk_collective_pitch_none_matches_zero():
101+
"""collective_pitch=None should produce identical output to no pitch offset."""
102+
rpm = 588.50450
103+
params = create_param_base()
104+
bet_disk = createBETDiskSteady(
105+
cylinder_entity=_create_test_cylinder(), pitch_in_degree=0, rpm=rpm
106+
)
107+
assert bet_disk.collective_pitch is None
108+
109+
params.models.append(bet_disk)
110+
params.time_stepping = createSteadyTimeStepping()
111+
translated = get_solver_json(params, mesh_unit=1 * u.inch)
112+
113+
twists = translated["BETDisks"][0]["twists"]
114+
assert "collectivePitch" not in translated["BETDisks"][0]
115+
assert len(twists) > 0
116+
117+
118+
def test_betdisk_collective_pitch_excluded_from_serialization_when_none():
119+
"""collective_pitch=None should not appear in serialized JSON (exclude_none)."""
120+
bet_disk = createBETDiskSteady(
121+
cylinder_entity=_create_test_cylinder(), pitch_in_degree=0, rpm=588.0
122+
)
123+
dumped = bet_disk.model_dump(exclude_none=True)
124+
assert "collectivePitch" not in dumped
125+
assert "collective_pitch" not in dumped

0 commit comments

Comments
 (0)