Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
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
13 changes: 13 additions & 0 deletions flow360/component/simulation/meshing_param/face_params.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
check_deleted_surface_in_entity_list,
check_geometry_ai_features,
check_ghost_surface_usage_policy_for_face_refinements,
remap_symmetric_ghost_entity,
Comment thread
cursor[bot] marked this conversation as resolved.
)


Expand Down Expand Up @@ -65,6 +66,12 @@ class SurfaceRefinement(Flow360BaseModel):
+ "accurately during the surface meshing process using anisotropic mesh refinement.",
)

@contextual_field_validator("entities", mode="after")
@classmethod
def remap_symmetric_to_user_name(cls, value, param_info: ParamsValidationInfo):
"""Remap 'symmetric' ghost entity to user's symmetry surface name for UDF backward compat."""
return remap_symmetric_ghost_entity(value, param_info)

@contextual_field_validator("entities", mode="after")
@classmethod
def ensure_surface_existence(cls, value, param_info: ParamsValidationInfo):
Expand Down Expand Up @@ -193,6 +200,12 @@ class PassiveSpacing(Flow360BaseModel):
Surface, MirroredSurface, WindTunnelGhostSurface, GhostSurface, GhostCircularPlane
] = pd.Field(alias="faces")

@contextual_field_validator("entities", mode="after")
@classmethod
def remap_symmetric_to_user_name(cls, value, param_info: ParamsValidationInfo):
"""Remap 'symmetric' ghost entity to user's symmetry surface name for UDF backward compat."""
return remap_symmetric_ghost_entity(value, param_info)

@contextual_field_validator("entities", mode="after")
@classmethod
def ensure_surface_existence(cls, value, param_info: ParamsValidationInfo):
Expand Down
3 changes: 3 additions & 0 deletions flow360/component/simulation/meshing_param/volume_params.py
Original file line number Diff line number Diff line change
Expand Up @@ -796,6 +796,9 @@ def symmetry_plane(self) -> GhostSurface:
Returns the symmetry plane boundary surface.

Warning: This should only be used when using GAI and beta mesher.

Note: If your geometry has an explicit symmetry plane, you can reference it
directly as geometry["your_face_name"] instead of using this property.
"""
if self.domain_type not in (
None,
Expand Down
11 changes: 10 additions & 1 deletion flow360/component/simulation/models/surface_models.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
"""
Contains basically only boundary conditons for now. In future we can add new models like 2D equations.
Contains basically only boundary conditions for now. In future we can add new models like 2D equations.
"""

# pylint: disable=too-many-lines

from abc import ABCMeta
from typing import Annotated, Dict, Literal, Optional, Union

Expand Down Expand Up @@ -48,6 +50,7 @@
)
from flow360.component.simulation.validation.validation_utils import (
check_deleted_surface_pair,
remap_symmetric_ghost_entity,
validate_entity_list_surface_existence,
)

Expand All @@ -67,6 +70,12 @@ class BoundaryBase(Flow360BaseModel, metaclass=ABCMeta):
)
private_attribute_id: str = pd.Field(default_factory=generate_uuid, frozen=True)

@contextual_field_validator("entities", mode="after")
@classmethod
def remap_symmetric_to_user_name(cls, value, param_info: ParamsValidationInfo):
"""Remap 'symmetric' ghost entity to user's symmetry surface name for UDF backward compat."""
return remap_symmetric_ghost_entity(value, param_info)

@contextual_field_validator("entities", mode="after")
@classmethod
def ensure_surface_existence(cls, value, param_info: ParamsValidationInfo):
Expand Down
32 changes: 14 additions & 18 deletions flow360/component/simulation/primitives.py
Original file line number Diff line number Diff line change
Expand Up @@ -740,7 +740,8 @@ class Surface(_SurfaceEntityBase):
# Note: private_attribute_id should not be `Optional` anymore.
# B.C. Updater and geometry pipeline will populate it.

