Skip to content

Commit 90f35d9

Browse files
[Hotfix Main]: fix(): Validate domain_type and check for deleted surfaces in half-body simulations (#1616)
* Validate `domain_type` and check for deleted surfaces in half-body simulations (#1615) - Added validation to ensure domain_type (half_body_positive_y/negative_y) is consistent with the model bounding box. - Updated Surface._will_be_deleted_by_mesher to identify surfaces that will be trimmed by the half-body domain. - Synchronized check_symmetric_boundary_existence with domain_type to skip existence check when domain type enforces symmetry. - Added tests for domain type validation and surface deletion logic. * Resolved conflict --------- Co-authored-by: Ben <106089368+benflexcompute@users.noreply.github.com> Co-authored-by: benflexcompute <ben@flexcompute.com>
1 parent cc94e63 commit 90f35d9

File tree

6 files changed

+246
-3
lines changed

6 files changed

+246
-3
lines changed

flow360/component/simulation/meshing_param/volume_params.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -391,6 +391,57 @@ def _validate_only_in_beta_mesher(cls, value):
391391
"`domain_type` is only supported when using both GAI surface mesher and beta volume mesher."
392392
)
393393

394+
@pd.field_validator("domain_type", mode="after")
395+
@classmethod
396+
def _validate_domain_type_bbox(cls, value):
397+
"""
398+
Ensure that when domain_type is used, the model actually spans across Y=0.
399+
"""
400+
validation_info = get_validation_info()
401+
if validation_info is None:
402+
return value
403+
404+
if (
405+
value not in ("half_body_positive_y", "half_body_negative_y")
406+
or validation_info.global_bounding_box is None
407+
):
408+
return value
409+
410+
y_min = validation_info.global_bounding_box[0][1]
411+
y_max = validation_info.global_bounding_box[1][1]
412+
413+
largest_dimension = -float("inf")
414+
for dim in range(3):
415+
dimension = (
416+
validation_info.global_bounding_box[1][dim]
417+
- validation_info.global_bounding_box[0][dim]
418+
)
419+
largest_dimension = max(largest_dimension, dimension)
420+
421+
tolerance = largest_dimension * validation_info.planar_face_tolerance
422+
423+
# Check if model crosses Y=0
424+
crossing = y_min < -tolerance and y_max > tolerance
425+
if crossing:
426+
return value
427+
428+
# If not crossing, check if it matches the requested domain
429+
if value == "half_body_positive_y":
430+
# Should be on positive side (y > 0)
431+
if y_min >= -tolerance:
432+
return value
433+
434+
if value == "half_body_negative_y":
435+
# Should be on negative side (y < 0)
436+
if y_max <= tolerance:
437+
return value
438+
439+
raise ValueError(
440+
f"The model does not cross the symmetry plane (Y=0) with tolerance {tolerance:.2g}. "
441+
f"Model Y range: [{y_min:.2g}, {y_max:.2g}]. "
442+
"Please check if `domain_type` is set correctly."
443+
)
444+
394445

395446
class AutomatedFarfield(_FarfieldBase):
396447
"""

flow360/component/simulation/primitives.py

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -578,14 +578,15 @@ def _overlaps(self, ghost_surface_center_y: Optional[float], length_tolerance: f
578578
return True
579579

580580
def _will_be_deleted_by_mesher(
581-
# pylint: disable=too-many-arguments, too-many-return-statements
581+
# pylint: disable=too-many-arguments, too-many-return-statements, too-many-branches
582582
self,
583583
at_least_one_body_transformed: bool,
584584
farfield_method: Optional[Literal["auto", "quasi-3d", "quasi-3d-periodic", "user-defined"]],
585585
global_bounding_box: Optional[BoundingBoxType],
586586
planar_face_tolerance: Optional[float],
587587
half_model_symmetry_plane_center_y: Optional[float],
588588
quasi_3d_symmetry_planes_center_y: Optional[tuple[float]],
589+
farfield_domain_type: Optional[str] = None,
589590
) -> bool:
590591
"""
591592
Check against the automated farfield method and
@@ -600,12 +601,24 @@ def _will_be_deleted_by_mesher(
600601
# VolumeMesh or Geometry/SurfaceMesh with legacy schema.
601602
return False
602603

604+
length_tolerance = global_bounding_box.largest_dimension * planar_face_tolerance
605+
606+
if farfield_domain_type in ("half_body_positive_y", "half_body_negative_y"):
607+
if self.private_attributes is not None:
608+
# pylint: disable=no-member
609+
y_min = self.private_attributes.bounding_box.ymin
610+
y_max = self.private_attributes.bounding_box.ymax
611+
612+
if farfield_domain_type == "half_body_positive_y" and y_max < -length_tolerance:
613+
return True
614+
615+
if farfield_domain_type == "half_body_negative_y" and y_min > length_tolerance:
616+
return True
617+
603618
if farfield_method == "user-defined":
604619
# Not applicable to user defined farfield
605620
return False
606621

607-
length_tolerance = global_bounding_box.largest_dimension * planar_face_tolerance
608-
609622
if farfield_method == "auto":
610623
if half_model_symmetry_plane_center_y is None:
611624
# Legacy schema.

flow360/component/simulation/user_defined_dynamics/user_defined_dynamics.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@ def ensure_output_surface_existence(cls, value):
137137
planar_face_tolerance=validation_info.planar_face_tolerance,
138138
half_model_symmetry_plane_center_y=validation_info.half_model_symmetry_plane_center_y,
139139
quasi_3d_symmetry_planes_center_y=validation_info.quasi_3d_symmetry_planes_center_y,
140+
farfield_domain_type=validation_info.farfield_domain_type,
140141
):
141142
raise ValueError(
142143
f"Boundary `{value.name}` will likely be deleted after mesh generation. Therefore it cannot be used."

flow360/component/simulation/validation/validation_utils.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,7 @@ def check_deleted_surface_in_entity_list(value):
135135
planar_face_tolerance=validation_info.planar_face_tolerance,
136136
half_model_symmetry_plane_center_y=validation_info.half_model_symmetry_plane_center_y,
137137
quasi_3d_symmetry_planes_center_y=validation_info.quasi_3d_symmetry_planes_center_y,
138+
farfield_domain_type=validation_info.farfield_domain_type,
138139
):
139140
raise ValueError(
140141
f"Boundary `{surface.name}` will likely be deleted after mesh generation. "
@@ -166,6 +167,7 @@ def check_deleted_surface_pair(value):
166167
planar_face_tolerance=validation_info.planar_face_tolerance,
167168
half_model_symmetry_plane_center_y=validation_info.half_model_symmetry_plane_center_y,
168169
quasi_3d_symmetry_planes_center_y=validation_info.quasi_3d_symmetry_planes_center_y,
170+
farfield_domain_type=validation_info.farfield_domain_type,
169171
):
170172
raise ValueError(
171173
f"Boundary `{surface.name}` will likely be deleted after mesh generation. "
@@ -224,6 +226,12 @@ def check_symmetric_boundary_existence(stored_entities):
224226
if item.private_attribute_entity_type_name != "GhostCircularPlane":
225227
continue
226228

229+
if validation_info.farfield_domain_type in (
230+
"half_body_positive_y",
231+
"half_body_negative_y",
232+
):
233+
continue
234+
227235
if not item.exists(validation_info):
228236
# pylint: disable=protected-access
229237
y_min, y_max, tolerance, largest_dimension = item._get_existence_dependency(

tests/simulation/params/test_automated_farfield.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
from flow360.component.project_utils import set_up_params_for_uploading
99
from flow360.component.resource_base import local_metadata_builder
1010
from flow360.component.simulation import services
11+
from flow360.component.simulation.entity_info import SurfaceMeshEntityInfo
12+
from flow360.component.simulation.framework.param_utils import AssetCache
1113
from flow360.component.simulation.meshing_param.face_params import SurfaceRefinement
1214
from flow360.component.simulation.meshing_param.params import (
1315
MeshingDefaults,
@@ -482,3 +484,81 @@ def _test_and_show_errors(geometry):
482484
assert errors_1 is None
483485
assert errors_2 is None
484486
assert errors_3 is None
487+
488+
489+
def test_domain_type_bounding_box_check():
490+
# Case 1: Model does not cross Y=0 (Positive Half)
491+
# y range [1, 10]
492+
# Request half_body_positive_y -> Should pass (aligned)
493+
494+
dummy_boundary = Surface(name="dummy")
495+
496+
asset_cache_positive = AssetCache(
497+
project_length_unit="m",
498+
use_inhouse_mesher=True,
499+
use_geometry_AI=True,
500+
project_entity_info=SurfaceMeshEntityInfo(
501+
global_bounding_box=[[0, 1, 0], [10, 10, 10]],
502+
ghost_entities=[],
503+
boundaries=[dummy_boundary],
504+
),
505+
)
506+
507+
farfield_pos = UserDefinedFarfield(domain_type="half_body_positive_y")
508+
509+
with SI_unit_system:
510+
params = SimulationParams(
511+
meshing=MeshingParams(
512+
defaults=MeshingDefaults(
513+
planar_face_tolerance=0.01,
514+
geometry_accuracy=1e-5,
515+
boundary_layer_first_layer_thickness=1e-3,
516+
),
517+
volume_zones=[farfield_pos],
518+
),
519+
models=[Wall(entities=[dummy_boundary])], # Assign BC to avoid missing BC error
520+
private_attribute_asset_cache=asset_cache_positive,
521+
)
522+
523+
params_dict = params.model_dump(mode="json", exclude_none=True)
524+
_, errors, _ = services.validate_model(
525+
params_as_dict=params_dict,
526+
validated_by=services.ValidationCalledBy.LOCAL,
527+
root_item_type="SurfaceMesh",
528+
validation_level="All",
529+
)
530+
531+
domain_errors = [
532+
e for e in (errors or []) if "The model does not cross the symmetry plane" in e["msg"]
533+
]
534+
assert len(domain_errors) == 0
535+
536+
# Case 2: Misaligned
537+
# Request half_body_negative_y on Positive Model -> Should Fail
538+
farfield_neg = UserDefinedFarfield(domain_type="half_body_negative_y")
539+
540+
with SI_unit_system:
541+
params = SimulationParams(
542+
meshing=MeshingParams(
543+
defaults=MeshingDefaults(
544+
planar_face_tolerance=0.01,
545+
geometry_accuracy=1e-5,
546+
boundary_layer_first_layer_thickness=1e-3,
547+
),
548+
volume_zones=[farfield_neg],
549+
),
550+
models=[Wall(entities=[dummy_boundary])],
551+
private_attribute_asset_cache=asset_cache_positive,
552+
)
553+
554+
params_dict = params.model_dump(mode="json", exclude_none=True)
555+
_, errors, _ = services.validate_model(
556+
params_as_dict=params_dict,
557+
validated_by=services.ValidationCalledBy.LOCAL,
558+
root_item_type="SurfaceMesh",
559+
validation_level="All",
560+
)
561+
562+
assert errors is not None
563+
domain_errors = [e for e in errors if "The model does not cross the symmetry plane" in e["msg"]]
564+
assert len(domain_errors) == 1

tests/simulation/params/test_validators_params.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2442,3 +2442,93 @@ def test_seedpoint_zone_based_params():
24422442
)
24432443

24442444
assert errors is None
2445+
2446+
2447+
def test_deleted_surfaces_domain_type():
2448+
# Mock Asset Cache
2449+
surface_pos = Surface(
2450+
name="pos_surf",
2451+
private_attributes=SurfacePrivateAttributes(bounding_box=[[0, 1, 0], [1, 2, 1]]),
2452+
)
2453+
surface_neg = Surface(
2454+
name="neg_surf",
2455+
private_attributes=SurfacePrivateAttributes(bounding_box=[[0, -2, 0], [1, -1, 1]]),
2456+
)
2457+
surface_cross = Surface(
2458+
name="cross_surf",
2459+
private_attributes=SurfacePrivateAttributes(
2460+
bounding_box=[[0, -0.000001, 0], [1, 0.000001, 1]]
2461+
),
2462+
)
2463+
2464+
asset_cache = AssetCache(
2465+
project_length_unit="m",
2466+
use_inhouse_mesher=True,
2467+
use_geometry_AI=True,
2468+
project_entity_info=SurfaceMeshEntityInfo(
2469+
global_bounding_box=[[0, -2, 0], [1, 2, 1]], # Crosses Y=0
2470+
boundaries=[surface_pos, surface_neg, surface_cross],
2471+
),
2472+
)
2473+
2474+
# Test half_body_positive_y -> keeps positive, deletes negative
2475+
farfield = UserDefinedFarfield(domain_type="half_body_positive_y")
2476+
2477+
with SI_unit_system:
2478+
params = SimulationParams(
2479+
meshing=MeshingParams(
2480+
defaults=MeshingDefaults(
2481+
planar_face_tolerance=1e-4,
2482+
geometry_accuracy=1e-5,
2483+
boundary_layer_first_layer_thickness=1e-3,
2484+
),
2485+
volume_zones=[farfield],
2486+
),
2487+
models=[
2488+
Wall(entities=[surface_pos]), # OK
2489+
Wall(entities=[surface_neg]), # Error
2490+
Wall(entities=[surface_cross]), # OK (touches 0)
2491+
],
2492+
private_attribute_asset_cache=asset_cache,
2493+
)
2494+
2495+
_, errors, _ = validate_model(
2496+
params_as_dict=params.model_dump(mode="json"),
2497+
validated_by=ValidationCalledBy.LOCAL,
2498+
root_item_type="SurfaceMesh",
2499+
validation_level="All",
2500+
)
2501+
2502+
assert len(errors) == 1
2503+
assert "Boundary `neg_surf` will likely be deleted" in errors[0]["msg"]
2504+
2505+
# Test half_body_negative_y -> keeps negative, deletes positive
2506+
farfield_neg = UserDefinedFarfield(domain_type="half_body_negative_y")
2507+
2508+
with SI_unit_system:
2509+
params = SimulationParams(
2510+
meshing=MeshingParams(
2511+
defaults=MeshingDefaults(
2512+
planar_face_tolerance=1e-4,
2513+
geometry_accuracy=1e-5,
2514+
boundary_layer_first_layer_thickness=1e-3,
2515+
),
2516+
volume_zones=[farfield_neg],
2517+
),
2518+
models=[
2519+
Wall(entities=[surface_pos]), # Error
2520+
Wall(entities=[surface_neg]), # OK
2521+
Wall(entities=[surface_cross]), # OK
2522+
],
2523+
private_attribute_asset_cache=asset_cache,
2524+
)
2525+
2526+
_, errors, _ = validate_model(
2527+
params_as_dict=params.model_dump(mode="json"),
2528+
validated_by=ValidationCalledBy.LOCAL,
2529+
root_item_type="SurfaceMesh",
2530+
validation_level="All",
2531+
)
2532+
2533+
assert len(errors) == 1
2534+
assert "Boundary `pos_surf` will likely be deleted" in errors[0]["msg"]

0 commit comments

Comments
 (0)