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..02819ed67 100644 --- a/flow360/component/simulation/meshing_param/volume_params.py +++ b/flow360/component/simulation/meshing_param/volume_params.py @@ -4,7 +4,7 @@ # pylint: disable=too-many-lines -from typing import Literal, Optional, Union +from typing import Dict, Literal, Optional, Union import pydantic as pd from typing_extensions import deprecated @@ -18,6 +18,7 @@ Box, CustomVolume, Cylinder, + Face, GenericVolume, GhostSurface, MirroredSurface, @@ -54,13 +55,17 @@ def __get__(self, obj, owner): class UniformRefinement(Flow360BaseModel): """ Uniform spacing refinement inside specified region of mesh. + For AxisymmetricBody entities, specify per-face 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.face(2): 0.2*fl.u.cm, + ... } ... ) ==== @@ -79,6 +84,32 @@ class UniformRefinement(Flow360BaseModel): None, description="Whether to include the refinement in the surface mesh. Defaults to True when using snappy.", ) + face_spacing: Optional[Dict[str, Dict[int, LengthType.Positive]]] = pd.Field( + None, + description="Per-face spacing overrides for AxisymmetricBody entities. " + "Use `body.face(i)` as keys, where face i is defined by the segment between" + "profile_curve[i] and profile_curve[i+1]. Faces without overrides use the default `spacing`.", + ) + + @pd.field_validator("face_spacing", mode="before") + @classmethod + def _convert_face_spacing(cls, value): + """Convert {Face: spacing} to {entity_name: {idx: spacing}} internal format.""" + if value is None or not isinstance(value, dict): + return value + result = {} + for key, val in value.items(): + if isinstance(key, Face): + result.setdefault(key.entity_name, {})[key.index] = val + elif isinstance(key, str) and isinstance(val, dict): + # Already in {name: {idx: spacing}} format (e.g., from JSON deserialization) + result[key] = {int(k): v for k, v in val.items()} + else: + raise ValueError( + f"Invalid face_spacing key {key!r}. " + f"Use body.face(i) or provide a valid serialized face_spacing dict." + ) + return result @contextual_field_validator("entities", mode="after") @classmethod @@ -131,6 +162,35 @@ def check_project_to_surface_with_snappy(self, param_info: ParamsValidationInfo) return self + @pd.model_validator(mode="after") + def check_face_spacing(self): + """Validate face_spacing keys match AxisymmetricBody entities.""" + if self.face_spacing is None: + return self + + entity_map = {} + if self.entities is not None: + for entity in self.entities.stored_entities: + if isinstance(entity, AxisymmetricBody): + entity_map[entity.name] = entity + + for entity_name, face_overrides in self.face_spacing.items(): + if entity_name not in entity_map: + raise ValueError( + f"face_spacing references '{entity_name}' which is not an " + f"AxisymmetricBody in this refinement's entities list." + ) + entity = entity_map[entity_name] + num_faces = len(entity.profile_curve) - 1 + for face_idx in face_overrides: + if face_idx >= num_faces or face_idx < 0: + raise ValueError( + f"Face index {face_idx} for entity '{entity.name}' is out of range. " + f"Valid range: [0, {num_faces - 1}]." + ) + + return self + class StructuredBoxRefinement(Flow360BaseModel): """ diff --git a/flow360/component/simulation/primitives.py b/flow360/component/simulation/primitives.py index ab5dbacf0..c06fb4173 100644 --- a/flow360/component/simulation/primitives.py +++ b/flow360/component/simulation/primitives.py @@ -303,6 +303,19 @@ class Edge(EntityBase): ) +@final +class Face(pd.BaseModel): + """Reference to a specific face of a geometric entity. Currently only supported + for AxisymmetricBody, for which faces correspond to profile curve segments.""" + + model_config = pd.ConfigDict(frozen=True) + + type_name: Literal["Face"] = pd.Field("Face", 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 +685,13 @@ def _check_profile_curve_has_no_duplicates(cls, curve): return curve + def face(self, index: int) -> Face: + """Return a Face reference for the given index.""" + num_faces = len(self.profile_curve) - 1 + if index < 0 or index >= num_faces: + raise IndexError(f"Face index {index} out of range [0, {num_faces - 1}]") + return Face(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..565e0ddfd 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: + result["_face_spacing"] = { + name: {idx: s.value.item() for idx, s in overrides.items()} + for name, overrides in obj.face_spacing.items() + } + 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..8c9bdb726 100644 --- a/tests/simulation/params/meshing_validation/test_meshing_param_validation.py +++ b/tests/simulation/params/meshing_validation/test_meshing_param_validation.py @@ -857,6 +857,89 @@ def test_axisymmetric_body_in_uniform_refinement(): ) +def test_face_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.face(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.face(2) + with pytest.raises(IndexError): + body.face(3) + with pytest.raises(IndexError): + body.face(-1) + + # Same entity + index -> equal, so usable as dict key + assert body.face(1) == body.face(1) + assert body.face(0) != body.face(1) + assert {body.face(1): "a"}[body.face(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.face(0) != other.face(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.face(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.face(5): 0.1 * u.m}, + ) + + # Invalid: entity id not in entities list + other_body = AxisymmetricBody( + name="other", + axis=(0, 0, 1), + center=(1, 0, 0), + profile_curve=[(0, 0), (0, 1), (1, 0)], + ) + with pytest.raises(pd.ValidationError, match="not an AxisymmetricBody in this refinement"): + UniformRefinement( + entities=[body], + spacing=0.5 * u.m, + face_spacing={other_body.face(0): 0.1 * u.m}, + ) + + # Invalid: non-Face key + with pytest.raises(pd.ValidationError, match="Invalid face_spacing key"): + UniformRefinement( + entities=[body], + spacing=0.5 * u.m, + face_spacing={42: 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..e0a9a3fe3 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.face(1): 0.1 * u.m, body.face(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.face(0): 0.1 * u.m, + body2.face(1): 0.2 * u.m, + body2.face(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.face(0): 10 * u.cm, body.face(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.face(1): 0.1 * u.m, body.face(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"]