Skip to content

Commit 9f0f718

Browse files
committed
[FXC-4804] Support surface selection for custom force distribution
1 parent cbf14cd commit 9f0f718

File tree

5 files changed

+319
-3
lines changed

5 files changed

+319
-3
lines changed

flow360/component/simulation/outputs/outputs.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1661,12 +1661,23 @@ class ForceDistributionOutput(Flow360BaseModel):
16611661
Example
16621662
-------
16631663
1664+
Basic usage with default settings (all wall surfaces):
16641665
16651666
>>> fl.ForceDistributionOutput(
16661667
... name="spanwise",
16671668
... distribution_direction=[0.1, 0.9, 0.0],
16681669
... )
16691670
1671+
Specifying specific surfaces to include in the force integration (useful for automotive cases
1672+
to exclude road/floor surfaces):
1673+
1674+
>>> fl.ForceDistributionOutput(
1675+
... name="vehicle_x_distribution",
1676+
... distribution_direction=[1.0, 0.0, 0.0],
1677+
... entities=[volume_mesh["vehicle_body"], volume_mesh["wheels"]],
1678+
... number_of_segments=500,
1679+
... )
1680+
16701681
====
16711682
"""
16721683

@@ -1677,10 +1688,29 @@ class ForceDistributionOutput(Flow360BaseModel):
16771688
distribution_type: Literal["incremental", "cumulative"] = pd.Field(
16781689
"incremental", description="Type of the distribution."
16791690
)
1691+
entities: Optional[EntityList[Surface, MirroredSurface]] = pd.Field(
1692+
None,
1693+
alias="surfaces",
1694+
description="List of surfaces to include in the force integration. "
1695+
"If not specified, all wall surfaces are included. "
1696+
"This is useful for automotive cases to exclude road/floor surfaces.",
1697+
)
1698+
number_of_segments: Optional[pd.PositiveInt] = pd.Field(
1699+
None,
1700+
description="Number of segments (bins) to use along the distribution direction. "
1701+
"If not specified, the default of 300 segments is used. "
1702+
"Increasing this value provides higher resolution in the force distribution plot.",
1703+
)
16801704
output_type: Literal["ForceDistributionOutput"] = pd.Field(
16811705
"ForceDistributionOutput", frozen=True
16821706
)
16831707

1708+
@contextual_field_validator("entities", mode="after")
1709+
@classmethod
1710+
def ensure_surface_existence(cls, value, param_info: ParamsValidationInfo):
1711+
"""Ensure all boundaries will be present after mesher"""
1712+
return validate_entity_list_surface_existence(value, param_info)
1713+
16841714

16851715
class TimeAverageForceDistributionOutput(ForceDistributionOutput):
16861716
"""
@@ -1698,6 +1728,17 @@ class TimeAverageForceDistributionOutput(ForceDistributionOutput):
16981728
... start_step=4,
16991729
... )
17001730
1731+
Specifying specific surfaces to include in the force integration (useful for automotive cases
1732+
to exclude road/floor surfaces):
1733+
1734+
>>> fl.TimeAverageForceDistributionOutput(
1735+
... name="vehicle_x_distribution",
1736+
... distribution_direction=[1.0, 0.0, 0.0],
1737+
... entities=[volume_mesh["vehicle_body"], volume_mesh["wheels"]],
1738+
... number_of_segments=500,
1739+
... start_step=100,
1740+
... )
1741+
17011742
====
17021743
"""
17031744

flow360/component/simulation/services.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,9 @@
2222

2323
# Required for correct global scope initialization
2424
from flow360.component.simulation.blueprint.core.dependency_graph import DependencyGraph
25-
from flow360.component.simulation.entity_info import GeometryEntityInfo
25+
from flow360.component.simulation.entity_info import (
26+
GeometryEntityInfo,
27+
)
2628
from flow360.component.simulation.entity_info import (
2729
merge_geometry_entity_info as merge_geometry_entity_info_obj,
2830
)

flow360/component/simulation/translator/solver_translator.py

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -819,10 +819,18 @@ def translate_force_distribution_output(output_params: list):
819819
force_distribution_output = {}
820820
for output in output_params:
821821
if is_exact_instance(output, ForceDistributionOutput):
822-
force_distribution_output[output.name] = {
822+
config = {
823823
"direction": list(output.distribution_direction),
824824
"type": output.distribution_type,
825825
}
826+
# Add surfaces if specified (for selective face integration)
827+
if output.entities is not None:
828+
surface_names = [entity.full_name for entity in output.entities.stored_entities]
829+
config["surfaces"] = surface_names
830+
# Add number of segments if specified
831+
if output.number_of_segments is not None:
832+
config["numberOfSegments"] = output.number_of_segments
833+
force_distribution_output[output.name] = config
826834
return force_distribution_output
827835

828836

@@ -831,11 +839,19 @@ def translate_time_averaged_force_distribution_output(output_params: list):
831839
time_averaged_force_distribution_output = {}
832840
for output in output_params:
833841
if isinstance(output, TimeAverageForceDistributionOutput):
834-
time_averaged_force_distribution_output[output.name] = {
842+
config = {
835843
"direction": list(output.distribution_direction),
836844
"type": output.distribution_type,
837845
"startAverageIntegrationStep": output.start_step,
838846
}
847+
# Add surfaces if specified (for selective face integration)
848+
if output.entities is not None:
849+
surface_names = [entity.full_name for entity in output.entities.stored_entities]
850+
config["surfaces"] = surface_names
851+
# Add number of segments if specified
852+
if output.number_of_segments is not None:
853+
config["numberOfSegments"] = output.number_of_segments
854+
time_averaged_force_distribution_output[output.name] = config
839855
return time_averaged_force_distribution_output
840856

841857

tests/simulation/params/test_validators_output.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1386,3 +1386,72 @@ def test_force_output_with_model_id():
13861386
assert err["type"] == exp_err["type"]
13871387
assert err["ctx"]["relevant_for"] == exp_err["ctx"]["relevant_for"]
13881388
assert err["msg"] == exp_err["msg"]
1389+
1390+
1391+
def test_force_distribution_output_entities_validation():
1392+
"""Test ForceDistributionOutput entities validation."""
1393+
1394+
# Test 1: Valid case - ForceDistributionOutput without entities (default all walls)
1395+
with imperial_unit_system:
1396+
SimulationParams(
1397+
outputs=[
1398+
ForceDistributionOutput(
1399+
name="test_default",
1400+
distribution_direction=[1.0, 0.0, 0.0],
1401+
),
1402+
],
1403+
)
1404+
1405+
# Test 2: Valid case - ForceDistributionOutput with surface entities
1406+
with imperial_unit_system:
1407+
SimulationParams(
1408+
outputs=[
1409+
ForceDistributionOutput(
1410+
name="test_with_surfaces",
1411+
distribution_direction=[1.0, 0.0, 0.0],
1412+
entities=[Surface(name="fluid/wing")],
1413+
),
1414+
],
1415+
)
1416+
1417+
# Test 3: Valid case - TimeAverageForceDistributionOutput with entities
1418+
with imperial_unit_system:
1419+
SimulationParams(
1420+
outputs=[
1421+
TimeAverageForceDistributionOutput(
1422+
name="test_time_avg",
1423+
distribution_direction=[0.0, 1.0, 0.0],
1424+
entities=[Surface(name="fluid/body")],
1425+
start_step=10,
1426+
),
1427+
],
1428+
time_stepping=Unsteady(steps=100, step_size=1e-3),
1429+
)
1430+
1431+
# Test 4: Valid case - ForceDistributionOutput with multiple surfaces
1432+
with imperial_unit_system:
1433+
SimulationParams(
1434+
outputs=[
1435+
ForceDistributionOutput(
1436+
name="test_multiple_surfaces",
1437+
distribution_direction=[0.0, 0.0, 1.0],
1438+
entities=[
1439+
Surface(name="fluid/wing"),
1440+
Surface(name="fluid/fuselage"),
1441+
],
1442+
),
1443+
],
1444+
)
1445+
1446+
# Test 5: Valid case - ForceDistributionOutput with custom number_of_segments
1447+
with imperial_unit_system:
1448+
SimulationParams(
1449+
outputs=[
1450+
ForceDistributionOutput(
1451+
name="test_custom_segments",
1452+
distribution_direction=[1.0, 0.0, 0.0],
1453+
entities=[Surface(name="fluid/wing")],
1454+
number_of_segments=500,
1455+
),
1456+
],
1457+
)

tests/simulation/translator/test_output_translation.py

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1224,6 +1224,93 @@ def test_force_distribution_output():
12241224
assert compare_values(param_with_ref[1], translated["forceDistributionOutput"])
12251225

12261226

1227+
def test_force_distribution_output_with_entities_and_segments():
1228+
"""Test ForceDistributionOutput with entities (selective surfaces) and number_of_segments parameters."""
1229+
# Test with entities (selective surfaces)
1230+
param_with_entities = (
1231+
[
1232+
ForceDistributionOutput(
1233+
name="test_with_entities",
1234+
distribution_direction=[1.0, 0.0, 0.0],
1235+
entities=[
1236+
Surface(name="wing", private_attribute_full_name="fluid/wing"),
1237+
Surface(name="fuselage", private_attribute_full_name="fluid/fuselage"),
1238+
],
1239+
),
1240+
],
1241+
{
1242+
"test_with_entities": {
1243+
"direction": [1.0, 0.0, 0.0],
1244+
"type": "incremental",
1245+
"surfaces": ["fluid/wing", "fluid/fuselage"],
1246+
},
1247+
},
1248+
)
1249+
1250+
with SI_unit_system:
1251+
param = SimulationParams(outputs=param_with_entities[0])
1252+
param = param._preprocess(mesh_unit=1.0 * u.m, exclude=["models"])
1253+
1254+
translated = {}
1255+
translated = translate_output(param, translated)
1256+
assert compare_values(param_with_entities[1], translated["forceDistributionOutput"])
1257+
1258+
# Test with number_of_segments
1259+
param_with_segments = (
1260+
[
1261+
ForceDistributionOutput(
1262+
name="test_with_segments",
1263+
distribution_direction=[0.0, 1.0, 0.0],
1264+
number_of_segments=500,
1265+
),
1266+
],
1267+
{
1268+
"test_with_segments": {
1269+
"direction": [0.0, 1.0, 0.0],
1270+
"type": "incremental",
1271+
"numberOfSegments": 500,
1272+
},
1273+
},
1274+
)
1275+
1276+
with SI_unit_system:
1277+
param = SimulationParams(outputs=param_with_segments[0])
1278+
param = param._preprocess(mesh_unit=1.0 * u.m, exclude=["models"])
1279+
1280+
translated = {}
1281+
translated = translate_output(param, translated)
1282+
assert compare_values(param_with_segments[1], translated["forceDistributionOutput"])
1283+
1284+
# Test with both entities and number_of_segments
1285+
param_with_both = (
1286+
[
1287+
ForceDistributionOutput(
1288+
name="test_with_both",
1289+
distribution_direction=[0.0, 0.0, 1.0],
1290+
entities=[Surface(name="body", private_attribute_full_name="fluid/body")],
1291+
number_of_segments=400,
1292+
distribution_type="cumulative",
1293+
),
1294+
],
1295+
{
1296+
"test_with_both": {
1297+
"direction": [0.0, 0.0, 1.0],
1298+
"type": "cumulative",
1299+
"surfaces": ["fluid/body"],
1300+
"numberOfSegments": 400,
1301+
},
1302+
},
1303+
)
1304+
1305+
with SI_unit_system:
1306+
param = SimulationParams(outputs=param_with_both[0])
1307+
param = param._preprocess(mesh_unit=1.0 * u.m, exclude=["models"])
1308+
1309+
translated = {}
1310+
translated = translate_output(param, translated)
1311+
assert compare_values(param_with_both[1], translated["forceDistributionOutput"])
1312+
1313+
12271314
def test_time_averaged_force_distribution_output():
12281315
param_with_ref = (
12291316
[
@@ -1263,6 +1350,107 @@ def test_time_averaged_force_distribution_output():
12631350
assert compare_values(param_with_ref[1], translated["timeAveragedForceDistributionOutput"])
12641351

12651352

1353+
def test_time_averaged_force_distribution_output_with_entities_and_segments():
1354+
"""Test TimeAverageForceDistributionOutput with entities and number_of_segments parameters."""
1355+
# Test with entities (selective surfaces)
1356+
param_with_entities = (
1357+
[
1358+
TimeAverageForceDistributionOutput(
1359+
name="test_time_avg_entities",
1360+
distribution_direction=[1.0, 0.0, 0.0],
1361+
entities=[
1362+
Surface(name="wing", private_attribute_full_name="fluid/wing"),
1363+
],
1364+
start_step=10,
1365+
),
1366+
],
1367+
{
1368+
"test_time_avg_entities": {
1369+
"direction": [1.0, 0.0, 0.0],
1370+
"type": "incremental",
1371+
"startAverageIntegrationStep": 10,
1372+
"surfaces": ["fluid/wing"],
1373+
},
1374+
},
1375+
)
1376+
1377+
with SI_unit_system:
1378+
param = SimulationParams(
1379+
outputs=param_with_entities[0], time_stepping=Unsteady(steps=100, step_size=0.1)
1380+
)
1381+
param = param._preprocess(mesh_unit=1.0 * u.m, exclude=["models"])
1382+
1383+
translated = {}
1384+
translated = translate_output(param, translated)
1385+
assert compare_values(param_with_entities[1], translated["timeAveragedForceDistributionOutput"])
1386+
1387+
# Test with number_of_segments
1388+
param_with_segments = (
1389+
[
1390+
TimeAverageForceDistributionOutput(
1391+
name="test_time_avg_segments",
1392+
distribution_direction=[0.0, 1.0, 0.0],
1393+
number_of_segments=600,
1394+
start_step=20,
1395+
),
1396+
],
1397+
{
1398+
"test_time_avg_segments": {
1399+
"direction": [0.0, 1.0, 0.0],
1400+
"type": "incremental",
1401+
"startAverageIntegrationStep": 20,
1402+
"numberOfSegments": 600,
1403+
},
1404+
},
1405+
)
1406+
1407+
with SI_unit_system:
1408+
param = SimulationParams(
1409+
outputs=param_with_segments[0], time_stepping=Unsteady(steps=100, step_size=0.1)
1410+
)
1411+
param = param._preprocess(mesh_unit=1.0 * u.m, exclude=["models"])
1412+
1413+
translated = {}
1414+
translated = translate_output(param, translated)
1415+
assert compare_values(param_with_segments[1], translated["timeAveragedForceDistributionOutput"])
1416+
1417+
# Test with both entities and number_of_segments
1418+
param_with_both = (
1419+
[
1420+
TimeAverageForceDistributionOutput(
1421+
name="test_time_avg_both",
1422+
distribution_direction=[0.0, 0.0, 1.0],
1423+
entities=[
1424+
Surface(name="body", private_attribute_full_name="fluid/body"),
1425+
Surface(name="tail", private_attribute_full_name="fluid/tail"),
1426+
],
1427+
number_of_segments=350,
1428+
distribution_type="cumulative",
1429+
start_step=50,
1430+
),
1431+
],
1432+
{
1433+
"test_time_avg_both": {
1434+
"direction": [0.0, 0.0, 1.0],
1435+
"type": "cumulative",
1436+
"startAverageIntegrationStep": 50,
1437+
"surfaces": ["fluid/body", "fluid/tail"],
1438+
"numberOfSegments": 350,
1439+
},
1440+
},
1441+
)
1442+
1443+
with SI_unit_system:
1444+
param = SimulationParams(
1445+
outputs=param_with_both[0], time_stepping=Unsteady(steps=100, step_size=0.1)
1446+
)
1447+
param = param._preprocess(mesh_unit=1.0 * u.m, exclude=["models"])
1448+
1449+
translated = {}
1450+
translated = translate_output(param, translated)
1451+
assert compare_values(param_with_both[1], translated["timeAveragedForceDistributionOutput"])
1452+
1453+
12661454
def test_surface_slice_output(vel_in_km_per_hr):
12671455
param_with_ref = (
12681456
[

0 commit comments

Comments
 (0)