Skip to content

Commit 7aef0eb

Browse files
refactor(): Replaced EntityList child class with per-entity-type validations (#1693)
1 parent 7f271e8 commit 7aef0eb

File tree

13 files changed

+326
-95
lines changed

13 files changed

+326
-95
lines changed

flow360/component/simulation/draft_context/coordinate_system_manager.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -436,7 +436,7 @@ def _from_status(
436436
key = (entity.entity_type, entity.entity_id)
437437
# Sanitize invalid assignments due to entity not being in scope anymore.
438438
if (
439-
entity_registry # entity_registry None indicates that no validation is needed
439+
entity_registry # Fast lane: entity_registry None indicates that no validation is needed
440440
and (
441441
entity_registry.find_by_type_name_and_id(
442442
entity_type=entity.entity_type, entity_id=entity.entity_id

flow360/component/simulation/framework/entity_base.py

Lines changed: 54 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,17 @@ def id(self) -> str:
152152
"""Returns private_attribute_id of the entity."""
153153
return self.private_attribute_id
154154

155+
def _manual_assignment_validation(self, _: ParamsValidationInfo) -> EntityBase:
156+
"""
157+
Pre-expansion contextual validation for the entity.
158+
This handles validation for the entity manually assigned.
159+
"""
160+
return self
161+
162+
def _per_entity_type_validation(self, _: ParamsValidationInfo) -> EntityBase:
163+
"""Contextual validation with validation logic bond with the specific entity type."""
164+
return self
165+
155166

156167
class _CombinedMeta(type(Flow360BaseModel), type):
157168
pass
@@ -265,18 +276,42 @@ def _ensure_entities_after_expansion(self, param_info: ParamsValidationInfo):
265276
With delayed selector expansion, stored_entities may be empty if only selectors
266277
are defined.
267278
"""
268-
# If stored_entities already has entities, validation passes
269-
if self.stored_entities:
270-
return self
279+
is_empty = True
280+
# If stored_entities already has entities (user manual assignment), validation passes
281+
manual_assignments: List[EntityBase] = self.stored_entities
282+
# pylint: disable=protected-access
283+
if manual_assignments:
284+
filtered_assignments = [
285+
item
286+
for item in manual_assignments
287+
if item._manual_assignment_validation(param_info) is not None
288+
]
289+
# Use object.__setattr__ to bypass validate_on_assignment and avoid recursion
290+
object.__setattr__(
291+
self,
292+
"stored_entities",
293+
filtered_assignments,
294+
)
295+
296+
for item in filtered_assignments:
297+
item._per_entity_type_validation(param_info)
298+
299+
if filtered_assignments:
300+
is_empty = False
271301

272302
# No stored_entities - check if selectors will produce any entities
273303
if self.selectors:
274-
expanded = param_info.expand_entity_list(self)
304+
expanded: List[EntityBase] = param_info.expand_entity_list(self)
275305
if expanded:
306+
for item in expanded:
307+
item._per_entity_type_validation(param_info)
308+
# Known non-empty
276309
return self
277310

278311
# Neither stored_entities nor selectors produced any entities
279-
raise ValueError("No entities were selected.")
312+
if is_empty:
313+
raise ValueError("No entities were selected.")
314+
return self
280315

281316
@classmethod
282317
def _get_valid_entity_types(cls):
@@ -311,17 +346,17 @@ def _get_valid_entity_types(cls):
311346
raise TypeError("Cannot extract valid entity types.")
312347

313348
@classmethod
314-
def _process_selector(cls, selector: EntitySelector, valid_type_names: List[str]) -> dict:
349+
def _validate_selector(cls, selector: EntitySelector, valid_type_names: List[str]) -> dict:
315350
"""Process and validate an EntitySelector object."""
316351
if selector.target_class not in valid_type_names:
317352
raise ValueError(
318353
f"Selector target_class ({selector.target_class}) is incompatible "
319354
f"with EntityList types {valid_type_names}."
320355
)
321-
return selector.model_dump()
356+
return selector
322357

323358
@classmethod
324-
def _process_entity(cls, entity: Union[EntityBase, Any]) -> EntityBase:
359+
def _validate_entity(cls, entity: Union[EntityBase, Any]) -> EntityBase:
325360
"""Process and validate an entity object."""
326361
if isinstance(entity, EntityBase):
327362
return entity
@@ -333,12 +368,12 @@ def _process_entity(cls, entity: Union[EntityBase, Any]) -> EntityBase:
333368

334369
@classmethod
335370
def _build_result(
336-
cls, entities_to_store: List[EntityBase], entity_patterns_to_store: List[dict]
371+
cls, entities_to_store: List[EntityBase], entity_selectors_to_store: List[dict]
337372
) -> dict:
338373
"""Build the final result dictionary."""
339374
return {
340375
"stored_entities": entities_to_store,
341-
"selectors": entity_patterns_to_store if entity_patterns_to_store else None,
376+
"selectors": entity_selectors_to_store if entity_selectors_to_store else None,
342377
}
343378

344379
@classmethod
@@ -348,13 +383,13 @@ def _process_single_item(
348383
item: Union[EntityBase, EntitySelector],
349384
valid_type_names: List[str],
350385
entities_to_store: List[EntityBase],
351-
entity_patterns_to_store: List[dict],
386+
entity_selectors_to_store: List[dict],
352387
) -> None:
353388
"""Process a single item (entity or selector) and add to appropriate storage lists."""
354389
if isinstance(item, EntitySelector):
355-
entity_patterns_to_store.append(cls._process_selector(item, valid_type_names))
390+
entity_selectors_to_store.append(cls._validate_selector(item, valid_type_names))
356391
else:
357-
processed_entity = cls._process_entity(item)
392+
processed_entity = cls._validate_entity(item)
358393
entities_to_store.append(processed_entity)
359394

360395
@pd.model_validator(mode="before")
@@ -367,7 +402,7 @@ def deserializer(cls, input_data: Union[dict, list, EntityBase, EntitySelector])
367402
field validator, which runs after deserialization but before discriminator validation.
368403
"""
369404
entities_to_store = []
370-
entity_patterns_to_store = []
405+
entity_selectors_to_store = []
371406
valid_types = tuple(cls._get_valid_entity_types())
372407
valid_type_names = [t.__name__ for t in valid_types]
373408

@@ -379,15 +414,15 @@ def deserializer(cls, input_data: Union[dict, list, EntityBase, EntitySelector])
379414
for item in input_data:
380415
if isinstance(item, list): # Nested list comes from assets __getitem__
381416
# Process all entities without filtering
382-
processed_entities = [cls._process_entity(individual) for individual in item]
417+
processed_entities = [cls._validate_entity(individual) for individual in item]
383418
entities_to_store.extend(processed_entities)
384419
else:
385420
# Single entity or selector
386421
cls._process_single_item(
387422
item,
388423
valid_type_names,
389424
entities_to_store,
390-
entity_patterns_to_store,
425+
entity_selectors_to_store,
391426
)
392427
elif isinstance(input_data, dict): # Deserialization
393428
# With delayed selector expansion, stored_entities may be absent if only selectors are defined.
@@ -403,13 +438,13 @@ def deserializer(cls, input_data: Union[dict, list, EntityBase, EntitySelector])
403438
input_data,
404439
valid_type_names,
405440
entities_to_store,
406-
entity_patterns_to_store,
441+
entity_selectors_to_store,
407442
)
408443

409-
if not entities_to_store and not entity_patterns_to_store:
444+
if not entities_to_store and not entity_selectors_to_store:
410445
raise ValueError(
411446
f"Can not find any valid entity of type {[valid_type.__name__ for valid_type in valid_types]}"
412447
f" from the input."
413448
)
414449

415-
return cls._build_result(entities_to_store, entity_patterns_to_store)
450+
return cls._build_result(entities_to_store, entity_selectors_to_store)

flow360/component/simulation/meshing_param/face_params.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66

77
from flow360.component.simulation.framework.base_model import Flow360BaseModel
88
from flow360.component.simulation.framework.entity_base import EntityList
9-
from flow360.component.simulation.models.surface_models import EntityListAllowingGhost
109
from flow360.component.simulation.primitives import (
1110
GhostCircularPlane,
1211
GhostSurface,
@@ -44,7 +43,7 @@ class SurfaceRefinement(Flow360BaseModel):
4443

4544
name: Optional[str] = pd.Field("Surface refinement")
4645
refinement_type: Literal["SurfaceRefinement"] = pd.Field("SurfaceRefinement", frozen=True)
47-
entities: EntityListAllowingGhost[
46+
entities: EntityList[
4847
Surface, MirroredSurface, WindTunnelGhostSurface, GhostSurface, GhostCircularPlane
4948
] = pd.Field(alias="faces")
5049
# pylint: disable=no-member
@@ -183,7 +182,7 @@ class PassiveSpacing(Flow360BaseModel):
183182
"""
184183
)
185184
refinement_type: Literal["PassiveSpacing"] = pd.Field("PassiveSpacing", frozen=True)
186-
entities: EntityListAllowingGhost[
185+
entities: EntityList[
187186
Surface, MirroredSurface, WindTunnelGhostSurface, GhostSurface, GhostCircularPlane
188187
] = pd.Field(alias="faces")
189188

flow360/component/simulation/models/solver_numerics.py

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,7 @@
2222
Flow360BaseModel,
2323
)
2424
from flow360.component.simulation.framework.entity_base import EntityList
25-
from flow360.component.simulation.primitives import (
26-
Box,
27-
CustomVolume,
28-
EntityListWithCustomVolume,
29-
GenericVolume,
30-
)
25+
from flow360.component.simulation.primitives import Box, CustomVolume, GenericVolume
3126

3227
# from .time_stepping import UnsteadyTimeStepping
3328

@@ -250,7 +245,7 @@ class TurbulenceModelControls(Flow360BaseModel):
250245
None, description="Force RANS or LES mode in a specific control region."
251246
)
252247

253-
entities: EntityListWithCustomVolume[GenericVolume, CustomVolume, Box] = pd.Field(
248+
entities: EntityList[GenericVolume, CustomVolume, Box] = pd.Field(
254249
alias="volumes",
255250
description="The entity in which to apply the `TurbulenceMOdelControls``. "
256251
+ "The entity should be defined by :class:`Box` or zones from the geometry/volume mesh."

flow360/component/simulation/models/surface_models.py

Lines changed: 3 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -45,12 +45,9 @@
4545
from flow360.component.simulation.validation.validation_context import (
4646
ParamsValidationInfo,
4747
contextual_field_validator,
48-
contextual_model_validator,
4948
)
5049
from flow360.component.simulation.validation.validation_utils import (
5150
check_deleted_surface_pair,
52-
check_symmetric_boundary_existence,
53-
check_user_defined_farfield_symmetry_existence,
5451
validate_entity_list_surface_existence,
5552
)
5653

@@ -59,18 +56,6 @@
5956
from flow360.component.types import Axis
6057

6158

62-
class EntityListAllowingGhost(EntityList):
63-
"""Entity list with customized validators for ghost entities"""
64-
65-
@contextual_model_validator(mode="after")
66-
def ghost_entity_validator(self, param_info: ParamsValidationInfo):
67-
"""Run all validators"""
68-
expanded = param_info.expand_entity_list(self)
69-
check_user_defined_farfield_symmetry_existence(expanded, param_info)
70-
check_symmetric_boundary_existence(expanded, param_info)
71-
return self
72-
73-
7459
class BoundaryBase(Flow360BaseModel, metaclass=ABCMeta):
7560
"""Boundary base"""
7661

@@ -489,7 +474,7 @@ class Freestream(BoundaryBaseWithTurbulenceQuantities):
489474
+ ":py:attr:`AerospaceCondition.alpha` and :py:attr:`AerospaceCondition.beta` angles. "
490475
+ "Optionally, an expression for each of the velocity components can be specified.",
491476
)
492-
entities: EntityListAllowingGhost[
477+
entities: EntityList[
493478
Surface,
494479
MirroredSurface,
495480
GhostSurface,
@@ -659,7 +644,7 @@ class SlipWall(BoundaryBase):
659644
"Slip wall", description="Name of the `SlipWall` boundary condition."
660645
)
661646
type: Literal["SlipWall"] = pd.Field("SlipWall", frozen=True)
662-
entities: EntityListAllowingGhost[
647+
entities: EntityList[
663648
Surface, MirroredSurface, GhostSurface, WindTunnelGhostSurface, GhostCircularPlane
664649
] = pd.Field(
665650
alias="surfaces",
@@ -692,9 +677,7 @@ class SymmetryPlane(BoundaryBase):
692677
"Symmetry", description="Name of the `SymmetryPlane` boundary condition."
693678
)
694679
type: Literal["SymmetryPlane"] = pd.Field("SymmetryPlane", frozen=True)
695-
entities: EntityListAllowingGhost[
696-
Surface, MirroredSurface, GhostSurface, GhostCircularPlane
697-
] = pd.Field(
680+
entities: EntityList[Surface, MirroredSurface, GhostSurface, GhostCircularPlane] = pd.Field(
698681
alias="surfaces",
699682
description="List of boundaries with the `SymmetryPlane` boundary condition imposed.",
700683
)

flow360/component/simulation/models/volume_models.py

Lines changed: 9 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,6 @@
5757
Box,
5858
CustomVolume,
5959
Cylinder,
60-
EntityListWithCustomVolume,
6160
GenericVolume,
6261
SeedpointVolume,
6362
)
@@ -367,7 +366,7 @@ class Solid(PDEModelBase):
367366

368367
name: Optional[str] = pd.Field(None, description="Name of the `Solid` model.")
369368
type: Literal["Solid"] = pd.Field("Solid", frozen=True)
370-
entities: EntityListWithCustomVolume[GenericVolume, CustomVolume] = pd.Field(
369+
entities: EntityList[GenericVolume, CustomVolume] = pd.Field(
371370
alias="volumes",
372371
description="The list of :class:`GenericVolume` or :class:`CustomVolume` "
373372
+ "entities on which the heat transfer equation is solved. "
@@ -1244,7 +1243,7 @@ class Rotation(Flow360BaseModel):
12441243

12451244
name: Optional[str] = pd.Field("Rotation", description="Name of the `Rotation` model.")
12461245
type: Literal["Rotation"] = pd.Field("Rotation", frozen=True)
1247-
entities: EntityListWithCustomVolume[
1246+
entities: EntityList[
12481247
GenericVolume, Cylinder, CustomVolume, SeedpointVolume, AxisymmetricBody
12491248
] = pd.Field(
12501249
alias="volumes",
@@ -1357,15 +1356,13 @@ class PorousMedium(Flow360BaseModel):
13571356

13581357
name: Optional[str] = pd.Field("Porous medium", description="Name of the `PorousMedium` model.")
13591358
type: Literal["PorousMedium"] = pd.Field("PorousMedium", frozen=True)
1360-
entities: EntityListWithCustomVolume[GenericVolume, Box, CustomVolume, SeedpointVolume] = (
1361-
pd.Field(
1362-
alias="volumes",
1363-
description="The entity list for the `PorousMedium` model. "
1364-
+ "The entity should be defined by :class:`Box`, zones from the geometry/volume mesh or"
1365-
+ "by :class:`SeedpointVolume` when using snappyHexMeshing."
1366-
+ "The axes of entity must be specified to serve as the the principle axes of the "
1367-
+ "porous medium material model.",
1368-
)
1359+
entities: EntityList[GenericVolume, Box, CustomVolume, SeedpointVolume] = pd.Field(
1360+
alias="volumes",
1361+
description="The entity list for the `PorousMedium` model. "
1362+
+ "The entity should be defined by :class:`Box`, zones from the geometry/volume mesh or"
1363+
+ "by :class:`SeedpointVolume` when using snappyHexMeshing."
1364+
+ "The axes of entity must be specified to serve as the the principle axes of the "
1365+
+ "porous medium material model.",
13691366
)
13701367

13711368
darcy_coefficient: InverseAreaType.Point = pd.Field(

flow360/component/simulation/outputs/outputs.py

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,7 @@
1717
from flow360.component.simulation.framework.expressions import StringExpression
1818
from flow360.component.simulation.framework.param_utils import serialize_model_obj_to_id
1919
from flow360.component.simulation.framework.unique_list import UniqueItemList
20-
from flow360.component.simulation.models.surface_models import (
21-
EntityListAllowingGhost,
22-
Wall,
23-
)
20+
from flow360.component.simulation.models.surface_models import Wall
2421
from flow360.component.simulation.models.volume_models import (
2522
ActuatorDisk,
2623
BETDisk,
@@ -339,7 +336,7 @@ class SurfaceOutput(_AnimationAndFileFormatSettings, _OutputBase):
339336
# TODO: entities is None --> use all surfaces. This is not implemented yet.
340337

341338
name: Optional[str] = pd.Field("Surface output", description="Name of the `SurfaceOutput`.")
342-
entities: EntityListAllowingGhost[ # pylint: disable=duplicate-code
339+
entities: EntityList[ # pylint: disable=duplicate-code
343340
Surface,
344341
MirroredSurface,
345342
GhostSurface,
@@ -681,7 +678,7 @@ class SurfaceIntegralOutput(_OutputBase):
681678
"""
682679

683680
name: str = pd.Field("Surface integral output", description="Name of integral.")
684-
entities: EntityListAllowingGhost[ # pylint: disable=duplicate-code
681+
entities: EntityList[ # pylint: disable=duplicate-code
685682
Surface,
686683
MirroredSurface,
687684
GhostSurface,
@@ -1336,9 +1333,7 @@ class AeroAcousticOutput(Flow360BaseModel):
13361333
+ "input.",
13371334
)
13381335
permeable_surfaces: Optional[
1339-
EntityListAllowingGhost[
1340-
Surface, GhostSurface, GhostCircularPlane, GhostSphere, WindTunnelGhostSurface
1341-
]
1336+
EntityList[Surface, GhostSurface, GhostCircularPlane, GhostSphere, WindTunnelGhostSurface]
13421337
] = pd.Field(
13431338
None, description="List of permeable surfaces. Left empty if `patch_type` is solid"
13441339
)

0 commit comments

Comments
 (0)