Skip to content

Commit 5cd538b

Browse files
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.
1 parent 790ad83 commit 5cd538b

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
@@ -374,6 +374,57 @@ def _validate_only_in_beta_mesher(cls, value):
374374
"`domain_type` is only supported when using both GAI surface mesher and beta volume mesher."
375375
)
376376

377+
@pd.field_validator("domain_type", mode="after")
378+
@classmethod
379+
def _validate_domain_type_bbox(cls, value):
380+
"""
381+
Ensure that when domain_type is used, the model actually spans across Y=0.
382+
"""
383+
validation_info = get_validation_info()
384+
if validation_info is None:
385+
return value
386+
387+
if (
388+
value not in ("half_body_positive_y", "half_body_negative_y")
389+
or validation_info.global_bounding_box is None
390+
):
391+
return value
392+
393+
y_min = validation_info.global_bounding_box[0][1]
394+
y_max = validation_info.global_bounding_box[1][1]
395+
396+
largest_dimension = -float("inf")
397+
for dim in range(3):
398+
dimension = (
399+
validation_info.global_bounding_box[1][dim]
400+
- validation_info.global_bounding_box[0][dim]
401+
)
402+
largest_dimension = max(largest_dimension, dimension)
403+
404+
tolerance = largest_dimension * validation_info.planar_face_tolerance
405+
406+
# Check if model crosses Y=0
407+
crossing = y_min < -tolerance and y_max > tolerance
408+
if crossing:
409+
return value
410+
411+
# If not crossing, check if it matches the requested domain
412+
if value == "half_body_positive_y":
413+
# Should be on positive side (y > 0)
414+
if y_min >= -tolerance:
415+
return value
416+
417+
if value == "half_body_negative_y":
418+
# Should be on negative side (y < 0)
419+
if y_max <= tolerance:
420+
return value
421+
422+
raise ValueError(
423+
f"The model does not cross the symmetry plane (Y=0) with tolerance {tolerance:.2g}. "
424+
f"Model Y range: [{y_min:.2g}, {y_max:.2g}]. "
425+
"Please check if `domain_type` is set correctly."
426+
)
427+
377428