def _overlaps(self, ghost_surface_center_y: Optional[float], length_tolerance: float) -> bool:
def _lies_on(self, ghost_surface_center_y: Optional[float], length_tolerance: float) -> bool:
# Check if the surface lies entirely within tolerance of the center y
if self.private_attributes is None:
# Legacy cloud asset.
return False
Expand All @@ -764,7 +765,6 @@ def _will_be_deleted_by_mesher(
half_model_symmetry_plane_center_y: Optional[float],
quasi_3d_symmetry_planes_center_y: Optional[tuple[float]],
farfield_domain_type: Optional[str] = None,
gai_and_beta_mesher: Optional[bool] = False,
) -> bool:
"""
Check against the automated farfield method and
Expand All @@ -782,6 +782,7 @@ def _will_be_deleted_by_mesher(
length_tolerance = global_bounding_box.largest_dimension * planar_face_tolerance

if farfield_domain_type in ("half_body_positive_y", "half_body_negative_y"):
# Wrong half
if self.private_attributes is not None:
# pylint: disable=no-member
y_min = self.private_attributes.bounding_box.ymin
Expand All @@ -793,35 +794,30 @@ def _will_be_deleted_by_mesher(
if farfield_domain_type == "half_body_negative_y" and y_min > length_tolerance:
return True

if farfield_method == "wind-tunnel":
# Not applicable to wind tunnel farfield
if farfield_method in ("user-defined", "wind-tunnel"):
# User-defined: user surfaces are not deleted
# Wind-tunnel: not applicable
return False

if farfield_method in ("auto", "user-defined"):
if farfield_method == "auto":
if half_model_symmetry_plane_center_y is None:
# Legacy schema.
return False
if farfield_method == "user-defined" and not gai_and_beta_mesher:
return False
if (
farfield_method == "auto"
and farfield_domain_type not in ("half_body_positive_y", "half_body_negative_y")
and (
not _auto_symmetric_plane_exists_from_bbox(
global_bounding_box=global_bounding_box,
planar_face_tolerance=planar_face_tolerance,
)
if farfield_domain_type not in ("half_body_positive_y", "half_body_negative_y") and (
not _auto_symmetric_plane_exists_from_bbox(
global_bounding_box=global_bounding_box,
planar_face_tolerance=planar_face_tolerance,
)
):
return False
return self._overlaps(half_model_symmetry_plane_center_y, length_tolerance)
return self._lies_on(half_model_symmetry_plane_center_y, length_tolerance)

if farfield_method in ("quasi-3d", "quasi-3d-periodic"):
if quasi_3d_symmetry_planes_center_y is None:
# Legacy schema.
return False
for plane_center_y in quasi_3d_symmetry_planes_center_y:
if self._overlaps(plane_center_y, length_tolerance):
if self._lies_on(plane_center_y, length_tolerance):
return True
return False

Expand Down Expand Up @@ -936,7 +932,7 @@ def exists(self, validation_info) -> bool:
"""For automated farfield, check mesher logic for symmetric plane existence."""

if self.name != "symmetric":
# Quasi-3D mode, no need to check existence.
# Quasi-3D mode or user-named symmetry patch (exists by definition)
return True

if validation_info is None:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,6 @@ def ensure_output_surface_existence(cls, value, param_info: ParamsValidationInfo
half_model_symmetry_plane_center_y=param_info.half_model_symmetry_plane_center_y,
quasi_3d_symmetry_planes_center_y=param_info.quasi_3d_symmetry_planes_center_y,
farfield_domain_type=param_info.farfield_domain_type,
gai_and_beta_mesher=param_info.use_geometry_AI and param_info.is_beta_mesher,
):
raise ValueError(
f"Boundary `{value.name}` will likely be deleted after mesh generation. Therefore it cannot be used."
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,10 @@
add_validation_warning,
get_validation_levels,
)
from flow360.component.simulation.validation.validation_utils import EntityUsageMap
from flow360.component.simulation.validation.validation_utils import (
EntityUsageMap,
find_user_symmetry_surfaces,
)


def _populate_validated_field_to_validation_context(v, param_info, attribute_name):
Expand Down Expand Up @@ -423,7 +426,6 @@ def _collect_asset_boundary_entities(params, param_info: ParamsValidationInfo) -
half_model_symmetry_plane_center_y=param_info.half_model_symmetry_plane_center_y,
quasi_3d_symmetry_planes_center_y=param_info.quasi_3d_symmetry_planes_center_y,
farfield_domain_type=param_info.farfield_domain_type,
gai_and_beta_mesher=param_info.use_geometry_AI and param_info.is_beta_mesher,
)
is False
]
Expand All @@ -447,12 +449,19 @@ def _collect_asset_boundary_entities(params, param_info: ParamsValidationInfo) -
]
elif farfield_method == "user-defined":
if param_info.use_geometry_AI and param_info.is_beta_mesher:
asset_boundary_entities += [
item
for item in ghost_entities
if item.name == "symmetric"
and (param_info.entity_transformation_detected or item.exists(param_info))
]
# Skip adding "symmetric" ghost if user geometry has y=0 surfaces
user_sym_surfaces = find_user_symmetry_surfaces(
asset_boundary_entities,
param_info.global_bounding_box,
param_info.planar_face_tolerance,
)
if len(user_sym_surfaces) == 0:
asset_boundary_entities += [
item
for item in ghost_entities
if item.name == "symmetric"
and (param_info.entity_transformation_detected or item.exists(param_info))
]
elif farfield_method == "wind-tunnel":
if param_info.will_generate_forced_symmetry_plane():
asset_boundary_entities += [item for item in ghost_entities if item.name == "symmetric"]
Expand Down Expand Up @@ -601,6 +610,28 @@ def _check_complete_boundary_condition_and_unknown_surface(
)
used_boundaries = _collect_used_boundary_names(params, param_info)

# Warn if multiple y=0 surfaces have different BC types
if param_info.farfield_method == "user-defined":
sym_surfaces = find_user_symmetry_surfaces(
asset_boundary_entities,
param_info.global_bounding_box,
param_info.planar_face_tolerance,
)
if len(sym_surfaces) > 1:
sym_names = {s.name for s in sym_surfaces}
bc_types = {
type(m).__name__
for m in params.models
if isinstance(m, get_args(SurfaceModelTypes))
and hasattr(m, "entities")
and any(e.name in sym_names for e in param_info.expand_entity_list(m.entities))
}
if len(bc_types) > 1:
add_validation_warning(
f"Multiple symmetry plane surfaces have different boundary conditions "
f"({', '.join(sorted(bc_types))}). Please check if this is intended."
)

# Step 4: Validate set differences with policy
_validate_boundary_completeness(
asset_boundaries=asset_boundaries,
Expand Down
93 changes: 91 additions & 2 deletions flow360/component/simulation/validation/validation_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,16 @@
from flow360.component.simulation.entity_info import DraftEntityTypes
from flow360.component.simulation.outputs.output_fields import CommonFieldNames
from flow360.component.simulation.primitives import (
GhostCircularPlane,
GhostSurface,
ImportedSurface,
Surface,
_SurfaceEntityBase,
_VolumeEntityBase,
)
from flow360.component.simulation.user_code.core.types import Expression, UserVariable
from flow360.component.simulation.utils import model_attribute_unlock
from flow360.log import log


def _validator_append_instance_name(func):
Expand Down Expand Up @@ -133,7 +137,6 @@ def check_deleted_surface_in_entity_list(expanded_entities: list, param_info) ->
half_model_symmetry_plane_center_y=param_info.half_model_symmetry_plane_center_y,
quasi_3d_symmetry_planes_center_y=param_info.quasi_3d_symmetry_planes_center_y,
farfield_domain_type=param_info.farfield_domain_type,
gai_and_beta_mesher=param_info.use_geometry_AI and param_info.is_beta_mesher,
):
deleted_boundaries.append(surface.name)

Expand Down Expand Up @@ -195,7 +198,6 @@ def check_deleted_surface_pair(value, param_info):
half_model_symmetry_plane_center_y=param_info.half_model_symmetry_plane_center_y,
quasi_3d_symmetry_planes_center_y=param_info.quasi_3d_symmetry_planes_center_y,
farfield_domain_type=param_info.farfield_domain_type,
gai_and_beta_mesher=param_info.use_geometry_AI and param_info.is_beta_mesher,
):
deleted_boundaries.append(surface.name)

Expand Down Expand Up @@ -274,6 +276,93 @@ def check_symmetric_boundary_existence(stored_entities, param_info):
return stored_entities


def find_user_symmetry_surfaces(boundaries, global_bounding_box, planar_face_tolerance):
"""Return Surface entities that lie on the y=0 symmetry plane (bounding box within tolerance)."""
if not global_bounding_box:
return []
tol = global_bounding_box.largest_dimension * (planar_face_tolerance or 1e-6)
return [
b
for b in boundaries
if isinstance(b, Surface) and b._lies_on(0, tol) # pylint: disable=protected-access
]
Comment thread
alexxu-flex marked this conversation as resolved.


def remap_symmetric_ghost_entity(value, param_info): # pylint: disable=too-many-return-statements
"""For UDF with GAI, replace any 'symmetric' ghost entity with the actual user Surface.
Call in validation on any model that can hold farfield.symmetry_plane (BCs and refinements).

