Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
57 changes: 27 additions & 30 deletions flow360/component/simulation/framework/base_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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
)

Expand Down
64 changes: 62 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 All @@ -18,6 +18,7 @@
Box,
CustomVolume,
Cylinder,
Face,
GenericVolume,
GhostSurface,
MirroredSurface,
Expand Down Expand Up @@ -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={
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is too nested and confusing. We need to redesign the interface.

Copy link
Copy Markdown
Collaborator Author

@alexxu-flex alexxu-flex Mar 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

//User interface
face_spacing={
axisymmetric_body.segments[1] : 0.2*fl.u.cm
}
// Storage side
"face_spacing":{
("entioty_id":"$axisymmetric_body.private_attribute_id", "segment_index": 1) : {"value": 0.2, "units":"cm"}
}

Copy link
Copy Markdown
Collaborator Author

@alexxu-flex alexxu-flex Mar 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Then you need a Pydantic model for the "segment" class.

It will be similar relationship between Windtunnel farfield and the windtunnel ghost surfaces.

class Segment(BaseModel):
    type_name:Literal["Segment"]
    entity_id:str
    segment_index: int =pd.Field(min=..., )

... axisymmetric_body.face(2): 0.2*fl.u.cm,
... }
... )

====
Expand All @@ -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")
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's not use before validators unless really necessary.

Why don't we do this instead?

    face_spacing: Optional[Dict[AxisSymmetricSegment, LengthType.Positive]] = pd.Field(

And then we have:

class AxisSymmetricSegment(Flow360BaseModel):
    entity_id: str
    segment_index: pd.PositiveInt

so the axisymmetric_body.face(2) returns an AxisSymmetricSegment instance?

Then we do not need this complicate and fragile before validator?

@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):
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Face overrides lose entity identity

Medium Severity

face_spacing converts Face keys using only entity_name, dropping Face.entity_id. This makes overrides ambiguous when multiple AxisymmetricBody objects share a name, and stale Face references from a different entity with the same name are silently accepted. Translation then applies spacing by name, so overrides can target the wrong body.

Additional Locations (2)
Fix in Cursor Fix in Web

# Already in {name: {idx: spacing}} format (e.g., from JSON deserialization)
result[key] = {int(k): v for k, v in val.items()}
else:
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mixed key formats drop face overrides

Low Severity

_convert_face_spacing silently overwrites entries when face_spacing mixes Face keys and serialized string keys for the same entity. The str branch assigns result[key] = ..., replacing previously accumulated per-face values from Face keys, so some overrides are lost without validation errors.

Additional Locations (1)
Fix in Cursor Fix in Web

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
Expand Down Expand Up @@ -131,6 +162,35 @@ def check_project_to_surface_with_snappy(self, param_info: ParamsValidationInfo)

return self

@pd.model_validator(mode="after")
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

make this a contextual validator. You can then retrieve the axisymmetric body instance by querying the entity registry.

Take a look at param_info.get_entity_registry() and registry.find_by_type_name_and_id()

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Selector entities rejected in face spacing validation

Medium Severity

check_face_spacing builds entity_map from self.entities.stored_entities only. When UniformRefinement.entities is provided via selectors, stored_entities can be empty before expansion, so valid face_spacing entries are rejected as “not an AxisymmetricBody in this refinement’s entities list.”

Fix in Cursor Fix in Web

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):
"""
Expand Down
20 changes: 20 additions & 0 deletions flow360/component/simulation/primitives.py
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,19 @@ class Edge(EntityBase):
)


@final
class Face(pd.BaseModel):
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Face will be reserved for geometry concepts. It is a reserved word.

"""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):
"""
Expand Down Expand Up @@ -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(
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 @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading
Loading