From eb1782a2905e726064ff38b7c5c03a455971b16c Mon Sep 17 00:00:00 2001 From: Shreyas Waghe Date: Mon, 2 Feb 2026 10:52:37 -0500 Subject: [PATCH 01/11] add sphere entity --- flow360/__init__.py | 2 + .../framework/entity_materializer.py | 2 + flow360/component/simulation/primitives.py | 53 ++++++++++++++- .../test_entity_transformation.py | 65 ++++++++++++++++++- .../simulation/framework/test_entities_v2.py | 27 ++++++++ 5 files changed, 147 insertions(+), 2 deletions(-) diff --git a/flow360/__init__.py b/flow360/__init__.py index cb543b989..0dcf70f47 100644 --- a/flow360/__init__.py +++ b/flow360/__init__.py @@ -171,6 +171,7 @@ Cylinder, ReferenceGeometry, SeedpointVolume, + Sphere, ) from flow360.component.simulation.run_control.run_control import RunControl from flow360.component.simulation.run_control.stopping_criterion import ( @@ -244,6 +245,7 @@ "ReferenceGeometry", "CustomVolume", "Cylinder", + "Sphere", "AxisymmetricBody", "AerospaceCondition", "ThermalState", diff --git a/flow360/component/simulation/framework/entity_materializer.py b/flow360/component/simulation/framework/entity_materializer.py index cb6d42953..33b9d0310 100644 --- a/flow360/component/simulation/framework/entity_materializer.py +++ b/flow360/component/simulation/framework/entity_materializer.py @@ -46,6 +46,7 @@ MirroredSurface, SeedpointVolume, SnappyBody, + Sphere, Surface, WindTunnelGhostSurface, ) @@ -62,6 +63,7 @@ "AxisymmetricBody": AxisymmetricBody, "Box": Box, "Cylinder": Cylinder, + "Sphere": Sphere, "ImportedSurface": ImportedSurface, "GhostSurface": GhostSurface, "GhostSphere": GhostSphere, diff --git a/flow360/component/simulation/primitives.py b/flow360/component/simulation/primitives.py index 82c20839d..8063bfd60 100644 --- a/flow360/component/simulation/primitives.py +++ b/flow360/component/simulation/primitives.py @@ -481,6 +481,57 @@ def _apply_transformation(self, matrix: np.ndarray) -> "Box": ) +@final +class Sphere(_VolumeEntityBase): + """ + :class:`Sphere` class represents a sphere in three-dimensional space. + + Example + ------- + >>> fl.Sphere( + ... name="sphere_zone", + ... center=(0, 0, 0) * fl.u.m, + ... radius=1.5 * fl.u.m, + ... ) + + ==== + """ + + private_attribute_entity_type_name: Literal["Sphere"] = pd.Field("Sphere", frozen=True) + # pylint: disable=no-member + center: LengthType.Point = pd.Field(description="The center point of the sphere.") + radius: LengthType.Positive = pd.Field(description="The radius of the sphere.") + private_attribute_id: str = pd.Field(default_factory=generate_uuid, frozen=True) + + def _apply_transformation(self, matrix: np.ndarray) -> "Sphere": + """Apply 3x4 transformation matrix with uniform scale validation.""" + # Validate uniform scaling + if not _is_uniform_scale(matrix): + scale_factors = _extract_scale_from_matrix(matrix) + raise Flow360ValueError( + f"Sphere only supports uniform scaling. " + f"Detected scale factors: {scale_factors}" + ) + + # Extract uniform scale factor + uniform_scale = _extract_scale_from_matrix(matrix)[0] + + # Transform center + center_array = np.asarray(self.center.value) + new_center_array = _transform_point(center_array, matrix) + new_center = type(self.center)(new_center_array, self.center.units) + + # Scale radius uniformly + new_radius = self.radius * uniform_scale + + return self.model_copy( + update={ + "center": new_center, + "radius": new_radius, + } + ) + + @final class Cylinder(_VolumeEntityBase): """ @@ -996,7 +1047,7 @@ def _per_entity_type_validation(self, param_info: ParamsValidationInfo): return self -VolumeEntityTypes = Union[GenericVolume, Cylinder, Box, str] +VolumeEntityTypes = Union[GenericVolume, Cylinder, Sphere, Box, str] class SurfacePair(SurfacePairBase): diff --git a/tests/simulation/draft_context/test_entity_transformation.py b/tests/simulation/draft_context/test_entity_transformation.py index b2d0b1479..5e7caddfd 100644 --- a/tests/simulation/draft_context/test_entity_transformation.py +++ b/tests/simulation/draft_context/test_entity_transformation.py @@ -16,7 +16,7 @@ PointArray2D, Slice, ) -from flow360.component.simulation.primitives import Box, Cylinder +from flow360.component.simulation.primitives import Box, Cylinder, Sphere from flow360.component.simulation.unit_system import SI_unit_system from flow360.exceptions import Flow360ValueError @@ -307,6 +307,69 @@ def test_box_non_uniform_scale_raises_error(): box._apply_transformation(matrix) +# ============================================================================== +# Sphere Tests (with uniform scaling validation) +# ============================================================================== + + +def test_sphere_identity(): + """Sphere with identity matrix should remain unchanged.""" + with SI_unit_system: + sphere = Sphere(name="test_sphere", center=(0, 0, 0) * u.m, radius=5 * u.m) + transformed = sphere._apply_transformation(identity_matrix()) + + np.testing.assert_allclose(transformed.center.value, [0, 0, 0], atol=1e-10) + np.testing.assert_allclose(transformed.radius.value, 5, atol=1e-10) + + +def test_sphere_translation(): + """Sphere should translate center.""" + with SI_unit_system: + sphere = Sphere(name="test_sphere", center=(1, 2, 3) * u.m, radius=5 * u.m) + matrix = translation_matrix(10, 20, 30) + transformed = sphere._apply_transformation(matrix) + + np.testing.assert_allclose(transformed.center.value, [11, 22, 33], atol=1e-10) + # Radius unchanged by translation + np.testing.assert_allclose(transformed.radius.value, 5, atol=1e-10) + + +def test_sphere_uniform_scale(): + """Sphere should scale radius uniformly.""" + with SI_unit_system: + sphere = Sphere(name="test_sphere", center=(1, 0, 0) * u.m, radius=5 * u.m) + matrix = uniform_scale_matrix(2.0) + transformed = sphere._apply_transformation(matrix) + + # Center scaled: (1,0,0) * 2 = (2,0,0) + np.testing.assert_allclose(transformed.center.value, [2, 0, 0], atol=1e-10) + # Radius scaled: 5 * 2 = 10 + np.testing.assert_allclose(transformed.radius.value, 10, atol=1e-10) + + +def test_sphere_rotation(): + """Sphere center should rotate (radius unchanged).""" + with SI_unit_system: + sphere = Sphere(name="test_sphere", center=(1, 0, 0) * u.m, radius=5 * u.m) + matrix = rotation_z_90() + transformed = sphere._apply_transformation(matrix) + + # Center (1,0,0) rotated 90° around Z = (0,1,0) + np.testing.assert_allclose(transformed.center.value, [0, 1, 0], atol=1e-10) + # Radius unchanged by rotation + np.testing.assert_allclose(transformed.radius.value, 5, atol=1e-10) + + +def test_sphere_non_uniform_scale_raises_error(): + """Sphere should reject non-uniform scaling.""" + with SI_unit_system: + sphere = Sphere(name="test_sphere", center=(0, 0, 0) * u.m, radius=5 * u.m) + matrix = non_uniform_scale_matrix() + + with pytest.raises(Flow360ValueError, match="only supports uniform scaling"): + sphere._apply_transformation(matrix) + + # ============================================================================== # Cylinder Tests (with uniform scaling validation) # ============================================================================== diff --git a/tests/simulation/framework/test_entities_v2.py b/tests/simulation/framework/test_entities_v2.py index 9999fbcd8..15ee18474 100644 --- a/tests/simulation/framework/test_entities_v2.py +++ b/tests/simulation/framework/test_entities_v2.py @@ -21,6 +21,7 @@ Cylinder, Edge, GenericVolume, + Sphere, Surface, _SurfaceEntityBase, ) @@ -726,3 +727,29 @@ def test_cylinder_validation(): inner_radius=1000 * u.m, outer_radius=2 * u.m, ) + + +def test_sphere_creation(): + """Test basic Sphere creation.""" + with SI_unit_system: + sphere = Sphere(name="test_sphere", center=(1, 2, 3) * u.m, radius=5 * u.m) + assert sphere.name == "test_sphere" + np.testing.assert_allclose(sphere.center.value, [1, 2, 3]) + np.testing.assert_allclose(sphere.radius.value, 5) + + +def test_sphere_validation(): + """Test Sphere validation for negative radius.""" + with pytest.raises(ValueError, match="Input should be greater than 0"): + Sphere( + name="sphere", + center=(0, 0, 0) * u.m, + radius=-5 * u.m, + ) + + with pytest.raises(ValueError, match="Input should be greater than 0"): + Sphere( + name="sphere", + center=(0, 0, 0) * u.m, + radius=-5 * u.flow360_length_unit, + ) From 7ae247db58cfc3ffd7d6aef50d1205ccccd4bc5e Mon Sep 17 00:00:00 2001 From: Shreyas Waghe Date: Mon, 2 Feb 2026 11:41:48 -0500 Subject: [PATCH 02/11] addressing bugbot comment --- .../component/simulation/entity_operation.py | 45 +++++++ .../simulation/meshing_param/volume_params.py | 100 +++++++++++++-- flow360/component/simulation/primitives.py | 77 +++--------- .../test_meshing_param_validation.py | 118 ++++++++++++++++++ 4 files changed, 270 insertions(+), 70 deletions(-) diff --git a/flow360/component/simulation/entity_operation.py b/flow360/component/simulation/entity_operation.py index d792e36c3..6738424a8 100644 --- a/flow360/component/simulation/entity_operation.py +++ b/flow360/component/simulation/entity_operation.py @@ -163,6 +163,51 @@ def _is_uniform_scale(matrix: np.ndarray, rtol: float = 1e-5) -> bool: return np.allclose(scale_factors, scale_factors[0], rtol=rtol) +def _validate_uniform_scale_and_transform_center( + matrix: np.ndarray, center, entity_name: str +) -> tuple: + """ + Common transformation logic for volume primitives that require uniform scaling. + + Validates that the transformation matrix has uniform scaling, extracts the scale factor, + and transforms the center point. + + Args: + matrix: 3x4 transformation matrix + center: The center point (LengthType.Point) to transform + entity_name: Name of the entity type (e.g., "Sphere", "Cylinder") for error messages + + Returns: + Tuple of (new_center, uniform_scale) where: + - new_center: Transformed center point with same type and units as input + - uniform_scale: The uniform scale factor extracted from the matrix + + Raises: + Flow360ValueError: If the matrix has non-uniform scaling + """ + # Import here to avoid circular imports + # pylint: disable=import-outside-toplevel + from flow360.exceptions import Flow360ValueError + + # Validate uniform scaling + if not _is_uniform_scale(matrix): + scale_factors = _extract_scale_from_matrix(matrix) + raise Flow360ValueError( + f"{entity_name} only supports uniform scaling. " + f"Detected scale factors: {scale_factors}" + ) + + # Extract uniform scale factor + uniform_scale = _extract_scale_from_matrix(matrix)[0] + + # Transform center + center_array = np.asarray(center.value) + new_center_array = _transform_point(center_array, matrix) + new_center = type(center)(new_center_array, center.units) + + return new_center, uniform_scale + + def _extract_rotation_matrix(matrix: np.ndarray) -> np.ndarray: """ Extract the pure rotation matrix from a 3x4 transformation matrix, diff --git a/flow360/component/simulation/meshing_param/volume_params.py b/flow360/component/simulation/meshing_param/volume_params.py index 449cd6de2..09043b63b 100644 --- a/flow360/component/simulation/meshing_param/volume_params.py +++ b/flow360/component/simulation/meshing_param/volume_params.py @@ -23,6 +23,7 @@ GhostSurface, MirroredSurface, SeedpointVolume, + Sphere, Surface, WindTunnelGhostSurface, ) @@ -145,9 +146,11 @@ class AxisymmetricRefinementBase(Flow360BaseModel, metaclass=ABCMeta): """Base class for all refinements that requires spacing in axial, radial and circumferential directions.""" # pylint: disable=no-member - spacing_axial: LengthType.Positive = pd.Field(description="Spacing along the axial direction.") - spacing_radial: LengthType.Positive = pd.Field( - description="Spacing along the radial direction." + spacing_axial: Optional[LengthType.Positive] = pd.Field( + None, description="Spacing along the axial direction." + ) + spacing_radial: Optional[LengthType.Positive] = pd.Field( + None, description="Spacing along the radial direction." ) spacing_circumferential: LengthType.Positive = pd.Field( description="Spacing along the circumferential direction." @@ -182,21 +185,40 @@ class AxisymmetricRefinement(AxisymmetricRefinementBase): ) entities: EntityList[Cylinder] = pd.Field() + @pd.model_validator(mode="after") + def _validate_axial_radial_spacing_required(self): + """Ensure spacing_axial and spacing_radial are provided for Cylinder entities.""" + if self.spacing_axial is None: + raise ValueError( + "`spacing_axial` is required for `AxisymmetricRefinement` with `Cylinder` entities." + ) + if self.spacing_radial is None: + raise ValueError( + "`spacing_radial` is required for `AxisymmetricRefinement` with `Cylinder` entities." + ) + return self + class RotationVolume(AxisymmetricRefinementBase): """ - Creates a rotation volume mesh using cylindrical or axisymmetric body entities. + Creates a rotation volume mesh using cylindrical, axisymmetric body, or sphere entities. - The mesh on :class:`RotationVolume` is guaranteed to be concentric. - The :class:`RotationVolume` is designed to enclose other objects, but it can't intersect with other objects. - Users can create a donut-shaped :class:`RotationVolume` and put their stationary centerbody in the middle. - This type of volume zone can be used to generate volume zones compatible with :class:`~flow360.Rotation` model. - - Supports both :class:`Cylinder` and :class:`AxisymmetricBody` entities for defining the rotation volume geometry. + - Supports :class:`Cylinder`, :class:`AxisymmetricBody`, and :class:`Sphere` entities + for defining the rotation volume geometry. .. note:: The deprecated :class:`RotationCylinder` class is maintained for backward compatibility but only accepts :class:`Cylinder` entities. New code should use :class:`RotationVolume`. + .. note:: + For :class:`Sphere` entities, only `spacing_circumferential` is required (uniform spacing on the surface). + For :class:`Cylinder` and :class:`AxisymmetricBody` entities, `spacing_axial`, `spacing_radial`, + and `spacing_circumferential` are all required. + Example ------- Using a Cylinder entity: @@ -219,6 +241,14 @@ class RotationVolume(AxisymmetricRefinementBase): ... entities=axisymmetric_body ... ) + Using a Sphere entity (spherical sliding interface): + + >>> fl.RotationVolume( + ... name="RotationSphere", + ... spacing_circumferential=0.3*fl.u.m, + ... entities=sphere + ... ) + With enclosed entities: >>> fl.RotationVolume( @@ -237,9 +267,9 @@ class RotationVolume(AxisymmetricRefinementBase): type: Literal["RotationVolume"] = pd.Field("RotationVolume", frozen=True) name: Optional[str] = pd.Field("Rotation Volume", description="Name to display in the GUI.") - entities: EntityList[Cylinder, AxisymmetricBody] = pd.Field() + entities: EntityList[Cylinder, AxisymmetricBody, Sphere] = pd.Field() enclosed_entities: Optional[ - EntityList[Cylinder, Surface, MirroredSurface, AxisymmetricBody, Box] + EntityList[Cylinder, Surface, MirroredSurface, AxisymmetricBody, Box, Sphere] ] = pd.Field( None, description=( @@ -247,6 +277,7 @@ class RotationVolume(AxisymmetricRefinementBase): "Can be :class:`~flow360.Surface` and/or other :class:`~flow360.Cylinder`" "and/or other :class:`~flow360.AxisymmetricBody`" "and/or other :class:`~flow360.Box`" + "and/or other :class:`~flow360.Sphere`" ), ) stationary_enclosed_entities: Optional[EntityList[Surface, MirroredSurface]] = pd.Field( @@ -334,6 +365,22 @@ def _validate_axisymmetric_only_in_beta_mesher(cls, values, param_info: ParamsVa ) return values + @contextual_field_validator("entities", mode="after") + @classmethod + def _validate_sphere_only_in_beta_mesher(cls, values, param_info: ParamsValidationInfo): + """ + Ensure that Sphere RotationVolumes are only processed with the beta mesher. + """ + if param_info.is_beta_mesher: + return values + + for entity in values.stored_entities: + if isinstance(entity, Sphere): + raise ValueError( + "`Sphere` entity for `RotationVolume` is only supported with the beta mesher." + ) + return values + @contextual_field_validator("enclosed_entities", mode="after") @classmethod def ensure_surface_existence(cls, value, param_info: ParamsValidationInfo): @@ -388,6 +435,45 @@ def _validate_stationary_enclosed_entities_subset(self, param_info: ParamsValida return self + @pd.model_validator(mode="after") + def _validate_spacing_requirements_by_entity_type(self): + """ + Validate spacing requirements based on entity type: + - Sphere: only spacing_circumferential is used; spacing_axial and spacing_radial must not be specified + - Cylinder/AxisymmetricBody: spacing_axial and spacing_radial are required + """ + # Check if entity is a Sphere + # pylint: disable=no-member + has_sphere = any(isinstance(entity, Sphere) for entity in self.entities.stored_entities) + has_cylinder_or_axisymmetric = any( + isinstance(entity, (Cylinder, AxisymmetricBody)) + for entity in self.entities.stored_entities + ) + + if has_sphere: + if self.spacing_axial is not None: + raise ValueError( + "`spacing_axial` must not be specified for `Sphere` entities. " + "Sphere uses only `spacing_circumferential` for uniform surface spacing." + ) + if self.spacing_radial is not None: + raise ValueError( + "`spacing_radial` must not be specified for `Sphere` entities. " + "Sphere uses only `spacing_circumferential` for uniform surface spacing." + ) + + if has_cylinder_or_axisymmetric: + if self.spacing_axial is None: + raise ValueError( + "`spacing_axial` is required for `Cylinder` and `AxisymmetricBody` entities." + ) + if self.spacing_radial is None: + raise ValueError( + "`spacing_radial` is required for `Cylinder` and `AxisymmetricBody` entities." + ) + + return self + @deprecated( "The `RotationCylinder` class is deprecated! Use `RotationVolume`," diff --git a/flow360/component/simulation/primitives.py b/flow360/component/simulation/primitives.py index 8063bfd60..5572766f2 100644 --- a/flow360/component/simulation/primitives.py +++ b/flow360/component/simulation/primitives.py @@ -15,11 +15,9 @@ import flow360.component.simulation.units as u from flow360.component.simulation.entity_operation import ( _extract_rotation_matrix, - _extract_scale_from_matrix, - _is_uniform_scale, _rotation_matrix_to_axis_angle, _transform_direction, - _transform_point, + _validate_uniform_scale_and_transform_center, rotation_matrix_from_axis_and_angle, ) from flow360.component.simulation.framework.base_model import Flow360BaseModel @@ -39,7 +37,7 @@ ) from flow360.component.types import Axis from flow360.component.utils import _naming_pattern_handler -from flow360.exceptions import Flow360DeprecationError, Flow360ValueError +from flow360.exceptions import Flow360DeprecationError BOUNDARY_FULL_NAME_WHEN_NOT_FOUND = "This boundary does not exist!!!" @@ -437,20 +435,9 @@ def _update_input_cache(cls, value, info: pd.ValidationInfo): def _apply_transformation(self, matrix: np.ndarray) -> "Box": """Apply 3x4 transformation matrix with uniform scale validation and rotation composition.""" - # Validate uniform scaling - if not _is_uniform_scale(matrix): - scale_factors = _extract_scale_from_matrix(matrix) - raise Flow360ValueError( - f"Box only supports uniform scaling. " f"Detected scale factors: {scale_factors}" - ) - - # Extract uniform scale factor - uniform_scale = _extract_scale_from_matrix(matrix)[0] - - # Transform center - center_array = np.asarray(self.center.value) - new_center_array = _transform_point(center_array, matrix) - new_center = type(self.center)(new_center_array, self.center.units) + new_center, uniform_scale = _validate_uniform_scale_and_transform_center( + matrix, self.center, "Box" + ) # Combine rotations: existing rotation + transformation rotation # Step 1: Get existing rotation matrix from axis-angle @@ -505,21 +492,9 @@ class Sphere(_VolumeEntityBase): def _apply_transformation(self, matrix: np.ndarray) -> "Sphere": """Apply 3x4 transformation matrix with uniform scale validation.""" - # Validate uniform scaling - if not _is_uniform_scale(matrix): - scale_factors = _extract_scale_from_matrix(matrix) - raise Flow360ValueError( - f"Sphere only supports uniform scaling. " - f"Detected scale factors: {scale_factors}" - ) - - # Extract uniform scale factor - uniform_scale = _extract_scale_from_matrix(matrix)[0] - - # Transform center - center_array = np.asarray(self.center.value) - new_center_array = _transform_point(center_array, matrix) - new_center = type(self.center)(new_center_array, self.center.units) + new_center, uniform_scale = _validate_uniform_scale_and_transform_center( + matrix, self.center, "Sphere" + ) # Scale radius uniformly new_radius = self.radius * uniform_scale @@ -571,21 +546,9 @@ def _check_inner_radius_is_less_than_outer_radius(self) -> Self: def _apply_transformation(self, matrix: np.ndarray) -> "Cylinder": """Apply 3x4 transformation matrix with uniform scale validation.""" - # Validate uniform scaling - if not _is_uniform_scale(matrix): - scale_factors = _extract_scale_from_matrix(matrix) - raise Flow360ValueError( - f"Cylinder only supports uniform scaling. " - f"Detected scale factors: {scale_factors}" - ) - - # Extract uniform scale factor - uniform_scale = _extract_scale_from_matrix(matrix)[0] - - # Transform center - center_array = np.asarray(self.center.value) - new_center_array = _transform_point(center_array, matrix) - new_center = type(self.center)(new_center_array, self.center.units) + new_center, uniform_scale = _validate_uniform_scale_and_transform_center( + matrix, self.center, "Cylinder" + ) # Rotate axis axis_array = np.asarray(self.axis) @@ -667,21 +630,9 @@ def _check_radial_profile_is_positive(cls, curve): def _apply_transformation(self, matrix: np.ndarray) -> "AxisymmetricBody": """Apply 3x4 transformation matrix with uniform scale validation.""" - # Validate uniform scaling - if not _is_uniform_scale(matrix): - scale_factors = _extract_scale_from_matrix(matrix) - raise Flow360ValueError( - f"AxisymmetricBody only supports uniform scaling. " - f"Detected scale factors: {scale_factors}" - ) - - # Extract uniform scale factor - uniform_scale = _extract_scale_from_matrix(matrix)[0] - - # Transform center - center_array = np.asarray(self.center.value) - new_center_array = _transform_point(center_array, matrix) - new_center = type(self.center)(new_center_array, self.center.units) + new_center, uniform_scale = _validate_uniform_scale_and_transform_center( + matrix, self.center, "AxisymmetricBody" + ) # Rotate axis axis_array = np.asarray(self.axis) diff --git a/tests/simulation/params/meshing_validation/test_meshing_param_validation.py b/tests/simulation/params/meshing_validation/test_meshing_param_validation.py index a639dbbe3..eec1d09fb 100644 --- a/tests/simulation/params/meshing_validation/test_meshing_param_validation.py +++ b/tests/simulation/params/meshing_validation/test_meshing_param_validation.py @@ -36,6 +36,7 @@ Cylinder, SeedpointVolume, SnappyBody, + Sphere, Surface, ) from flow360.component.simulation.services import ValidationCalledBy, validate_model @@ -335,6 +336,123 @@ def test_limit_axisymmetric_body_in_rotation_volume(): ) +def test_sphere_in_rotation_volume_only_in_beta_mesher(): + """Test that Sphere entity for RotationVolume is only supported with the beta mesher.""" + # raises when beta mesher is off + with pytest.raises( + pd.ValidationError, + match=r"`Sphere` entity for `RotationVolume` is only supported with the beta mesher.", + ): + with ValidationContext(VOLUME_MESH, non_beta_mesher_context): + with CGS_unit_system: + sphere = Sphere( + name="rotation_sphere", + center=(0, 0, 0), + radius=10, + ) + _ = RotationVolume( + entities=[sphere], + spacing_circumferential=0.5, + ) + + # does not raise with beta mesher on + with ValidationContext(VOLUME_MESH, beta_mesher_context): + with CGS_unit_system: + sphere = Sphere( + name="rotation_sphere", + center=(0, 0, 0), + radius=10, + ) + _ = RotationVolume( + entities=[sphere], + spacing_circumferential=0.5, + ) + + +def test_sphere_rotation_volume_spacing_requirements(): + """Test spacing requirements for Sphere vs Cylinder/AxisymmetricBody in RotationVolume.""" + # Test 1: Sphere with spacing_axial should raise error + with pytest.raises( + pd.ValidationError, + match=r"`spacing_axial` must not be specified for `Sphere` entities", + ): + with ValidationContext(VOLUME_MESH, beta_mesher_context): + with CGS_unit_system: + sphere = Sphere(name="sphere", center=(0, 0, 0), radius=10) + _ = RotationVolume( + entities=[sphere], + spacing_circumferential=0.5, + spacing_axial=0.5, + ) + + # Test 2: Sphere with spacing_radial should raise error + with pytest.raises( + pd.ValidationError, + match=r"`spacing_radial` must not be specified for `Sphere` entities", + ): + with ValidationContext(VOLUME_MESH, beta_mesher_context): + with CGS_unit_system: + sphere = Sphere(name="sphere", center=(0, 0, 0), radius=10) + _ = RotationVolume( + entities=[sphere], + spacing_circumferential=0.5, + spacing_radial=0.5, + ) + + # Test 3: Cylinder without spacing_axial should raise error + with pytest.raises( + pd.ValidationError, + match=r"`spacing_axial` is required for `Cylinder` and `AxisymmetricBody` entities", + ): + with ValidationContext(VOLUME_MESH, beta_mesher_context): + with CGS_unit_system: + cylinder = Cylinder( + name="cyl", + center=(0, 0, 0), + axis=(0, 0, 1), + height=10, + outer_radius=5, + ) + _ = RotationVolume( + entities=[cylinder], + spacing_circumferential=0.5, + spacing_radial=0.5, + ) + + # Test 4: Cylinder without spacing_radial should raise error + with pytest.raises( + pd.ValidationError, + match=r"`spacing_radial` is required for `Cylinder` and `AxisymmetricBody` entities", + ): + with ValidationContext(VOLUME_MESH, beta_mesher_context): + with CGS_unit_system: + cylinder = Cylinder( + name="cyl", + center=(0, 0, 0), + axis=(0, 0, 1), + height=10, + outer_radius=5, + ) + _ = RotationVolume( + entities=[cylinder], + spacing_circumferential=0.5, + spacing_axial=0.5, + ) + + +def test_sphere_rotation_volume_with_enclosed_entities(): + """Test that Sphere RotationVolume supports enclosed_entities.""" + with ValidationContext(VOLUME_MESH, beta_mesher_context): + with CGS_unit_system: + sphere = Sphere(name="outer_sphere", center=(0, 0, 0), radius=10) + inner_sphere = Sphere(name="inner_sphere", center=(0, 0, 0), radius=5) + _ = RotationVolume( + entities=[sphere], + spacing_circumferential=0.5, + enclosed_entities=[inner_sphere, Surface(name="hub")], + ) + + def test_reuse_of_same_cylinder(mock_validation_context): with mock_validation_context, pytest.raises( pd.ValidationError, From 5a055f2c24289bd179eae7abe31a66f815f194f0 Mon Sep 17 00:00:00 2001 From: Shreyas Waghe Date: Mon, 2 Feb 2026 11:56:05 -0500 Subject: [PATCH 03/11] more bugbot comments --- .../simulation/meshing_param/volume_params.py | 26 ++++------- .../test_meshing_param_validation.py | 45 +++++++++++++++++++ 2 files changed, 53 insertions(+), 18 deletions(-) diff --git a/flow360/component/simulation/meshing_param/volume_params.py b/flow360/component/simulation/meshing_param/volume_params.py index 09043b63b..9fb4831fe 100644 --- a/flow360/component/simulation/meshing_param/volume_params.py +++ b/flow360/component/simulation/meshing_param/volume_params.py @@ -329,11 +329,9 @@ def _validate_cylinder_name_length(cls, values, param_info: ParamsValidationInfo @contextual_field_validator("enclosed_entities", mode="after") @classmethod - def _validate_enclosed_box_only_in_beta_mesher(cls, values, param_info: ParamsValidationInfo): + def _validate_enclosed_entities_beta_mesher_only(cls, values, param_info: ParamsValidationInfo): """ - Check the name length for the cylinder entities due to the 32-character - limitation of all data structure names and labels in CGNS format. - The current prefix is 'rotatingBlock-' with 14 characters. + Ensure that Box and Sphere entities in enclosed_entities are only used with the beta mesher. """ if values is None: return values @@ -346,14 +344,18 @@ def _validate_enclosed_box_only_in_beta_mesher(cls, values, param_info: ParamsVa raise ValueError( "`Box` entity in `RotationVolume.enclosed_entities` is only supported with the beta mesher." ) + if isinstance(entity, Sphere): + raise ValueError( + "`Sphere` entity in `RotationVolume.enclosed_entities` is only supported with the beta mesher." + ) return values @contextual_field_validator("entities", mode="after") @classmethod - def _validate_axisymmetric_only_in_beta_mesher(cls, values, param_info: ParamsValidationInfo): + def _validate_entities_beta_mesher_only(cls, values, param_info: ParamsValidationInfo): """ - Ensure that axisymmetric RotationVolumes are only processed with the beta mesher. + Ensure that AxisymmetricBody and Sphere entities are only used with the beta mesher. """ if param_info.is_beta_mesher: return values @@ -363,18 +365,6 @@ def _validate_axisymmetric_only_in_beta_mesher(cls, values, param_info: ParamsVa raise ValueError( "`AxisymmetricBody` entity for `RotationVolume` is only supported with the beta mesher." ) - return values - - @contextual_field_validator("entities", mode="after") - @classmethod - def _validate_sphere_only_in_beta_mesher(cls, values, param_info: ParamsValidationInfo): - """ - Ensure that Sphere RotationVolumes are only processed with the beta mesher. - """ - if param_info.is_beta_mesher: - return values - - for entity in values.stored_entities: if isinstance(entity, Sphere): raise ValueError( "`Sphere` entity for `RotationVolume` is only supported with the beta mesher." diff --git a/tests/simulation/params/meshing_validation/test_meshing_param_validation.py b/tests/simulation/params/meshing_validation/test_meshing_param_validation.py index eec1d09fb..429dcbac6 100644 --- a/tests/simulation/params/meshing_validation/test_meshing_param_validation.py +++ b/tests/simulation/params/meshing_validation/test_meshing_param_validation.py @@ -453,6 +453,51 @@ def test_sphere_rotation_volume_with_enclosed_entities(): ) +def test_sphere_in_enclosed_entities_only_in_beta_mesher(): + """Test that Sphere in enclosed_entities is only supported with the beta mesher.""" + # raises when beta mesher is off + with pytest.raises( + pd.ValidationError, + match=r"`Sphere` entity in `RotationVolume.enclosed_entities` is only supported with the beta mesher.", + ): + with ValidationContext(VOLUME_MESH, non_beta_mesher_context): + with CGS_unit_system: + cylinder = Cylinder( + name="outer_cyl", + center=(0, 0, 0), + axis=(0, 0, 1), + height=10, + outer_radius=5, + ) + inner_sphere = Sphere(name="inner_sphere", center=(0, 0, 0), radius=2) + _ = RotationVolume( + entities=[cylinder], + spacing_axial=0.5, + spacing_radial=0.5, + spacing_circumferential=0.5, + enclosed_entities=[inner_sphere], + ) + + # does not raise with beta mesher on + with ValidationContext(VOLUME_MESH, beta_mesher_context): + with CGS_unit_system: + cylinder = Cylinder( + name="outer_cyl", + center=(0, 0, 0), + axis=(0, 0, 1), + height=10, + outer_radius=5, + ) + inner_sphere = Sphere(name="inner_sphere", center=(0, 0, 0), radius=2) + _ = RotationVolume( + entities=[cylinder], + spacing_axial=0.5, + spacing_radial=0.5, + spacing_circumferential=0.5, + enclosed_entities=[inner_sphere], + ) + + def test_reuse_of_same_cylinder(mock_validation_context): with mock_validation_context, pytest.raises( pd.ValidationError, From 887c06975e3b59293d48661f654626d8478474e9 Mon Sep 17 00:00:00 2001 From: Shreyas Waghe Date: Mon, 2 Feb 2026 12:13:17 -0500 Subject: [PATCH 04/11] add translator support --- flow360/component/simulation/primitives.py | 11 +++ .../translator/volume_meshing_translator.py | 59 ++++++++++++---- .../test_entity_transformation.py | 8 ++- .../test_volume_meshing_translator.py | 70 +++++++++++++++++++ 4 files changed, 132 insertions(+), 16 deletions(-) diff --git a/flow360/component/simulation/primitives.py b/flow360/component/simulation/primitives.py index 5572766f2..f3e4410c8 100644 --- a/flow360/component/simulation/primitives.py +++ b/flow360/component/simulation/primitives.py @@ -479,6 +479,7 @@ class Sphere(_VolumeEntityBase): ... name="sphere_zone", ... center=(0, 0, 0) * fl.u.m, ... radius=1.5 * fl.u.m, + ... axis=(0, 0, 1), ... ) ==== @@ -488,6 +489,10 @@ class Sphere(_VolumeEntityBase): # pylint: disable=no-member center: LengthType.Point = pd.Field(description="The center point of the sphere.") radius: LengthType.Positive = pd.Field(description="The radius of the sphere.") + axis: Axis = pd.Field( + default=(0, 0, 1), + description="The axis of rotation for the sphere (used in sliding interfaces).", + ) private_attribute_id: str = pd.Field(default_factory=generate_uuid, frozen=True) def _apply_transformation(self, matrix: np.ndarray) -> "Sphere": @@ -496,12 +501,18 @@ def _apply_transformation(self, matrix: np.ndarray) -> "Sphere": matrix, self.center, "Sphere" ) + # Rotate axis + axis_array = np.asarray(self.axis) + transformed_axis = _transform_direction(axis_array, matrix) + new_axis = tuple(transformed_axis / np.linalg.norm(transformed_axis)) + # Scale radius uniformly new_radius = self.radius * uniform_scale return self.model_copy( update={ "center": new_center, + "axis": new_axis, "radius": new_radius, } ) diff --git a/flow360/component/simulation/translator/volume_meshing_translator.py b/flow360/component/simulation/translator/volume_meshing_translator.py index dee646e7e..09082d6b2 100644 --- a/flow360/component/simulation/translator/volume_meshing_translator.py +++ b/flow360/component/simulation/translator/volume_meshing_translator.py @@ -30,6 +30,7 @@ CustomVolume, Cylinder, SeedpointVolume, + Sphere, Surface, ) from flow360.component.simulation.simulation_params import SimulationParams @@ -109,27 +110,45 @@ def passive_spacing_translator(obj: PassiveSpacing): } +def spherical_refinement_translator(obj: RotationVolume): + """ + Translate RotationVolume with Sphere entity. + Sphere only uses spacing_circumferential as maxEdgeLength. + """ + return { + "maxEdgeLength": obj.spacing_circumferential.value.item(), + } + + def rotation_volume_translator(obj: RotationVolume, rotor_disk_names: list): """Setting translation for RotationVolume.""" - setting = cylindrical_refinement_translator(obj) + # Check if the entity is a Sphere (uses different spacing fields) + entity = obj.entities.stored_entities[0] # Only single entity allowed + if is_exact_instance(entity, Sphere): + setting = spherical_refinement_translator(obj) + else: + setting = cylindrical_refinement_translator(obj) + setting["enclosedObjects"] = [] if obj.enclosed_entities is not None: - for entity in obj.enclosed_entities.stored_entities: - if is_exact_instance(entity, Cylinder): - if entity.name in rotor_disk_names: + for enclosed_entity in obj.enclosed_entities.stored_entities: + if is_exact_instance(enclosed_entity, Cylinder): + if enclosed_entity.name in rotor_disk_names: # Current sliding interface encloses a rotor disk # Then we append the interface name which is hardcoded "rotorDisk-"" - setting["enclosedObjects"].append("rotorDisk-" + entity.name) + setting["enclosedObjects"].append("rotorDisk-" + enclosed_entity.name) else: # Current sliding interface encloses another sliding interface # Then we append the interface name which is hardcoded "slidingInterface-"" - setting["enclosedObjects"].append("slidingInterface-" + entity.name) - elif is_exact_instance(entity, AxisymmetricBody): - setting["enclosedObjects"].append("slidingInterface-" + entity.name) - elif is_exact_instance(entity, Box): - setting["enclosedObjects"].append("structuredBox-" + entity.name) - elif is_exact_instance(entity, Surface): - setting["enclosedObjects"].append(entity.name) + setting["enclosedObjects"].append("slidingInterface-" + enclosed_entity.name) + elif is_exact_instance(enclosed_entity, AxisymmetricBody): + setting["enclosedObjects"].append("slidingInterface-" + enclosed_entity.name) + elif is_exact_instance(enclosed_entity, Sphere): + setting["enclosedObjects"].append("slidingInterface-" + enclosed_entity.name) + elif is_exact_instance(enclosed_entity, Box): + setting["enclosedObjects"].append("structuredBox-" + enclosed_entity.name) + elif is_exact_instance(enclosed_entity, Surface): + setting["enclosedObjects"].append(enclosed_entity.name) return setting @@ -187,9 +206,9 @@ def rotor_disks_entity_injector(entity: Cylinder): def rotation_volume_entity_injector( - entity: Union[Cylinder, AxisymmetricBody], use_inhouse_mesher: bool + entity: Union[Cylinder, AxisymmetricBody, Sphere], use_inhouse_mesher: bool ): - """Injector for Cylinder entity in RotationCylinder.""" + """Injector for Cylinder, AxisymmetricBody, or Sphere entity in RotationVolume.""" if isinstance(entity, Cylinder): data = { "name": entity.name, @@ -213,6 +232,18 @@ def rotation_volume_entity_injector( if use_inhouse_mesher: data["type"] = "Axisymmetric" return data + + if isinstance(entity, Sphere): + data = { + "name": entity.name, + "radius": entity.radius.value.item(), + "axisOfRotation": list(entity.axis), + "center": list(entity.center.value), + } + if use_inhouse_mesher: + data["type"] = "Sphere" + return data + return {} diff --git a/tests/simulation/draft_context/test_entity_transformation.py b/tests/simulation/draft_context/test_entity_transformation.py index 5e7caddfd..84fa7cbf0 100644 --- a/tests/simulation/draft_context/test_entity_transformation.py +++ b/tests/simulation/draft_context/test_entity_transformation.py @@ -348,14 +348,18 @@ def test_sphere_uniform_scale(): def test_sphere_rotation(): - """Sphere center should rotate (radius unchanged).""" + """Sphere center and axis should rotate (radius unchanged).""" with SI_unit_system: - sphere = Sphere(name="test_sphere", center=(1, 0, 0) * u.m, radius=5 * u.m) + sphere = Sphere( + name="test_sphere", center=(1, 0, 0) * u.m, radius=5 * u.m, axis=(1, 0, 0) + ) matrix = rotation_z_90() transformed = sphere._apply_transformation(matrix) # Center (1,0,0) rotated 90° around Z = (0,1,0) np.testing.assert_allclose(transformed.center.value, [0, 1, 0], atol=1e-10) + # Axis (1,0,0) rotated 90° around Z = (0,1,0) + np.testing.assert_allclose(transformed.axis, (0, 1, 0), atol=1e-10) # Radius unchanged by rotation np.testing.assert_allclose(transformed.radius.value, 5, atol=1e-10) diff --git a/tests/simulation/translator/test_volume_meshing_translator.py b/tests/simulation/translator/test_volume_meshing_translator.py index 24bce3dd1..272b91b4a 100644 --- a/tests/simulation/translator/test_volume_meshing_translator.py +++ b/tests/simulation/translator/test_volume_meshing_translator.py @@ -42,6 +42,7 @@ Cylinder, GhostCircularPlane, SeedpointVolume, + Sphere, Surface, ) from flow360.component.simulation.services import ValidationCalledBy, validate_model @@ -1142,3 +1143,72 @@ def test_windtunnel_ghost_surface_supported_in_volume_face_refinements(get_surfa assert "faces" in translated assert translated["faces"]["windTunnelFloor"]["type"] == "aniso" assert translated["faces"]["windTunnelInlet"]["type"] == "projectAnisoSpacing" + + +def test_sphere_rotation_volume_translator(get_surface_mesh): + """Test that Sphere entity in RotationVolume is correctly translated to JSON.""" + with SI_unit_system: + outer_sphere = Sphere( + name="outerSphere", + center=(0, 0, 0) * u.m, + radius=10 * u.m, + axis=(1, 0, 0), + ) + inner_sphere = Sphere( + name="sphereInterface", + center=(1, 2, 3) * u.m, + radius=5 * u.m, + axis=(0, 0, 1), + ) + param = SimulationParams( + meshing=MeshingParams( + defaults=MeshingDefaults( + boundary_layer_first_layer_thickness=1e-3, + boundary_layer_growth_rate=1.2, + ), + volume_zones=[ + AutomatedFarfield(), + RotationVolume( + entities=[inner_sphere], + spacing_circumferential=0.2 * u.m, + enclosed_entities=[Surface(name="body")], + ), + RotationVolume( + entities=[outer_sphere], + spacing_circumferential=0.5 * u.m, + enclosed_entities=[inner_sphere, Surface(name="otherBody")], + ), + ], + ), + private_attribute_asset_cache=AssetCache(use_inhouse_mesher=True), + ) + + translated = get_volume_meshing_json(param, get_surface_mesh.mesh_unit) + + assert "slidingInterfaces" in translated + assert len(translated["slidingInterfaces"]) == 2 + + # Find the inner sphere interface + inner_interface = next( + (si for si in translated["slidingInterfaces"] if si["name"] == "sphereInterface"), None + ) + assert inner_interface is not None + assert inner_interface["type"] == "Sphere" + assert inner_interface["radius"] == 5.0 + assert inner_interface["axisOfRotation"] == [0, 0, 1] + assert inner_interface["center"] == [1.0, 2.0, 3.0] + assert inner_interface["maxEdgeLength"] == 0.2 + assert inner_interface["enclosedObjects"] == ["body"] + + # Find the outer sphere interface + outer_interface = next( + (si for si in translated["slidingInterfaces"] if si["name"] == "outerSphere"), None + ) + assert outer_interface is not None + assert outer_interface["type"] == "Sphere" + assert outer_interface["radius"] == 10.0 + assert outer_interface["axisOfRotation"] == [1, 0, 0] + assert outer_interface["center"] == [0, 0, 0] + assert outer_interface["maxEdgeLength"] == 0.5 + assert "slidingInterface-sphereInterface" in outer_interface["enclosedObjects"] + assert "otherBody" in outer_interface["enclosedObjects"] From 47dff41bf2ccb95a2f7c7c09978428a59fd85935 Mon Sep 17 00:00:00 2001 From: Shreyas Waghe Date: Mon, 2 Feb 2026 12:20:35 -0500 Subject: [PATCH 05/11] make all three spacings optional, construction validated for AxisymmetricRefinementBase --- .../simulation/meshing_param/volume_params.py | 35 +++++++++++----- .../test_meshing_param_validation.py | 40 +++++++++++++++++-- 2 files changed, 61 insertions(+), 14 deletions(-) diff --git a/flow360/component/simulation/meshing_param/volume_params.py b/flow360/component/simulation/meshing_param/volume_params.py index 9fb4831fe..efe1d3c4f 100644 --- a/flow360/component/simulation/meshing_param/volume_params.py +++ b/flow360/component/simulation/meshing_param/volume_params.py @@ -152,8 +152,8 @@ class AxisymmetricRefinementBase(Flow360BaseModel, metaclass=ABCMeta): spacing_radial: Optional[LengthType.Positive] = pd.Field( None, description="Spacing along the radial direction." ) - spacing_circumferential: LengthType.Positive = pd.Field( - description="Spacing along the circumferential direction." + spacing_circumferential: Optional[LengthType.Positive] = pd.Field( + None, description="Spacing along the circumferential direction." ) @@ -186,15 +186,19 @@ class AxisymmetricRefinement(AxisymmetricRefinementBase): entities: EntityList[Cylinder] = pd.Field() @pd.model_validator(mode="after") - def _validate_axial_radial_spacing_required(self): - """Ensure spacing_axial and spacing_radial are provided for Cylinder entities.""" + def _validate_all_spacings_required(self): + """Ensure all three spacings are provided for AxisymmetricRefinement.""" if self.spacing_axial is None: raise ValueError( - "`spacing_axial` is required for `AxisymmetricRefinement` with `Cylinder` entities." + "`spacing_axial` is required for `AxisymmetricRefinement`." ) if self.spacing_radial is None: raise ValueError( - "`spacing_radial` is required for `AxisymmetricRefinement` with `Cylinder` entities." + "`spacing_radial` is required for `AxisymmetricRefinement`." + ) + if self.spacing_circumferential is None: + raise ValueError( + "`spacing_circumferential` is required for `AxisymmetricRefinement`." ) return self @@ -429,8 +433,8 @@ def _validate_stationary_enclosed_entities_subset(self, param_info: ParamsValida def _validate_spacing_requirements_by_entity_type(self): """ Validate spacing requirements based on entity type: - - Sphere: only spacing_circumferential is used; spacing_axial and spacing_radial must not be specified - - Cylinder/AxisymmetricBody: spacing_axial and spacing_radial are required + - Sphere: only spacing_circumferential is required; spacing_axial and spacing_radial must not be specified + - Cylinder/AxisymmetricBody: all three spacings are required """ # Check if entity is a Sphere # pylint: disable=no-member @@ -441,6 +445,10 @@ def _validate_spacing_requirements_by_entity_type(self): ) if has_sphere: + if self.spacing_circumferential is None: + raise ValueError( + "`spacing_circumferential` is required for `Sphere` entities in `RotationVolume`." + ) if self.spacing_axial is not None: raise ValueError( "`spacing_axial` must not be specified for `Sphere` entities. " @@ -455,11 +463,18 @@ def _validate_spacing_requirements_by_entity_type(self): if has_cylinder_or_axisymmetric: if self.spacing_axial is None: raise ValueError( - "`spacing_axial` is required for `Cylinder` and `AxisymmetricBody` entities." + "`spacing_axial` is required for `Cylinder` and `AxisymmetricBody` entities " + "in `RotationVolume`." ) if self.spacing_radial is None: raise ValueError( - "`spacing_radial` is required for `Cylinder` and `AxisymmetricBody` entities." + "`spacing_radial` is required for `Cylinder` and `AxisymmetricBody` entities " + "in `RotationVolume`." + ) + if self.spacing_circumferential is None: + raise ValueError( + "`spacing_circumferential` is required for `Cylinder` and `AxisymmetricBody` " + "entities in `RotationVolume`." ) return self diff --git a/tests/simulation/params/meshing_validation/test_meshing_param_validation.py b/tests/simulation/params/meshing_validation/test_meshing_param_validation.py index 429dcbac6..ac9fc019b 100644 --- a/tests/simulation/params/meshing_validation/test_meshing_param_validation.py +++ b/tests/simulation/params/meshing_validation/test_meshing_param_validation.py @@ -371,7 +371,19 @@ def test_sphere_in_rotation_volume_only_in_beta_mesher(): def test_sphere_rotation_volume_spacing_requirements(): """Test spacing requirements for Sphere vs Cylinder/AxisymmetricBody in RotationVolume.""" - # Test 1: Sphere with spacing_axial should raise error + # Test 1: Sphere without spacing_circumferential should raise error + with pytest.raises( + pd.ValidationError, + match=r"`spacing_circumferential` is required for `Sphere` entities", + ): + with ValidationContext(VOLUME_MESH, beta_mesher_context): + with CGS_unit_system: + sphere = Sphere(name="sphere", center=(0, 0, 0), radius=10) + _ = RotationVolume( + entities=[sphere], + ) + + # Test 2: Sphere with spacing_axial should raise error with pytest.raises( pd.ValidationError, match=r"`spacing_axial` must not be specified for `Sphere` entities", @@ -385,7 +397,7 @@ def test_sphere_rotation_volume_spacing_requirements(): spacing_axial=0.5, ) - # Test 2: Sphere with spacing_radial should raise error + # Test 3: Sphere with spacing_radial should raise error with pytest.raises( pd.ValidationError, match=r"`spacing_radial` must not be specified for `Sphere` entities", @@ -399,7 +411,7 @@ def test_sphere_rotation_volume_spacing_requirements(): spacing_radial=0.5, ) - # Test 3: Cylinder without spacing_axial should raise error + # Test 4: Cylinder without spacing_axial should raise error with pytest.raises( pd.ValidationError, match=r"`spacing_axial` is required for `Cylinder` and `AxisymmetricBody` entities", @@ -419,7 +431,7 @@ def test_sphere_rotation_volume_spacing_requirements(): spacing_radial=0.5, ) - # Test 4: Cylinder without spacing_radial should raise error + # Test 5: Cylinder without spacing_radial should raise error with pytest.raises( pd.ValidationError, match=r"`spacing_radial` is required for `Cylinder` and `AxisymmetricBody` entities", @@ -439,6 +451,26 @@ def test_sphere_rotation_volume_spacing_requirements(): spacing_axial=0.5, ) + # Test 6: Cylinder without spacing_circumferential should raise error + with pytest.raises( + pd.ValidationError, + match=r"`spacing_circumferential` is required for `Cylinder` and `AxisymmetricBody`", + ): + with ValidationContext(VOLUME_MESH, beta_mesher_context): + with CGS_unit_system: + cylinder = Cylinder( + name="cyl", + center=(0, 0, 0), + axis=(0, 0, 1), + height=10, + outer_radius=5, + ) + _ = RotationVolume( + entities=[cylinder], + spacing_axial=0.5, + spacing_radial=0.5, + ) + def test_sphere_rotation_volume_with_enclosed_entities(): """Test that Sphere RotationVolume supports enclosed_entities.""" From 59fea8ae5a0505c5e0e28d70f5ed254ac26cedc0 Mon Sep 17 00:00:00 2001 From: Shreyas Waghe Date: Mon, 2 Feb 2026 13:01:15 -0500 Subject: [PATCH 06/11] formatting --- .../simulation/meshing_param/volume_params.py | 12 +++--------- .../draft_context/test_entity_transformation.py | 4 +--- 2 files changed, 4 insertions(+), 12 deletions(-) diff --git a/flow360/component/simulation/meshing_param/volume_params.py b/flow360/component/simulation/meshing_param/volume_params.py index efe1d3c4f..9e81ebe73 100644 --- a/flow360/component/simulation/meshing_param/volume_params.py +++ b/flow360/component/simulation/meshing_param/volume_params.py @@ -189,17 +189,11 @@ class AxisymmetricRefinement(AxisymmetricRefinementBase): def _validate_all_spacings_required(self): """Ensure all three spacings are provided for AxisymmetricRefinement.""" if self.spacing_axial is None: - raise ValueError( - "`spacing_axial` is required for `AxisymmetricRefinement`." - ) + raise ValueError("`spacing_axial` is required for `AxisymmetricRefinement`.") if self.spacing_radial is None: - raise ValueError( - "`spacing_radial` is required for `AxisymmetricRefinement`." - ) + raise ValueError("`spacing_radial` is required for `AxisymmetricRefinement`.") if self.spacing_circumferential is None: - raise ValueError( - "`spacing_circumferential` is required for `AxisymmetricRefinement`." - ) + raise ValueError("`spacing_circumferential` is required for `AxisymmetricRefinement`.") return self diff --git a/tests/simulation/draft_context/test_entity_transformation.py b/tests/simulation/draft_context/test_entity_transformation.py index 84fa7cbf0..4128b6cc9 100644 --- a/tests/simulation/draft_context/test_entity_transformation.py +++ b/tests/simulation/draft_context/test_entity_transformation.py @@ -350,9 +350,7 @@ def test_sphere_uniform_scale(): def test_sphere_rotation(): """Sphere center and axis should rotate (radius unchanged).""" with SI_unit_system: - sphere = Sphere( - name="test_sphere", center=(1, 0, 0) * u.m, radius=5 * u.m, axis=(1, 0, 0) - ) + sphere = Sphere(name="test_sphere", center=(1, 0, 0) * u.m, radius=5 * u.m, axis=(1, 0, 0)) matrix = rotation_z_90() transformed = sphere._apply_transformation(matrix) From 294bd481c38834db36674708845167a283062e29 Mon Sep 17 00:00:00 2001 From: Shreyas Waghe Date: Wed, 4 Feb 2026 11:42:13 -0500 Subject: [PATCH 07/11] resolve PR comments --- .../component/simulation/entity_operation.py | 5 +- .../simulation/meshing_param/volume_params.py | 66 +++++++++---------- .../translator/volume_meshing_translator.py | 14 +++- .../test_meshing_param_validation.py | 6 +- 4 files changed, 45 insertions(+), 46 deletions(-) diff --git a/flow360/component/simulation/entity_operation.py b/flow360/component/simulation/entity_operation.py index 6738424a8..41a6666cc 100644 --- a/flow360/component/simulation/entity_operation.py +++ b/flow360/component/simulation/entity_operation.py @@ -11,6 +11,7 @@ from flow360.component.simulation.framework.entity_utils import generate_uuid from flow360.component.simulation.unit_system import AngleType, LengthType from flow360.component.types import Axis +from flow360.exceptions import Flow360ValueError def rotation_matrix_from_axis_and_angle(axis, angle): @@ -185,10 +186,6 @@ def _validate_uniform_scale_and_transform_center( Raises: Flow360ValueError: If the matrix has non-uniform scaling """ - # Import here to avoid circular imports - # pylint: disable=import-outside-toplevel - from flow360.exceptions import Flow360ValueError - # Validate uniform scaling if not _is_uniform_scale(matrix): scale_factors = _extract_scale_from_matrix(matrix) diff --git a/flow360/component/simulation/meshing_param/volume_params.py b/flow360/component/simulation/meshing_param/volume_params.py index 9e81ebe73..76ec84fd7 100644 --- a/flow360/component/simulation/meshing_param/volume_params.py +++ b/flow360/component/simulation/meshing_param/volume_params.py @@ -4,7 +4,6 @@ # pylint: disable=too-many-lines -from abc import ABCMeta from typing import Literal, Optional, Union import pydantic as pd @@ -142,22 +141,7 @@ def _validate_only_in_beta_mesher(self, param_info: ParamsValidationInfo): raise ValueError("`StructuredBoxRefinement` is only supported with the beta mesher.") -class AxisymmetricRefinementBase(Flow360BaseModel, metaclass=ABCMeta): - """Base class for all refinements that requires spacing in axial, radial and circumferential directions.""" - - # pylint: disable=no-member - spacing_axial: Optional[LengthType.Positive] = pd.Field( - None, description="Spacing along the axial direction." - ) - spacing_radial: Optional[LengthType.Positive] = pd.Field( - None, description="Spacing along the radial direction." - ) - spacing_circumferential: Optional[LengthType.Positive] = pd.Field( - None, description="Spacing along the circumferential direction." - ) - - -class AxisymmetricRefinement(AxisymmetricRefinementBase): +class AxisymmetricRefinement(Flow360BaseModel): """ - The mesh inside the :class:`AxisymmetricRefinement` is semi-structured. - The :class:`AxisymmetricRefinement` cannot enclose/intersect with other objects. @@ -184,20 +168,19 @@ class AxisymmetricRefinement(AxisymmetricRefinementBase): "AxisymmetricRefinement", frozen=True ) entities: EntityList[Cylinder] = pd.Field() - - @pd.model_validator(mode="after") - def _validate_all_spacings_required(self): - """Ensure all three spacings are provided for AxisymmetricRefinement.""" - if self.spacing_axial is None: - raise ValueError("`spacing_axial` is required for `AxisymmetricRefinement`.") - if self.spacing_radial is None: - raise ValueError("`spacing_radial` is required for `AxisymmetricRefinement`.") - if self.spacing_circumferential is None: - raise ValueError("`spacing_circumferential` is required for `AxisymmetricRefinement`.") - return self + # pylint: disable=no-member + spacing_axial: LengthType.Positive = pd.Field( + description="Spacing along the axial direction." + ) + spacing_radial: LengthType.Positive = pd.Field( + description="Spacing along the radial direction." + ) + spacing_circumferential: LengthType.Positive = pd.Field( + description="Spacing along the circumferential direction." + ) -class RotationVolume(AxisymmetricRefinementBase): +class RotationVolume(Flow360BaseModel): """ Creates a rotation volume mesh using cylindrical, axisymmetric body, or sphere entities. @@ -285,6 +268,16 @@ class RotationVolume(AxisymmetricRefinementBase): "(excluded from rotation)." ), ) + # pylint: disable=no-member + spacing_axial: Optional[LengthType.Positive] = pd.Field( + None, description="Spacing along the axial direction." + ) + spacing_radial: Optional[LengthType.Positive] = pd.Field( + None, description="Spacing along the radial direction." + ) + spacing_circumferential: Optional[LengthType.Positive] = pd.Field( + None, description="Spacing along the circumferential direction." + ) @contextual_field_validator("entities", mode="after") @classmethod @@ -423,8 +416,8 @@ def _validate_stationary_enclosed_entities_subset(self, param_info: ParamsValida return self - @pd.model_validator(mode="after") - def _validate_spacing_requirements_by_entity_type(self): + @contextual_model_validator(mode="after") + def _validate_spacing_requirements_by_entity_type(self, param_info: ParamsValidationInfo): """ Validate spacing requirements based on entity type: - Sphere: only spacing_circumferential is required; spacing_axial and spacing_radial must not be specified @@ -432,10 +425,11 @@ def _validate_spacing_requirements_by_entity_type(self): """ # Check if entity is a Sphere # pylint: disable=no-member - has_sphere = any(isinstance(entity, Sphere) for entity in self.entities.stored_entities) + expanded_entities = param_info.expand_entity_list(self.entities) + has_sphere = any(isinstance(entity, Sphere) for entity in expanded_entities) has_cylinder_or_axisymmetric = any( isinstance(entity, (Cylinder, AxisymmetricBody)) - for entity in self.entities.stored_entities + for entity in expanded_entities ) if has_sphere: @@ -457,17 +451,17 @@ def _validate_spacing_requirements_by_entity_type(self): if has_cylinder_or_axisymmetric: if self.spacing_axial is None: raise ValueError( - "`spacing_axial` is required for `Cylinder` and `AxisymmetricBody` entities " + "`spacing_axial` is required for `Cylinder` or `AxisymmetricBody` entities " "in `RotationVolume`." ) if self.spacing_radial is None: raise ValueError( - "`spacing_radial` is required for `Cylinder` and `AxisymmetricBody` entities " + "`spacing_radial` is required for `Cylinder` or `AxisymmetricBody` entities " "in `RotationVolume`." ) if self.spacing_circumferential is None: raise ValueError( - "`spacing_circumferential` is required for `Cylinder` and `AxisymmetricBody` " + "`spacing_circumferential` is required for `Cylinder` or `AxisymmetricBody` " "entities in `RotationVolume`." ) diff --git a/flow360/component/simulation/translator/volume_meshing_translator.py b/flow360/component/simulation/translator/volume_meshing_translator.py index 09082d6b2..64b51d220 100644 --- a/flow360/component/simulation/translator/volume_meshing_translator.py +++ b/flow360/component/simulation/translator/volume_meshing_translator.py @@ -14,7 +14,6 @@ from flow360.component.simulation.meshing_param.volume_params import ( AutomatedFarfield, AxisymmetricRefinement, - AxisymmetricRefinementBase, CustomZones, MeshSliceOutput, RotationCylinder, @@ -56,10 +55,19 @@ def uniform_refinement_translator(obj: UniformRefinement): return {"spacing": obj.spacing.value.item()} -def cylindrical_refinement_translator(obj: AxisymmetricRefinementBase): +def cylindrical_refinement_translator(obj: Union[AxisymmetricRefinement, RotationVolume]): """ - Translate CylindricalRefinementBase. [SlidingInterface + RotorDisks] + Translate AxisymmetricRefinement or RotationVolume with Cylinder/AxisymmetricBody entities. + + Note: This should not be called for RotationVolume with Sphere entities. + Use spherical_refinement_translator() for those cases. """ + if obj.spacing_axial is None or obj.spacing_radial is None or obj.spacing_circumferential is None: + raise ValueError( + "cylindrical_refinement_translator requires all spacing fields to be specified. " + "For Sphere entities in RotationVolume, use spherical_refinement_translator instead." + ) + return { "spacingAxial": obj.spacing_axial.value.item(), "spacingRadial": obj.spacing_radial.value.item(), diff --git a/tests/simulation/params/meshing_validation/test_meshing_param_validation.py b/tests/simulation/params/meshing_validation/test_meshing_param_validation.py index ac9fc019b..d892f3099 100644 --- a/tests/simulation/params/meshing_validation/test_meshing_param_validation.py +++ b/tests/simulation/params/meshing_validation/test_meshing_param_validation.py @@ -414,7 +414,7 @@ def test_sphere_rotation_volume_spacing_requirements(): # Test 4: Cylinder without spacing_axial should raise error with pytest.raises( pd.ValidationError, - match=r"`spacing_axial` is required for `Cylinder` and `AxisymmetricBody` entities", + match=r"`spacing_axial` is required for `Cylinder` or `AxisymmetricBody` entities", ): with ValidationContext(VOLUME_MESH, beta_mesher_context): with CGS_unit_system: @@ -434,7 +434,7 @@ def test_sphere_rotation_volume_spacing_requirements(): # Test 5: Cylinder without spacing_radial should raise error with pytest.raises( pd.ValidationError, - match=r"`spacing_radial` is required for `Cylinder` and `AxisymmetricBody` entities", + match=r"`spacing_radial` is required for `Cylinder` or `AxisymmetricBody` entities", ): with ValidationContext(VOLUME_MESH, beta_mesher_context): with CGS_unit_system: @@ -454,7 +454,7 @@ def test_sphere_rotation_volume_spacing_requirements(): # Test 6: Cylinder without spacing_circumferential should raise error with pytest.raises( pd.ValidationError, - match=r"`spacing_circumferential` is required for `Cylinder` and `AxisymmetricBody`", + match=r"`spacing_circumferential` is required for `Cylinder` or `AxisymmetricBody`", ): with ValidationContext(VOLUME_MESH, beta_mesher_context): with CGS_unit_system: From e9ab6d8f3f0bc533aea06fbbf26f471a722d4fc4 Mon Sep 17 00:00:00 2001 From: Shreyas Waghe Date: Wed, 4 Feb 2026 11:44:38 -0500 Subject: [PATCH 08/11] format --- .../simulation/meshing_param/volume_params.py | 7 ++----- .../simulation/translator/volume_meshing_translator.py | 10 +++++++--- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/flow360/component/simulation/meshing_param/volume_params.py b/flow360/component/simulation/meshing_param/volume_params.py index 76ec84fd7..1b31807bc 100644 --- a/flow360/component/simulation/meshing_param/volume_params.py +++ b/flow360/component/simulation/meshing_param/volume_params.py @@ -169,9 +169,7 @@ class AxisymmetricRefinement(Flow360BaseModel): ) entities: EntityList[Cylinder] = pd.Field() # pylint: disable=no-member - spacing_axial: LengthType.Positive = pd.Field( - description="Spacing along the axial direction." - ) + spacing_axial: LengthType.Positive = pd.Field(description="Spacing along the axial direction.") spacing_radial: LengthType.Positive = pd.Field( description="Spacing along the radial direction." ) @@ -428,8 +426,7 @@ def _validate_spacing_requirements_by_entity_type(self, param_info: ParamsValida expanded_entities = param_info.expand_entity_list(self.entities) has_sphere = any(isinstance(entity, Sphere) for entity in expanded_entities) has_cylinder_or_axisymmetric = any( - isinstance(entity, (Cylinder, AxisymmetricBody)) - for entity in expanded_entities + isinstance(entity, (Cylinder, AxisymmetricBody)) for entity in expanded_entities ) if has_sphere: diff --git a/flow360/component/simulation/translator/volume_meshing_translator.py b/flow360/component/simulation/translator/volume_meshing_translator.py index 64b51d220..e42d3cea0 100644 --- a/flow360/component/simulation/translator/volume_meshing_translator.py +++ b/flow360/component/simulation/translator/volume_meshing_translator.py @@ -58,16 +58,20 @@ def uniform_refinement_translator(obj: UniformRefinement): def cylindrical_refinement_translator(obj: Union[AxisymmetricRefinement, RotationVolume]): """ Translate AxisymmetricRefinement or RotationVolume with Cylinder/AxisymmetricBody entities. - + Note: This should not be called for RotationVolume with Sphere entities. Use spherical_refinement_translator() for those cases. """ - if obj.spacing_axial is None or obj.spacing_radial is None or obj.spacing_circumferential is None: + if ( + obj.spacing_axial is None + or obj.spacing_radial is None + or obj.spacing_circumferential is None + ): raise ValueError( "cylindrical_refinement_translator requires all spacing fields to be specified. " "For Sphere entities in RotationVolume, use spherical_refinement_translator instead." ) - + return { "spacingAxial": obj.spacing_axial.value.item(), "spacingRadial": obj.spacing_radial.value.item(), From 88e7487f548dbaf7ca20309106c5f651ede81e5f Mon Sep 17 00:00:00 2001 From: Shreyas Waghe Date: Wed, 4 Feb 2026 12:15:54 -0500 Subject: [PATCH 09/11] resolve bugbot comment --- .../simulation/meshing_param/volume_params.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/flow360/component/simulation/meshing_param/volume_params.py b/flow360/component/simulation/meshing_param/volume_params.py index 1b31807bc..c5cf76345 100644 --- a/flow360/component/simulation/meshing_param/volume_params.py +++ b/flow360/component/simulation/meshing_param/volume_params.py @@ -279,16 +279,15 @@ class RotationVolume(Flow360BaseModel): @contextual_field_validator("entities", mode="after") @classmethod - def _validate_single_instance_in_entity_list(cls, values): + def _validate_single_instance_in_entity_list(cls, values, param_info: ParamsValidationInfo): """ [CAPABILITY-LIMITATION] - Multiple instances in the entities is not allowed. - Because enclosed_entities will almost certain be different. - `enclosed_entities` is planned to be auto_populated in the future. + Only single instance is allowed in entities for each `RotationVolume`. """ - # pylint: disable=protected-access # Note: Should be fine without expansion since we only allow Draft entities here. - if len(values.stored_entities) > 1: + # But using expand_entity_list for consistency and future-proofing. + expanded_entities = param_info.expand_entity_list(values) + if len(expanded_entities) > 1: raise ValueError( "Only single instance is allowed in entities for each `RotationVolume`." ) @@ -304,11 +303,11 @@ def _validate_cylinder_name_length(cls, values, param_info: ParamsValidationInfo """ if param_info.is_beta_mesher: return values - # Note: Should be fine without expansion since we only allow Draft entities here. + expanded_entities = param_info.expand_entity_list(values) cgns_max_zone_name_length = 32 max_cylinder_name_length = cgns_max_zone_name_length - len("rotatingBlock-") - for entity in values.stored_entities: + for entity in expanded_entities: if isinstance(entity, Cylinder) and len(entity.name) > max_cylinder_name_length: raise ValueError( f"The name ({entity.name}) of `Cylinder` entity in `RotationVolume` " @@ -349,7 +348,8 @@ def _validate_entities_beta_mesher_only(cls, values, param_info: ParamsValidatio if param_info.is_beta_mesher: return values - for entity in values.stored_entities: + expanded_entities = param_info.expand_entity_list(values) + for entity in expanded_entities: if isinstance(entity, AxisymmetricBody): raise ValueError( "`AxisymmetricBody` entity for `RotationVolume` is only supported with the beta mesher." From f8da43d21ce0cb85a2a7614fabc6e3141072f780 Mon Sep 17 00:00:00 2001 From: Shreyas W Date: Tue, 10 Feb 2026 10:47:37 -0500 Subject: [PATCH 10/11] update error message --- flow360/component/simulation/meshing_param/volume_params.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/flow360/component/simulation/meshing_param/volume_params.py b/flow360/component/simulation/meshing_param/volume_params.py index c5cf76345..6b65d21a0 100644 --- a/flow360/component/simulation/meshing_param/volume_params.py +++ b/flow360/component/simulation/meshing_param/volume_params.py @@ -273,6 +273,10 @@ class RotationVolume(Flow360BaseModel): spacing_radial: Optional[LengthType.Positive] = pd.Field( None, description="Spacing along the radial direction." ) + # This is actually a required field for all of Sphere, Cylinder, AxisymmetricBody entity + # RotationVolumes, but making this not Optional causes validation to be triggered in pydantic + # vs in validator below, giving different error messages than what we want. + # Use of validation_default=False messes up schemas. spacing_circumferential: Optional[LengthType.Positive] = pd.Field( None, description="Spacing along the circumferential direction." ) From d11e036c4b1dca7b2bc1db4e6622bfcf73219971 Mon Sep 17 00:00:00 2001 From: Shreyas W Date: Tue, 10 Feb 2026 16:08:35 -0500 Subject: [PATCH 11/11] update msg --- flow360/component/simulation/meshing_param/volume_params.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flow360/component/simulation/meshing_param/volume_params.py b/flow360/component/simulation/meshing_param/volume_params.py index 6b65d21a0..60322d0f4 100644 --- a/flow360/component/simulation/meshing_param/volume_params.py +++ b/flow360/component/simulation/meshing_param/volume_params.py @@ -471,7 +471,7 @@ def _validate_spacing_requirements_by_entity_type(self, param_info: ParamsValida @deprecated( "The `RotationCylinder` class is deprecated! Use `RotationVolume`," - "which supports both `Cylinder` and `AxisymmetricBody` entities instead." + "which supports `Cylinder`, `AxisymmetricBody`, and `Sphere` entities instead." ) class RotationCylinder(RotationVolume): """