378429
class AutomatedFarfield(_FarfieldBase):
379430
"""

flow360/component/simulation/primitives.py

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

556556
def _will_be_deleted_by_mesher(
557-
# pylint: disable=too-many-arguments, too-many-return-statements
557+
# pylint: disable=too-many-arguments, too-many-return-statements, too-many-branches
558558
self,
559559
at_least_one_body_transformed: bool,
560560
farfield_method: Optional[Literal["auto", "quasi-3d", "quasi-3d-periodic", "user-defined"]],
561561
global_bounding_box: Optional[BoundingBoxType],
562562
planar_face_tolerance: Optional[float],
563563
half_model_symmetry_plane_center_y: Optional[float],
564564
quasi_3d_symmetry_planes_center_y: Optional[tuple[float]],
565+
farfield_domain_type: Optional[str] = None,
565566
) -> bool:
566567
"""
567568
Check against the automated farfield method and
@@ -576,12 +577,24 @@ def _will_be_deleted_by_mesher(
576577
# VolumeMesh or Geometry/SurfaceMesh with legacy schema.
577578
return False
578579

580+
length_tolerance = global_bounding_box.largest_dimension * planar_face_tolerance
581+
582+
if farfield_domain_type in ("half_body_positive_y", "half_body_negative_y"):
583+
if self.private_attributes is not None:
584+
# pylint: disable=no-member
585+
y_min = self.private_attributes.bounding_box.ymin
586+
y_max = self.private_attributes.bounding_box.ymax
587+
588+
if farfield_domain_type == "half_body_positive_y" and y_max < -length_tolerance:
589+
return True
590+
591+
if farfield_domain_type == "half_body_negative_y" and y_min > length_tolerance:
592+
return True
593+
579594
if farfield_method == "user-defined":
580595
# Not applicable to user defined farfield
581596
return False
582597

583-
length_tolerance = global_bounding_box.largest_dimension * planar_face_tolerance
584-
585598
if farfield_method == "auto":
586599
if half_model_symmetry_plane_center_y is None:
587600
# 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
@@ -136,6 +136,7 @@ def ensure_output_surface_existence(cls, value):
136136
planar_face_tolerance=validation_info.planar_face_tolerance,
137137
half_model_symmetry_plane_center_y=validation_info.half_model_symmetry_plane_center_y,
138138
quasi_3d_symmetry_planes_center_y=validation_info.quasi_3d_symmetry_planes_center_y,
139+
farfield_domain_type=validation_info.farfield_domain_type,
139140
):
140141
raise ValueError(
141142
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
@@ -84,6 +84,7 @@ def check_deleted_surface_in_entity_list(value):
8484
planar_face_tolerance=validation_info.planar_face_tolerance,
8585
half_model_symmetry_plane_center_y=validation_info.half_model_symmetry_plane_center_y,
8686
quasi_3d_symmetry_planes_center_y=validation_info.quasi_3d_symmetry_planes_center_y,
87+
farfield_domain_type=validation_info.farfield_domain_type,
8788
):
8889
raise ValueError(
8990
f"Boundary `{surface.name}` will likely be deleted after mesh generation. "
@@ -115,6 +116,7 @@ def check_deleted_surface_pair(value):
115116
planar_face_tolerance=validation_info.planar_face_tolerance,
116117
half_model_symmetry_plane_center_y=validation_info.half_model_symmetry_plane_center_y,
117118
quasi_3d_symmetry_planes_center_y=validation_info.quasi_3d_symmetry_planes_center_y,
119+
farfield_domain_type=validation_info.farfield_domain_type,
118120
):
119121
raise ValueError(
120122
f"Boundary `{surface.name}` will likely be deleted after mesh generation. "
@@ -173,6 +175,12 @@ def check_symmetric_boundary_existence(stored_entities):
173175
if item.private_attribute_entity_type_name != "GhostCircularPlane":
174176
continue
175177

178+
if validation_info.farfield_domain_type in (
179+
"half_body_positive_y",
180+
"half_body_negative_y",
181+
):
182+
continue
183+
176184
if not item.exists(validation_info):
177185
# pylint: disable=protected-access
178186
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,
@@ -481,3 +483,81 @@ def _test_and_show_errors(geometry):
481483
assert errors_1 is None
482484
assert errors_2 is None
483485
assert errors_3 is None
486+
487+
488+
def test_domain_type_bounding_box_check():
489+
# Case 1: Model does not cross Y=0 (Positive Half)
490+
# y range [1, 10]
491+
# Request half_body_positive_y -> Should pass (aligned)
492+
493+
dummy_boundary = Surface(name="dummy")
494+
495+
asset_cache_positive = AssetCache(
496+
project_length_unit="m",
497+
use_inhouse_mesher=True,
498+
use_geometry_AI=True,
499+
project_entity_info=SurfaceMeshEntityInfo(
500+
global_bounding_box=[[0, 1, 0], [10, 10, 10]],
501+
ghost_entities=[],
502+
boundaries=[dummy_boundary],
503+
),
504+
)
505+
506+
farfield_pos = UserDefinedFarfield(domain_type="half_body_positive_y")
507+
508+
with SI_unit_system:
509+
params = SimulationParams(
510+
meshing=MeshingParams(
511+
defaults=MeshingDefaults(
512+
planar_face_tolerance=0.01,
513+
geometry_accuracy=1e-5,
514+
boundary_layer_first_layer_thickness=1e-3,
515+
),
516+
volume_zones=[farfield_pos],
517+
),
518+
models=[Wall(entities=[dummy_boundary])], # Assign BC to avoid missing BC error
519+
private_attribute_asset_cache=asset_cache_positive,
520+
)
521+
522+
params_dict = params.model_dump(mode="json", exclude_none=True)
523+
_, errors, _ = services.validate_model(
524+
params_as_dict=params_dict,
525+
validated_by=services.ValidationCalledBy.LOCAL,
526+
root_item_type="SurfaceMesh",
527+
validation_level="All",
528+
)
529+
530+
domain_errors = [
531+
e for e in (errors or []) if "The model does not cross the symmetry plane" in e["msg"]
532+
]
533+
assert len(domain_errors) == 0
534+
535+
# Case 2: Misaligned
536+
# Request half_body_negative_y on Positive Model -> Should Fail
537+
farfield_neg = UserDefinedFarfield(domain_type="half_body_negative_y")
538+
539+
with SI_unit_system:
540+
params = SimulationParams(
541+
meshing=MeshingParams(
542+
defaults=MeshingDefaults(
543+
planar_face_tolerance=0.01,
544+
geometry_accuracy=1e-5,
545+
boundary_layer_first_layer_thickness=1e-3,
546+
),
547+
volume_zones=[farfield_neg],
548+
),
549+
models=[Wall(entities=[dummy_boundary])],
550+
private_attribute_asset_cache=asset_cache_positive,
551+
)
552+
553+
params_dict = params.model_dump(mode="json", exclude_none=True)
554+
_, errors, _ = services.validate_model(
555+
params_as_dict=params_dict,
556+
validated_by=services.ValidationCalledBy.LOCAL,
557+
root_item_type="SurfaceMesh",
558+
validation_level="All",
559+
)
560+
561+
assert errors is not None
562+
domain_errors = [e for e in errors if "The model does not cross the symmetry plane" in e["msg"]]
563+
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
@@ -2279,3 +2279,93 @@ def test_ghost_surface_pair_requires_quasi_3d_periodic_farfield():
22792279
# Case 4: Farfield method IS "quasi-3d-periodic" → should pass
22802280
with SI_unit_system, ValidationContext(CASE, quasi_3d_periodic_farfield_context):
22812281
Periodic(surface_pairs=(periodic_1, periodic_2), spec=Translational())
2282+
2283+
2284+
def test_deleted_surfaces_domain_type():
2285+
# Mock Asset Cache
2286+
surface_pos = Surface(
2287+
name="pos_surf",
2288+
private_attributes=SurfacePrivateAttributes(bounding_box=[[0, 1, 0], [1, 2, 1]]),
2289+
)
2290+
surface_neg = Surface(
2291+
name="neg_surf",
2292+
private_attributes=SurfacePrivateAttributes(bounding_box=[[0, -2, 0], [1, -1, 1]]),
2293+
)
2294+
surface_cross = Surface(
2295+
name="cross_surf",
2296+
private_attributes=SurfacePrivateAttributes(
2297+
bounding_box=[[0, -0.000001, 0], [1, 0.000001, 1]]
2298+
),
2299+
)
2300+
2301+
asset_cache = AssetCache(
2302+
project_length_unit="m",
2303+
use_inhouse_mesher=True,
2304+
use_geometry_AI=True,
2305+
project_entity_info=SurfaceMeshEntityInfo(
2306+
global_bounding_box=[[0, -2, 0], [1, 2, 1]], # Crosses Y=0
2307+
boundaries=[surface_pos, surface_neg, surface_cross],
2308+
),
2309+
)
2310+
2311+
# Test half_body_positive_y -> keeps positive, deletes negative
2312+
farfield = UserDefinedFarfield(domain_type="half_body_positive_y")
2313+
2314+
with SI_unit_system:
2315+
params = SimulationParams(
2316+
meshing=MeshingParams(
2317+
defaults=MeshingDefaults(
2318+
planar_face_tolerance=1e-4,
2319+
geometry_accuracy=1e-5,
2320+
boundary_layer_first_layer_thickness=1e-3,
2321+
),
2322+
volume_zones=[farfield],
2323+
),
2324+
models=[
2325+
Wall(entities=[surface_pos]), # OK
2326+
Wall(entities=[surface_neg]), # Error
2327+
Wall(entities=[surface_cross]), # OK (touches 0)
2328+
],
2329+
private_attribute_asset_cache=asset_cache,
2330+
)
2331+
2332+
_, errors, _ = validate_model(
2333+
params_as_dict=params.model_dump(mode="json"),
2334+
validated_by=ValidationCalledBy.LOCAL,
2335+
root_item_type="SurfaceMesh",
2336+
validation_level="All",
2337+
)
2338+
2339+
assert len(errors) == 1
2340+
assert "Boundary `neg_surf` will likely be deleted" in errors[0]["msg"]
2341+
2342+
# Test half_body_negative_y -> keeps negative, deletes positive
2343+
farfield_neg = UserDefinedFarfield(domain_type="half_body_negative_y")
2344+
2345+
with SI_unit_system:
2346+
params = SimulationParams(
2347+
meshing=MeshingParams(
2348+
defaults=MeshingDefaults(
2349+
planar_face_tolerance=1e-4,
2350+
geometry_accuracy=1e-5,
2351+
boundary_layer_first_layer_thickness=1e-3,
2352+
),
2353+
volume_zones=[farfield_neg],
2354+
),
2355+
models=[
2356+
Wall(entities=[surface_pos]), # Error
2357+
Wall(entities=[surface_neg]), # OK
2358+
Wall(entities=[surface_cross]), # OK
2359+
],
2360+
private_attribute_asset_cache=asset_cache,
2361+
)
2362+
2363+
_, errors, _ = validate_model(
2364+
params_as_dict=params.model_dump(mode="json"),
2365+
validated_by=ValidationCalledBy.LOCAL,
2366+
root_item_type="SurfaceMesh",
2367+
validation_level="All",
2368+
)
2369+
2370+
assert len(errors) == 1
2371+
assert "Boundary `pos_surf` will likely be deleted" in errors[0]["msg"]

0 commit comments

Comments
 (0)