Downstream validators see the correct entity, allowing users to directly reference
their symmetry surface, but we discourage using the legacy 'farfield.symmetry_plane'.
"""

if value is None or param_info.farfield_method != "user-defined":
return value
if not param_info.use_geometry_AI or not param_info.is_beta_mesher:
return value
if not hasattr(value, "stored_entities") or not value.stored_entities:
return value

ghost_idx = next(
(
i
for i, e in enumerate(value.stored_entities)
if isinstance(e, (GhostSurface, GhostCircularPlane)) and e.name == "symmetric"
),
None,
)
if ghost_idx is None:
return value

entity_info = param_info.get_entity_info()
if entity_info is None:
return value

try: # if entity info is incomplete, skip remap
boundaries = entity_info.get_boundaries()
except (ValueError, KeyError, AttributeError):
return value

sym_surfaces = find_user_symmetry_surfaces(
boundaries,
param_info.global_bounding_box,
param_info.planar_face_tolerance,
)

if len(sym_surfaces) == 1:
user_surface = sym_surfaces[0]
# Replace the ghost entity with the real Surface in the BC/refinement entity list
value.stored_entities[ghost_idx] = user_surface
# Also rename the ghost entity in entity_info so asset boundary collection matches
asset_ghost = next(
(
g
for g in entity_info.ghost_entities
if isinstance(g, (GhostSurface, GhostCircularPlane)) and g.name == "symmetric"
),
None,
)
if asset_ghost is not None:
with model_attribute_unlock(asset_ghost, "name"):
asset_ghost.name = user_surface.name
with model_attribute_unlock(asset_ghost, "private_attribute_id"):
asset_ghost.private_attribute_id = user_surface.private_attribute_id
log.warning(
"Your geometry has a symmetry surface '%s'. "
"Remapping farfield.symmetry_plane to use this name. "
"Consider using geometry['%s'] directly.",
user_surface.name,
user_surface.name,
)
elif len(sym_surfaces) > 1:
raise ValueError(
"farfield.symmetry_plane cannot be used with multiple symmetry surfaces. "
"Use geometry['name'] to reference individual symmetry surfaces directly."
)

return value


def _ghost_surface_names(stored_entities) -> list[str]:
"""Collect names of ghost-type boundaries in the list."""
names = []
Expand Down
6 changes: 3 additions & 3 deletions tests/simulation/params/data/surface_mesh/simulation.json
Original file line number Diff line number Diff line change
Expand Up @@ -634,12 +634,12 @@
"bounding_box": [
[
0,
1159.931377585337,
0,
0
],
[
1,
1159.931377585337,
0,
1
]
],
Expand All @@ -665,7 +665,7 @@
{
"center": [
1359.480337868488,
1159.931377585337,
0,
372.74731940854895
],
"max_radius": 2533.9434744230307,
Expand Down
Loading
Loading