diff --git a/flow360/component/simulation/framework/base_model.py b/flow360/component/simulation/framework/base_model.py index deb53de23..64e06f48f 100644 --- a/flow360/component/simulation/framework/base_model.py +++ b/flow360/component/simulation/framework/base_model.py @@ -31,33 +31,31 @@ ] -def _preprocess_nested_list(value, required_by, params, exclude, flow360_unit_system): - new_list = [] - for i, item in enumerate(value): - # Extend the 'required_by' path with the current index. - new_required_by = required_by + [f"{i}"] - if isinstance(item, list): - # Recursively process nested lists. - new_list.append( - _preprocess_nested_list(item, new_required_by, params, exclude, flow360_unit_system) - ) - elif isinstance(item, Flow360BaseModel): - # Process Flow360BaseModel instances. - new_list.append( - item.preprocess( - params=params, - required_by=new_required_by, - exclude=exclude, - flow360_unit_system=flow360_unit_system, - ) - ) - elif need_conversion(item): - # Convert nested dimensioned values to base unit system - new_list.append(item.in_base(flow360_unit_system)) - else: - # Return item unchanged if it doesn't need processing. - new_list.append(item) - return new_list +def _preprocess_nested(value, required_by, params, exclude, flow360_unit_system): + """Recursively convert dimensioned values inside lists, dicts, and models.""" + # Recurse into containers + if isinstance(value, list): + return [ + _preprocess_nested(item, required_by + [f"{i}"], params, exclude, flow360_unit_system) + for i, item in enumerate(value) + ] + if isinstance(value, dict): + return { + k: _preprocess_nested(v, required_by + [f"{k}"], params, exclude, flow360_unit_system) + for k, v in value.items() + } + # Process Flow360BaseModel instances + if isinstance(value, Flow360BaseModel): + return value.preprocess( + params=params, + required_by=required_by, + exclude=exclude, + flow360_unit_system=flow360_unit_system, + ) + # Convert dimensioned values to base unit system + if need_conversion(value): + return value.in_base(flow360_unit_system) + return value class Conflicts(pd.BaseModel): @@ -678,9 +676,8 @@ def preprocess( exclude=exclude, flow360_unit_system=flow360_unit_system, ) - elif isinstance(value, list): - # Use the helper to handle nested lists. - solver_values[property_name] = _preprocess_nested_list( + elif isinstance(value, (list, dict)): + solver_values[property_name] = _preprocess_nested( value, [loc_name], params, exclude, flow360_unit_system ) diff --git a/flow360/component/simulation/meshing_param/volume_params.py b/flow360/component/simulation/meshing_param/volume_params.py index 2abf82009..fae6858a9 100644 --- a/flow360/component/simulation/meshing_param/volume_params.py +++ b/flow360/component/simulation/meshing_param/volume_params.py @@ -4,9 +4,11 @@ # pylint: disable=too-many-lines -from typing import Literal, Optional, Union +from typing import Annotated, Dict, Literal, Optional, Union import pydantic as pd +from pydantic.functional_serializers import PlainSerializer +from pydantic.functional_validators import BeforeValidator from typing_extensions import deprecated import flow360.component.simulation.units as u @@ -15,6 +17,7 @@ from flow360.component.simulation.outputs.output_entities import Slice from flow360.component.simulation.primitives import ( AxisymmetricBody, + AxisymmetricSegment, Box, CustomVolume, Cylinder, @@ -27,7 +30,10 @@ WindTunnelGhostSurface, compute_bbox_tolerance, ) -from flow360.component.simulation.unit_system import LengthType +from flow360.component.simulation.unit_system import ( + LengthType, + _dimensioned_type_serializer, +) from flow360.component.simulation.validation.validation_context import ( ParamsValidationInfo, add_validation_warning, @@ -51,16 +57,44 @@ def __get__(self, obj, owner): return self.func(owner) +def _serialize_segment_spacing(d): + """Serialize Dict[AxisymmetricSegment, LengthType] as a list of {segment, spacing} pairs.""" + if d is None: + return None + return [ + {"segment": seg.model_dump(), "spacing": _dimensioned_type_serializer(spacing)} + for seg, spacing in d.items() + ] + + +def _deserialize_segment_spacing(v): + """Deserialize list of {segment, spacing} pairs back into Dict[AxisymmetricSegment, LengthType].""" + if isinstance(v, list): + return {AxisymmetricSegment.model_validate(item["segment"]): item["spacing"] for item in v} + return v + + +SegmentSpacingDict = Annotated[ + Dict[AxisymmetricSegment, LengthType.Positive], # pylint: disable=no-member + BeforeValidator(_deserialize_segment_spacing), + PlainSerializer(_serialize_segment_spacing), +] + + class UniformRefinement(Flow360BaseModel): """ Uniform spacing refinement inside specified region of mesh. + For AxisymmetricBody entities, specify per-segment spacing overrides via ``face_spacing``. Example ------- >>> fl.UniformRefinement( ... entities=[cylinder, box, axisymmetric_body, sphere], - ... spacing=1*fl.u.cm + ... spacing=1*fl.u.cm, + ... face_spacing={ + ... axisymmetric_body.segment(2): 0.2*fl.u.cm, + ... } ... ) ==== @@ -79,6 +113,12 @@ class UniformRefinement(Flow360BaseModel): None, description="Whether to include the refinement in the surface mesh. Defaults to True when using snappy.", ) + face_spacing: Optional[SegmentSpacingDict] = pd.Field( + None, + description="Per-segment spacing overrides for AxisymmetricBody entities. " + "Use ``body.segment(i)`` as keys, where segment i is defined between " + "profile_curve[i] and profile_curve[i+1]. Segments without overrides use the default ``spacing``.", + ) @contextual_field_validator("entities", mode="after") @classmethod @@ -131,6 +171,47 @@ def check_project_to_surface_with_snappy(self, param_info: ParamsValidationInfo) return self + @contextual_model_validator(mode="after") + def check_face_spacing(self, param_info: ParamsValidationInfo): + """Validate face_spacing keys reference registered AxisymmetricBody entities.""" + if self.face_spacing is None: + return self + + registry = param_info.get_entity_registry() + if registry is None: + return self + + # Build set of entity ids in this refinement's entities list + entity_ids_in_refinement = set() + if self.entities is not None: + for entity in param_info.expand_entity_list(self.entities): + if isinstance(entity, AxisymmetricBody): + entity_ids_in_refinement.add(entity.private_attribute_id) + + for seg in self.face_spacing: + entity = registry.find_by_type_name_and_id( + entity_type="AxisymmetricBody", + entity_id=seg.entity_id, + ) + if entity is None: + raise ValueError( + f"face_spacing references entity '{seg.entity_name}' " + f"(id={seg.entity_id}) which is not a registered AxisymmetricBody." + ) + if seg.entity_id not in entity_ids_in_refinement: + add_validation_warning( + f"face_spacing references entity '{seg.entity_name}', which is not in " + f"this refinement's entities list. Override will be ignored." + ) + num_segments = len(entity.profile_curve) - 1 + if seg.index >= num_segments: + raise ValueError( + f"Segment index {seg.index} for entity '{entity.name}' is out of range. " + f"Valid range: [0, {num_segments - 1}]." + ) + + return self + class StructuredBoxRefinement(Flow360BaseModel): """ diff --git a/flow360/component/simulation/primitives.py b/flow360/component/simulation/primitives.py index ab5dbacf0..139e0f802 100644 --- a/flow360/component/simulation/primitives.py +++ b/flow360/component/simulation/primitives.py @@ -303,6 +303,17 @@ class Edge(EntityBase): ) +@final +class AxisymmetricSegment(pd.BaseModel): + """Reference to the region bounded by a segment of an AxisymmetricBody profile curve.""" + + model_config = pd.ConfigDict(frozen=True) + type_name: Literal["AxisymmetricSegment"] = pd.Field("AxisymmetricSegment", frozen=True) + entity_id: str = pd.Field(description="The private_attribute_id of the owning entity.") + entity_name: str = pd.Field(description="The name of the owning entity.") + index: int = pd.Field(ge=0, description="Index along the profile curve (0-based).") + + @final class GenericVolume(_VolumeEntityBase): """ @@ -672,6 +683,15 @@ def _check_profile_curve_has_no_duplicates(cls, curve): return curve + def segment(self, index: int) -> AxisymmetricSegment: + """Return an AxisymmetricSegment reference for the given profile curve segment index.""" + num_segments = len(self.profile_curve) - 1 + if index < 0 or index >= num_segments: + raise IndexError(f"Segment index {index} out of range [0, {num_segments - 1}]") + return AxisymmetricSegment( + entity_id=self.private_attribute_id, entity_name=self.name, index=index + ) + def _apply_transformation(self, matrix: np.ndarray) -> "AxisymmetricBody": """Apply 3x4 transformation matrix with uniform scale validation.""" new_center, uniform_scale = _validate_uniform_scale_and_transform_center( diff --git a/flow360/component/simulation/translator/volume_meshing_translator.py b/flow360/component/simulation/translator/volume_meshing_translator.py index 66a893c7f..dc85415fa 100644 --- a/flow360/component/simulation/translator/volume_meshing_translator.py +++ b/flow360/component/simulation/translator/volume_meshing_translator.py @@ -52,7 +52,35 @@ def uniform_refinement_translator(obj: UniformRefinement): """ Translate UniformRefinement. """ - return {"spacing": obj.spacing.value.item()} + result = {"spacing": obj.spacing.value.item()} + if obj.face_spacing: + grouped = {} + for seg, spacing in obj.face_spacing.items(): + grouped.setdefault(seg.entity_name, {})[seg.index] = spacing.value.item() + result["_face_spacing"] = grouped + return result + + +def _expand_face_spacing(refinement_list: list): + """Expand sparse face_spacing into dense faceSpacings arrays. + + Each item in the list may contain a '_face_spacing' key from uniform_refinement_translator. + For AxisymmetricBody entities whose name appears in the overrides, this expands the sparse + {face_idx: spacing} dict into a dense list and strips the internal key. + """ + for item in refinement_list: + overrides = item.pop("_face_spacing", None) + if not overrides: + continue + if item.get("type") != "Axisymmetric": + continue + entity_name = item.get("name") + if entity_name not in overrides: + continue + num_faces = len(item["profileCurve"]) - 1 + default_spacing = item["spacing"] + face_overrides = overrides[entity_name] + item["faceSpacings"] = [face_overrides.get(i, default_spacing) for i in range(num_faces)] def cylindrical_refinement_translator(obj: Union[AxisymmetricRefinement, RotationVolume]): @@ -534,6 +562,7 @@ def get_volume_meshing_json(input_params: SimulationParams, mesh_units): to_list=True, entity_injection_func=refinement_entity_injector, ) + _expand_face_spacing(uniform_refinement_list) rotor_disk_refinement = translate_setting_and_apply_to_all_entities( refinements, AxisymmetricRefinement, 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 9dafe82bc..63af75adb 100644 --- a/tests/simulation/params/meshing_validation/test_meshing_param_validation.py +++ b/tests/simulation/params/meshing_validation/test_meshing_param_validation.py @@ -4,6 +4,7 @@ import pytest from flow360 import u +from flow360.component.simulation.framework.entity_registry import EntityRegistry from flow360.component.simulation.framework.param_utils import AssetCache from flow360.component.simulation.meshing_param import snappy from flow360.component.simulation.meshing_param.face_params import ( @@ -857,6 +858,127 @@ def test_axisymmetric_body_in_uniform_refinement(): ) +def test_axisymmetric_segment_class(): + with SI_unit_system: + body = AxisymmetricBody( + name="body1", + axis=(1, 0, 0), + center=(0, 0, 0), + profile_curve=[(0, 0), (0, 1), (2, 1), (2, 0)], + ) + + f = body.segment(0) + assert f.entity_id == body.private_attribute_id + assert f.entity_name == "body1" + assert f.index == 0 + + # 3 segments -> indices 0-2 are valid + body.segment(2) + with pytest.raises(IndexError): + body.segment(3) + with pytest.raises(IndexError): + body.segment(-1) + + # Same entity + index -> equal, so usable as dict key + assert body.segment(1) == body.segment(1) + assert body.segment(0) != body.segment(1) + assert {body.segment(1): "a"}[body.segment(1)] == "a" + + # Different entity, same index -> not equal + other = AxisymmetricBody( + name="body2", + axis=(1, 0, 0), + center=(0, 0, 0), + profile_curve=[(0, 0), (0, 1), (2, 1), (2, 0)], + ) + assert body.segment(0) != other.segment(0) + + +def test_face_spacing_validation(): + with SI_unit_system: + body = AxisymmetricBody( + name="body", + axis=(0, 0, 1), + center=(0, 0, 0), + profile_curve=[(0, 0), (0, 1), (1, 1), (1, 0)], + ) + + # Valid: override face 1 of 3 faces + UniformRefinement( + entities=[body], + spacing=0.5 * u.m, + face_spacing={body.segment(1): 0.1 * u.m}, + ) + + # Invalid: face index out of range + with pytest.raises(IndexError, match="out of range"): + UniformRefinement( + entities=[body], + spacing=0.5 * u.m, + face_spacing={body.segment(5): 0.1 * u.m}, + ) + + # Invalid: non-AxisymmetricSegment key (pydantic type validation rejects it) + with pytest.raises(pd.ValidationError): + UniformRefinement( + entities=[body], + spacing=0.5 * u.m, + face_spacing={42: 0.1 * u.m}, + ) + + +def test_face_spacing_contextual_validation(): + with SI_unit_system: + body = AxisymmetricBody( + name="body", + axis=(0, 0, 1), + center=(0, 0, 0), + profile_curve=[(0, 0), (0, 1), (1, 1), (1, 0)], + ) + other = AxisymmetricBody( + name="other", + axis=(0, 0, 1), + center=(1, 0, 0), + profile_curve=[(0, 0), (0, 1), (1, 0)], + ) + + # Registry contains both bodies + registry = EntityRegistry() + registry.register(body) + registry.register(other) + ctx = ParamsValidationInfo({}, []) + ctx.is_beta_mesher = True + ctx._entity_registry = registry + + # Warning: other exists in registry, but not in this refinement's entities + mock_context = ValidationContext(VOLUME_MESH, ctx) + with mock_context: + UniformRefinement( + entities=[body], + spacing=0.5 * u.m, + face_spacing={other.segment(0): 0.1 * u.m}, + ) + assert any( + "not in this refinement's entities list" in w["msg"] + for w in mock_context.validation_warnings + ) + + # Error: reference an entity not in registry at all (stale) + removed = AxisymmetricBody( + name="removed", + axis=(0, 0, 1), + center=(2, 0, 0), + profile_curve=[(0, 0), (0, 1), (1, 0)], + ) + with pytest.raises(pd.ValidationError, match="not a registered AxisymmetricBody"): + with ValidationContext(VOLUME_MESH, ctx): + UniformRefinement( + entities=[body], + spacing=0.5 * u.m, + face_spacing={removed.segment(0): 0.1 * u.m}, + ) + + def test_sphere_in_uniform_refinement(): with ValidationContext(VOLUME_MESH, beta_mesher_context): with CGS_unit_system: diff --git a/tests/simulation/translator/test_volume_meshing_translator.py b/tests/simulation/translator/test_volume_meshing_translator.py index ec874cd83..4e3ab0bb8 100644 --- a/tests/simulation/translator/test_volume_meshing_translator.py +++ b/tests/simulation/translator/test_volume_meshing_translator.py @@ -2052,3 +2052,189 @@ def test_farfield_enclosed_entities_mixed_direct_and_custom_volume(get_surface_m "slidingInterface-ball", "slidingInterface-rotor", ] + + +def test_face_spacing_single_body(get_surface_mesh): + """Per-face spacing overrides produce dense faceSpacings array.""" + with SI_unit_system: + body = AxisymmetricBody( + name="axisymm_body", + axis=(1, 0, 0), + center=(0, 0, 0), + profile_curve=[(0, 0), (0, 1), (1, 2), (2, 1), (2, 0)], + ) + param = SimulationParams( + meshing=MeshingParams( + defaults=MeshingDefaults( + boundary_layer_first_layer_thickness=1e-4 * u.m, + ), + volume_zones=[AutomatedFarfield()], + refinements=[ + UniformRefinement( + entities=[body], + spacing=0.5 * u.m, + face_spacing={body.segment(1): 0.1 * u.m, body.segment(3): 0.2 * u.m}, + ), + ], + ), + private_attribute_asset_cache=AssetCache(use_inhouse_mesher=True), + ) + + translated = get_volume_meshing_json(param, get_surface_mesh.mesh_unit) + ref = translated["refinement"][0] + assert ref["type"] == "Axisymmetric" + assert ref["spacing"] == 0.5 + assert ref["faceSpacings"] == [0.5, 0.1, 0.5, 0.2] + assert "_face_spacing" not in ref + + +def test_face_spacing_no_overrides(get_surface_mesh): + """Without face_spacing, no faceSpacings key should appear.""" + with SI_unit_system: + body = AxisymmetricBody( + name="body", + axis=(0, 0, 1), + center=(0, 0, 0), + profile_curve=[(0, 0), (0, 1), (1, 0)], + ) + param = SimulationParams( + meshing=MeshingParams( + defaults=MeshingDefaults( + boundary_layer_first_layer_thickness=1e-4 * u.m, + ), + volume_zones=[AutomatedFarfield()], + refinements=[ + UniformRefinement(entities=[body], spacing=0.5 * u.m), + ], + ), + private_attribute_asset_cache=AssetCache(use_inhouse_mesher=True), + ) + + translated = get_volume_meshing_json(param, get_surface_mesh.mesh_unit) + ref = translated["refinement"][0] + assert "faceSpacings" not in ref + assert "_face_spacing" not in ref + + +def test_face_spacing_mixed_entities(get_surface_mesh): + """face_spacing with both AxisymmetricBody and Box entities.""" + with SI_unit_system: + body1 = AxisymmetricBody( + name="body1", + axis=(0, 0, 1), + center=(0, 0, 0), + profile_curve=[(0, 0), (0, 1), (1, 1), (1, 0)], + ) + body2 = AxisymmetricBody( + name="body2", + axis=(1, 0, 0), + center=(5, 0, 0), + profile_curve=[(0, 0), (0, 2), (3, 2), (3, 0)], + ) + box = Box.from_principal_axes( + name="mybox", + center=(0, 0, 0), + size=(1, 1, 1), + axes=((1, 0, 0), (0, 1, 0)), + ) + param = SimulationParams( + meshing=MeshingParams( + defaults=MeshingDefaults( + boundary_layer_first_layer_thickness=1e-5 * u.m, + ), + volume_zones=[AutomatedFarfield()], + refinements=[ + UniformRefinement( + entities=[body1, box, body2], + spacing=1.0 * u.m, + face_spacing={ + body1.segment(0): 0.1 * u.m, + body2.segment(1): 0.2 * u.m, + body2.segment(2): 0.3 * u.m, + }, + ), + ], + ), + private_attribute_asset_cache=AssetCache(use_inhouse_mesher=True), + ) + + translated = get_volume_meshing_json(param, get_surface_mesh.mesh_unit) + refs = translated["refinement"] + assert len(refs) == 3 + + body1_ref = refs[0] + assert body1_ref["faceSpacings"] == [0.1, 1.0, 1.0] + + box_ref = refs[1] + assert "faceSpacings" not in box_ref + assert "_face_spacing" not in box_ref + + body2_ref = refs[2] + assert body2_ref["faceSpacings"] == [1.0, 0.2, 0.3] + + +def test_face_spacing_mixed_units(get_surface_mesh): + """face_spacing values in different units are converted to mesh units.""" + with SI_unit_system: + body = AxisymmetricBody( + name="body", + axis=(1, 0, 0), + center=(0, 0, 0), + profile_curve=[(0, 0), (0, 1), (1, 1), (1, 0)], + ) + param = SimulationParams( + meshing=MeshingParams( + defaults=MeshingDefaults( + boundary_layer_first_layer_thickness=1e-4 * u.m, + ), + volume_zones=[AutomatedFarfield()], + refinements=[ + UniformRefinement( + entities=[body], + spacing=0.5 * u.m, + face_spacing={body.segment(0): 10 * u.cm, body.segment(2): 200 * u.mm}, + ), + ], + ), + private_attribute_asset_cache=AssetCache(use_inhouse_mesher=True), + ) + + translated = get_volume_meshing_json(param, get_surface_mesh.mesh_unit) + ref = translated["refinement"][0] + assert ref["spacing"] == 0.5 + assert ref["faceSpacings"] == pytest.approx([0.1, 0.5, 0.2]) + + +def test_face_spacing_round_trip(get_surface_mesh): + """Serialize and deserialize SimulationParams with face_spacing; translated output must match.""" + with SI_unit_system: + body = AxisymmetricBody( + name="body", + axis=(1, 0, 0), + center=(0, 0, 0), + profile_curve=[(0, 0), (0, 0.5), (2, 1), (4, 0.5), (4, 0)], + ) + param = SimulationParams( + meshing=MeshingParams( + defaults=MeshingDefaults( + boundary_layer_first_layer_thickness=1e-4 * u.m, + ), + volume_zones=[AutomatedFarfield()], + refinements=[ + UniformRefinement( + entities=[body], + spacing=0.5 * u.m, + face_spacing={body.segment(1): 0.1 * u.m, body.segment(3): 0.2 * u.m}, + ), + ], + ), + private_attribute_asset_cache=AssetCache(use_inhouse_mesher=True), + ) + + original_translated = get_volume_meshing_json(param, get_surface_mesh.mesh_unit) + + with SI_unit_system: + restored = SimulationParams.model_validate_json(param.model_dump_json()) + restored_translated = get_volume_meshing_json(restored, get_surface_mesh.mesh_unit) + + assert original_translated["refinement"] == restored_translated["refinement"]