Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 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
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
87 changes: 84 additions & 3 deletions flow360/component/simulation/meshing_param/volume_params.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -15,6 +17,7 @@
from flow360.component.simulation.outputs.output_entities import Slice
from flow360.component.simulation.primitives import (
AxisymmetricBody,
AxisymmetricSegment,
Box,
CustomVolume,
Cylinder,
Expand All @@ -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,
Expand All @@ -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={
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.segment(2): 0.2*fl.u.cm,
... }
... )

====
Expand All @@ -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
Expand Down Expand Up @@ -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):
"""
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,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):
"""
Expand Down Expand Up @@ -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(
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:
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]):
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
Loading
Loading