Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
7ddd2f7
first version
alexxu-flex Mar 9, 2026
bdc0285
Merge branch 'main' of github.com:flexcompute/Flow360 into alexxu/var…
alexxu-flex Mar 11, 2026
d62042e
Merge branch 'main' of github.com:flexcompute/Flow360 into alexxu/var…
alexxu-flex Mar 11, 2026
6dbf834
Merge branch 'main' of github.com:flexcompute/Flow360 into alexxu/var…
alexxu-flex Mar 12, 2026
69c56f8
black
alexxu-flex Mar 12, 2026
c7fc526
bugfix: unit conversion in dicts
alexxu-flex Mar 19, 2026
55c50bd
resolve merge conflict; improve test location
alexxu-flex Mar 19, 2026
f77647f
cursor comment: unify recursive preprocess dimension
alexxu-flex Mar 19, 2026
c04f7d6
add Face class, switch API to axisymm_body.face(n)
alexxu-flex Mar 24, 2026
bdc9aaf
Merge branch 'main' of github.com:flexcompute/Flow360 into alexxu/var…
alexxu-flex Mar 24, 2026
8e45cfc
add face class standalone unit test
alexxu-flex Mar 24, 2026
359b8e1
use entity name for face_spacing to align with C++ parser
alexxu-flex Mar 25, 2026
b19cd53
Merge branch 'main' of github.com:flexcompute/Flow360 into alexxu/var…
alexxu-flex Mar 25, 2026
200f6b4
Merge branch 'main' into alexxu/varying-refinement-faces
benflexcompute Mar 31, 2026
b1021ec
Merge branch 'main' of github.com:flexcompute/Flow360 into alexxu/var…
alexxu-flex Apr 3, 2026
ecd9921
refactor face_spacing to dict[segment, length], cleaner validation
alexxu-flex Apr 3, 2026
a6336cb
Merge branch 'main' of github.com:flexcompute/Flow360 into alexxu/var…
alexxu-flex Apr 3, 2026
ffce319
Merge branch 'alexxu/varying-refinement-faces' of github.com:flexcomp…
alexxu-flex Apr 3, 2026
4b64c60
cursor feedback: check face_spacing entities against entity list, w/ …
alexxu-flex Apr 3, 2026
5d30a22
Merge branch 'main' of github.com:flexcompute/Flow360 into alexxu/var…
alexxu-flex Apr 29, 2026
abbaac0
bump version + add axisymmetricsegment
alexxu-flex Apr 29, 2026
17371b1
Update lock
benflexcompute May 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions flow360/component/simulation/framework/base_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,19 @@ def _preprocess_nested_list(value, required_by, params, exclude, flow360_unit_sy
return new_list


def _preprocess_nested_dict(value, flow360_unit_system):
"""Recursively convert dimensioned values inside dicts."""
result = {}
for k, v in value.items():
if isinstance(v, dict):
result[k] = _preprocess_nested_dict(v, flow360_unit_system)
elif need_conversion(v):
result[k] = v.in_base(flow360_unit_system)
else:
result[k] = v
return result
Comment thread
alexxu-flex marked this conversation as resolved.
Outdated


class Conflicts(pd.BaseModel):
"""
Wrapper for handling fields that cannot be specified simultaneously
Expand Down Expand Up @@ -683,5 +696,7 @@ def preprocess(
solver_values[property_name] = _preprocess_nested_list(
value, [loc_name], params, exclude, flow360_unit_system
)
elif isinstance(value, dict):
solver_values[property_name] = _preprocess_nested_dict(value, flow360_unit_system)

return self.__class__(**solver_values)
45 changes: 43 additions & 2 deletions flow360/component/simulation/meshing_param/volume_params.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -54,13 +54,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={
Comment thread
benflexcompute marked this conversation as resolved.
Outdated
... "axisymmetric_body": {2: 0.2*fl.u.cm},
... }
... )

====
Expand All @@ -79,6 +83,13 @@ 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. "
"Outer key is the entity name, inner key is the face index "
"(0-based, where face i is the segment between profile_curve[i] and profile_curve[i+1]). "
"Faces without overrides use the default `spacing`.",
)

@contextual_field_validator("entities", mode="after")
@classmethod
Expand Down Expand Up @@ -111,6 +122,36 @@ def check_project_to_surface_with_snappy(self, param_info: ParamsValidationInfo)

return self

@pd.model_validator(mode="after")
Comment thread
benflexcompute marked this conversation as resolved.
Outdated
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

Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
for entity_name, face_overrides in self.face_spacing.items():
if entity_name not in entity_map:
axisym_names = list(entity_map.keys())
raise ValueError(
f"face_spacing key '{entity_name}' does not match any "
f"AxisymmetricBody entity. Available: {axisym_names}"
)
entity = entity_map[entity_name]
num_faces = len(entity.profile_curve) - 1
for face_idx in face_overrides:
if face_idx < 0 or face_idx >= num_faces:
raise ValueError(
f"Face index {face_idx} for entity '{entity_name}' is out of range. "
f"Valid range: [0, {num_faces - 1}]."
)

return self
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated


class StructuredBoxRefinement(Flow360BaseModel):
"""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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]):
Expand Down Expand Up @@ -531,6 +559,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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,13 @@
import flow360.component.simulation.units as u
from flow360.component.simulation.meshing_param import snappy
from flow360.component.simulation.meshing_param.volume_params import UniformRefinement
from flow360.component.simulation.primitives import Box, Cylinder, SnappyBody, Surface
from flow360.component.simulation.primitives import (
AxisymmetricBody,
Box,
Cylinder,
SnappyBody,
Surface,
)
from flow360.component.simulation.unit_system import SI_unit_system


Expand Down Expand Up @@ -211,3 +217,47 @@ def test_snappy_body_refinement_validator():
snappy.BodyRefinement(
bodies=SnappyBody(name="body1", surfaces=[Surface(name="surface")]), gap_resolution=2 * u.mm
)


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": {1: 0.1 * u.m}},
)

# Invalid: face index out of range
with pytest.raises(pd.ValidationError, match="out of range"):
UniformRefinement(
entities=[body],
spacing=0.5 * u.m,
face_spacing={"body": {5: 0.1 * u.m}},
)

# Invalid: entity name not found
with pytest.raises(pd.ValidationError, match="does not match any AxisymmetricBody"):
UniformRefinement(
entities=[body],
spacing=0.5 * u.m,
face_spacing={"invalid_name": {0: 0.1 * u.m}},
)

# Invalid: non-AxisymmetricBody entity name
box = Box.from_principal_axes(
name="mybox", center=(0, 0, 0), size=(1, 1, 1), axes=((1, 0, 0), (0, 1, 0))
)
with pytest.raises(pd.ValidationError, match="does not match any AxisymmetricBody"):
UniformRefinement(
entities=[box],
spacing=0.5 * u.m,
face_spacing={"mybox": {0: 0.1 * u.m}},
)
150 changes: 150 additions & 0 deletions tests/simulation/translator/test_volume_meshing_translator.py
Original file line number Diff line number Diff line change
Expand Up @@ -2086,3 +2086,153 @@ 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={"axisymm_body": {1: 0.1 * u.m, 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": {0: 0.1 * u.m},
"body2": {1: 0.2 * u.m, 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": {0: 10 * u.cm, 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])