Source code for flow360_schema.models.simulation.models.surface_models

"""
Contains basically only boundary conditions for now. In future we can add new models like 2D equations.
"""

# pylint: disable=too-many-lines

import logging
import warnings
from abc import ABCMeta
from typing import Annotated, Literal, Union

import pydantic as pd
import unyt as u

from flow360_schema.framework.base_model import Flow360BaseModel
from flow360_schema.framework.entity.entity_list import EntityList
from flow360_schema.framework.entity.entity_utils import generate_uuid
from flow360_schema.framework.entity.geometric_types import Axis
from flow360_schema.framework.expression import StringExpression
from flow360_schema.framework.physical_dimensions import (
    AbsoluteTemperature,
    AngularVelocity,
    InverseArea,
    InverseLength,
    Length,
)
from flow360_schema.framework.physical_dimensions import HeatFlux as HeatFluxDim
from flow360_schema.framework.physical_dimensions import MassFlowRate as MassFlowRateDim
from flow360_schema.framework.physical_dimensions import Pressure as PressureDim
from flow360_schema.framework.single_attribute_base import SingleAttributeModel
from flow360_schema.framework.unique_list import UniqueItemList
from flow360_schema.models.simulation.framework.updater_utils import deprecation_reminder
from flow360_schema.models.entities.surface_entities import (
    GhostCircularPlane,
    GhostSphere,
    GhostSurface,
    GhostSurfacePair,
    MirroredSurface,
    Surface,
    SurfacePair,
    WindTunnelGhostSurface,
)
from flow360_schema.models.simulation.models.turbulence_quantities import (
    TurbulenceQuantitiesType,
)
from flow360_schema.models.simulation.operating_condition.operating_condition import (
    VelocityVectorType,
)
from flow360_schema.models.simulation.validation.validation_context import (
    ParamsValidationInfo,
    contextual_field_validator,
)
from flow360_schema.models.simulation.validation.validation_utils import (
    check_deleted_surface_in_entity_list,
    check_deleted_surface_pair,
    remap_symmetric_ghost_entity,
    validate_entity_list_surface_existence,
)

logger = logging.getLogger(__name__)


class BoundaryBase(Flow360BaseModel, metaclass=ABCMeta):
    """Boundary base"""

    type: str = pd.Field()
    entities: EntityList[Surface, MirroredSurface] = pd.Field(
        alias="surfaces",
        description="List of boundaries with boundary condition imposed.",
    )
    private_attribute_id: str = pd.Field(default_factory=generate_uuid, frozen=True)

    @contextual_field_validator("entities", mode="after")
    @classmethod
    def remap_symmetric_to_user_name(cls, value, param_info: ParamsValidationInfo):
        """Remap 'symmetric' ghost entity to user's symmetry surface name for UDF backward compat."""
        return remap_symmetric_ghost_entity(value, param_info)

    @contextual_field_validator("entities", mode="after")
    @classmethod
    def ensure_surface_existence(cls, value, param_info: ParamsValidationInfo):
        """Ensure all boundaries will be present after mesher"""
        return validate_entity_list_surface_existence(value, param_info)


class BoundaryBaseWithTurbulenceQuantities(BoundaryBase, metaclass=ABCMeta):
    """Boundary base with turbulence quantities"""

    turbulence_quantities: TurbulenceQuantitiesType | None = pd.Field(
        None,
        description="The turbulence related quantities definition." + "See :func:`TurbulenceQuantities` documentation.",
    )


[docs] class HeatFlux(SingleAttributeModel): """ :class:`HeatFlux` class to specify the heat flux for `Wall` boundary condition via :py:attr:`Wall.heat_spec`. A positive heat flux removes energy from the fluid (heat flows from the fluid into the wall, cooling the fluid); a negative heat flux adds energy to the fluid (heat flows from the wall into the fluid, heating it). A value of ``0`` corresponds to an adiabatic wall. Example ------- >>> fl.HeatFlux(value = 1.0 * fl.u.W/fl.u.m**2) ==== """ type_name: Literal["HeatFlux"] = pd.Field("HeatFlux", frozen=True) value: StringExpression | HeatFluxDim.Float64 = pd.Field( description="The heat flux value. A positive value cools the fluid; a negative value heats it.", )
[docs] class Temperature(SingleAttributeModel): """ :class:`Temperature` class to specify the temperature for `Wall` or `Inflow` boundary condition via :py:attr:`Wall.heat_spec`/ :py:attr:`Inflow.spec`. Example ------- >>> fl.Temperature(value = 350 * fl.u.K) ==== """ type_name: Literal["Temperature"] = pd.Field("Temperature", frozen=True) value: StringExpression | AbsoluteTemperature.Float64 = pd.Field(description="The temperature value.")
[docs] class TotalPressure(Flow360BaseModel): """ :class:`TotalPressure` class to specify the total pressure for `Inflow` boundary condition via :py:attr:`Inflow.spec`. Example ------- - Using a constant value: >>> fl.TotalPressure( ... value = 1.04e6 * fl.u.Pa, ... ) - Using an expression (nondimensionalized by Flow360 pressure unit, rho * a^2): >>> fl.TotalPressure( ... value = "pow(1.0+0.2*pow(0.1*(1.0-y*y),2.0),1.4/0.4) / 1.4", ... ) ==== """ type_name: Literal["TotalPressure"] = pd.Field("TotalPressure", frozen=True) value: StringExpression | PressureDim.PositiveFloat64 = pd.Field( description="The total pressure value. When a string expression is supplied the value" + " needs to be nondimensionalized by the Flow360 pressure unit (rho_inf * a_inf^2)." )
[docs] class Pressure(SingleAttributeModel): """ :class:`Pressure` class to specify the static pressure for `Outflow` boundary condition via :py:attr:`Outflow.spec`. Example ------- >>> fl.Pressure(value = 1.01e6 * fl.u.Pa) ==== """ type_name: Literal["Pressure"] = pd.Field("Pressure", frozen=True) value: PressureDim.PositiveFloat64 = pd.Field(description="The static pressure value.")
[docs] class SlaterPorousBleed(Flow360BaseModel): """ :class:`SlaterPorousBleed` is a no-slip wall model which prescribes a normal velocity at the surface as a function of the surface pressure and density according to the model of John Slater. Example ------- - Specify a static pressure of 1.01e6 Pascals at the slater bleed boundary, and set the porosity of the surface to 0.4 (40%). >>> fl.SlaterPorousBleed(static_pressure=1.01e6 * fl.u.Pa, porosity=0.4, activation_step=200) ==== """ type_name: Literal["SlaterPorousBleed"] = pd.Field("SlaterPorousBleed", frozen=True) static_pressure: PressureDim.PositiveFloat64 = pd.Field(description="The static pressure value.") porosity: float = pd.Field(gt=0, le=1, description="The porosity of the bleed region.") activation_step: pd.PositiveInt | None = pd.Field( None, description="Pseudo step at which to start applying the SlaterPorousBleedModel." )
[docs] class MassFlowRate(Flow360BaseModel): """ :class:`MassFlowRate` class to specify the mass flow rate for `Inflow` or `Outflow` boundary condition via :py:attr:`Inflow.spec`/:py:attr:`Outflow.spec`. Example ------- >>> fl.MassFlowRate( ... value = 123 * fl.u.lb / fl.u.s, ... ramp_steps = 100, ... ) ==== """ type_name: Literal["MassFlowRate"] = pd.Field("MassFlowRate", frozen=True) value: MassFlowRateDim.NonNegativeFloat64 = pd.Field(description="The mass flow rate.") ramp_steps: pd.PositiveInt | None = pd.Field( None, description="Number of pseudo steps before reaching :py:attr:`MassFlowRate.value` within 1 physical step.", )
[docs] class Supersonic(Flow360BaseModel): """ :class:`Supersonic` class to specify the supersonic conditions for `Inflow`. Example ------- >>> fl.Supersonic( ... total_pressure = 7.90e6 * fl.u.Pa, ... static_pressure = 1.01e6 * fl.u.Pa, ... ) """ type_name: Literal["Supersonic"] = pd.Field("Supersonic", frozen=True) total_pressure: PressureDim.PositiveFloat64 = pd.Field(description="The total pressure.") static_pressure: PressureDim.PositiveFloat64 = pd.Field(description="The static pressure.")
[docs] class Mach(SingleAttributeModel): """ :class:`Mach` class to specify Mach number for the `Inflow` boundary condition via :py:attr:`Inflow.spec`. Example ------- >>> fl.Mach(value = 0.5) ==== """ type_name: Literal["Mach"] = pd.Field("Mach", frozen=True) value: pd.NonNegativeFloat = pd.Field(description="The Mach number.")
[docs] class Translational(Flow360BaseModel): """ :class:`Translational` class to specify translational periodic boundary condition via :py:attr:`Periodic.spec`. """ type_name: Literal["Translational"] = pd.Field("Translational", frozen=True)
[docs] class Rotational(Flow360BaseModel): """ :class:`Rotational` class to specify rotational periodic boundary condition via :py:attr:`Periodic.spec`. """ type_name: Literal["Rotational"] = pd.Field("Rotational", frozen=True) axis_of_rotation: Axis | None = pd.Field(None)
[docs] class WallRotation(Flow360BaseModel): """ :class:`WallRotation` class to specify the rotational velocity model for the `Wall` boundary condition. The wall rotation model prescribes a rotational motion at the wall by defining a center of rotation, an axis about which the wall rotates, and an angular velocity. This model can be used to simulate rotating components or surfaces in a flow simulation. Example ------- >>> fl.Wall( ... entities=volume_mesh["fluid/wall"], ... velocity=fl.WallRotation( ... axis=(0, 0, 1), ... center=(1, 2, 3) * u.m, ... angular_velocity=100 * u.rpm ... ), ... use_wall_function=True, ... ) ==== """ center: Length.Vector3 = pd.Field(description="The center of rotation") axis: Axis = pd.Field(description="The axis of rotation.") angular_velocity: AngularVelocity.Float64 = pd.Field(description="The value of the angular velocity.") type_name: Literal["WallRotation"] = pd.Field("WallRotation", frozen=True) private_attribute_circle_mode: dict | None = pd.Field(None)
WallVelocityModelTypes = Annotated[SlaterPorousBleed | WallRotation, pd.Field(discriminator="type_name")] WALL_VELOCITY_MODEL_ADAPTER = pd.TypeAdapter(WallVelocityModelTypes)
[docs] class WallFunction(Flow360BaseModel): """ :class:`WallFunction` specifies the wall function model to use on a :class:`Wall` boundary. Example ------- - Default boundary-layer wall function: >>> fl.Wall( ... entities=volume_mesh["fluid/wall"], ... use_wall_function=fl.WallFunction(), ... ) - Inner-layer wall model: >>> fl.Wall( ... entities=volume_mesh["fluid/wall"], ... use_wall_function=fl.WallFunction(wall_function_type="InnerLayer"), ... ) ==== """ wall_function_type: Literal["BoundaryLayer", "InnerLayer"] = pd.Field( "BoundaryLayer", description="Type of wall function model. " + "'BoundaryLayer' uses integral flat plate boundary layer theory to predict wall shear stress. " + "It performs well across all y+ ranges. " + "'InnerLayer' uses the inner layer behavior of the turbulent boundary layer, " + "offering better accuracy for y+ values in the log layer and below.", )
[docs] class Wall(BoundaryBase): """ :class:`Wall` class defines the wall boundary condition based on the inputs. Example ------- - :code:`Wall` with default wall function (BoundaryLayer) and prescribed velocity: >>> fl.Wall( ... entities=geometry["wall_function"], ... velocity = ["min(0.2, 0.2 + 0.2*y/0.5)", "0", "0.1*y/0.5"], ... use_wall_function=fl.WallFunction(), ... ) - :code:`Wall` with inner-layer wall function: >>> fl.Wall( ... entities=volume_mesh["8"], ... use_wall_function=fl.WallFunction(wall_function_type="InnerLayer"), ... ) - :code:`Wall` with wall function and wall rotation: >>> fl.Wall( ... entities=volume_mesh["8"], ... velocity=WallRotation( ... axis=(0, 0, 1), ... center=(1, 2, 3) * u.m, ... angular_velocity=100 * u.rpm ... ), ... use_wall_function=fl.WallFunction(), ... ) - Define isothermal wall boundary condition on entities with the naming pattern :code:`"fluid/isothermal-*"`: >>> fl.Wall( ... entities=volume_mesh["fluid/isothermal-*"], ... heat_spec=fl.Temperature(350 * fl.u.K), ... ) - Define isoflux wall boundary condition on entities with the naming pattern :code:`"solid/isoflux-*"`: >>> fl.Wall( ... entities=volume_mesh["solid/isoflux-*"], ... heat_spec=fl.HeatFlux(1.0 * fl.u.W/fl.u.m**2), ... ) - Define Slater no-slip bleed model on entities with the naming pattern :code:`"fluid/SlaterBoundary-*"`: >>> fl.Wall( ... entities=volume_mesh["fluid/SlaterBoundary-*"], ... velocity=fl.SlaterPorousBleed( ... static_pressure=1.01e6 * fl.u.Pa, porosity=0.4, activation_step=200 ... ), ... ) - Define roughness height on entities with the naming pattern :code:`"fluid/Roughness-*"`: >>> fl.Wall( ... entities=volume_mesh["fluid/Roughness-*"], ... roughness_height=0.1 * fl.u.mm, ... ) ==== """ name: str | None = pd.Field("Wall", description="Name of the `Wall` boundary condition.") type: Literal["Wall"] = pd.Field("Wall", frozen=True) use_wall_function: WallFunction | None = pd.Field( None, description="Wall function configuration. Set to :class:`WallFunction` to enable " + "wall functions. The default wall function type is ``'BoundaryLayer'``. " + "Set to ``None`` to disable wall functions (no-slip wall).", ) velocity: WallVelocityModelTypes | VelocityVectorType | None = pd.Field( None, description="Prescribe a velocity or the velocity model on the wall." ) heat_spec: HeatFlux | Temperature = pd.Field( HeatFlux(0 * u.W / u.m**2), discriminator="type_name", description="Specify the heat flux or temperature at the `Wall` boundary.", ) roughness_height: Length.NonNegativeFloat64 = pd.Field( 0 * u.m, description="Equivalent sand grain roughness height. Available only to `Fluid` zone boundaries.", ) private_attribute_dict: dict | None = pd.Field(None) entities: EntityList[Surface, MirroredSurface, WindTunnelGhostSurface] = pd.Field( alias="surfaces", description="List of boundaries with the `Wall` boundary condition imposed.", ) @pd.field_validator("use_wall_function", mode="before") @classmethod def _normalize_wall_function(cls, value): """Handle backward-compatible bool inputs for use_wall_function.""" if value is True: logger.warning( "Passing a bool to `use_wall_function` is deprecated. " "Use `use_wall_function=WallFunction()` instead of `True`." ) return WallFunction() if value is False: logger.warning( "Passing a bool to `use_wall_function` is deprecated. Use `use_wall_function=None` instead of `False`." ) return None return value @pd.field_validator("velocity", mode="before") @classmethod def _normalize_velocity(cls, value): if isinstance(value, dict) and value.get("type_name") is not None: return WALL_VELOCITY_MODEL_ADAPTER.validate_python(value) return value
[docs] @pd.model_validator(mode="after") def check_wall_function_conflict(self): """Check no setting is conflicting with the usage of wall function""" if self.use_wall_function is None: return self if isinstance(self.velocity, SlaterPorousBleed): raise ValueError(f"Using `{type(self.velocity).__name__}` with wall function is not supported currently.") return self
@contextual_field_validator("heat_spec", mode="after") @classmethod def _ensure_adiabatic_wall_for_liquid(cls, value, param_info: ParamsValidationInfo): """Allow only adiabatic wall when liquid operating condition is used""" if param_info.using_liquid_as_material is False: return value if isinstance(value, HeatFlux) and value.value == 0 * u.W / u.m**2: return value raise ValueError("Only adiabatic wall is allowed when using liquid as simulation material.") @contextual_field_validator("velocity", mode="after") @classmethod def _disable_expression_for_liquid(cls, value, param_info: ParamsValidationInfo): if param_info.using_liquid_as_material is False: return value if isinstance(value, tuple): if isinstance(value[0], str) and isinstance(value[1], str) and isinstance(value[2], str): raise ValueError("Expression cannot be used when using liquid as simulation material.") return value
[docs] class Freestream(BoundaryBaseWithTurbulenceQuantities): """ :class:`Freestream` defines the freestream boundary condition. Example ------- - Define freestream boundary condition with velocity expression and boundaries from the volume mesh: >>> fl.Freestream( ... surfaces=[volume_mesh["blk-1/freestream-part1"], ... volume_mesh["blk-1/freestream-part2"]], ... velocity = ["min(0.2, 0.2 + 0.2*y/0.5)", "0", "0.1*y/0.5"] ... ) - Define freestream boundary condition with turbulence quantities and automated farfield: >>> auto_farfield = fl.AutomatedFarfield() ... fl.Freestream( ... entities=[auto_farfield.farfield], ... turbulence_quantities= fl.TurbulenceQuantities( ... modified_viscosity_ratio=10, ... ) ... ) ==== """ name: str | None = pd.Field("Freestream", description="Name of the `Freestream` boundary condition.") type: Literal["Freestream"] = pd.Field("Freestream", frozen=True) velocity: VelocityVectorType | None = pd.Field( None, description="The default values are set according to the " + ":py:attr:`AerospaceCondition.alpha` and :py:attr:`AerospaceCondition.beta` angles. " + "Optionally, an expression for each of the velocity components can be specified.", ) entities: EntityList[ Surface, MirroredSurface, GhostSurface, WindTunnelGhostSurface, GhostSphere, GhostCircularPlane, ] = pd.Field( alias="surfaces", description="List of boundaries with the `Freestream` boundary condition imposed.", ) @contextual_field_validator("velocity", mode="after") @classmethod def _disable_expression_for_liquid(cls, value, param_info: ParamsValidationInfo): if param_info.using_liquid_as_material is False: return value if isinstance(value, tuple): if isinstance(value[0], str) and isinstance(value[1], str) and isinstance(value[2], str): raise ValueError("Expression cannot be used when using liquid as simulation material.") return value
[docs] class Outflow(BoundaryBase): """ :class:`Outflow` defines the outflow boundary condition based on the input :py:attr:`spec`. Example ------- - Define outflow boundary condition with pressure: >>> fl.Outflow( ... surfaces=volume_mesh["fluid/outlet"], ... spec=fl.Pressure(value = 0.99e6 * fl.u.Pa) ... ) - Define outflow boundary condition with Mach number: >>> fl.Outflow( ... surfaces=volume_mesh["fluid/outlet"], ... spec=fl.Mach(value = 0.2) ... ) - Define outflow boundary condition with mass flow rate: >>> fl.Outflow( ... surfaces=volume_mesh["fluid/outlet"], ... spec=fl.MassFlowRate(value = 123 * fl.u.lb / fl.u.s) ... ) ==== """ name: str | None = pd.Field("Outflow", description="Name of the `Outflow` boundary condition.") type: Literal["Outflow"] = pd.Field("Outflow", frozen=True) spec: Pressure | MassFlowRate | Mach = pd.Field( discriminator="type_name", description="Specify the static pressure, mass flow rate, or Mach number parameters at" + " the `Outflow` boundary.", ) entities: EntityList[Surface, MirroredSurface, WindTunnelGhostSurface] = pd.Field( alias="surfaces", description="List of boundaries with the `Outflow` boundary condition imposed.", )
[docs] class Inflow(BoundaryBaseWithTurbulenceQuantities): """ :class:`Inflow` defines the inflow boundary condition based on the input :py:attr:`spec`. Example ------- - Define inflow boundary condition with pressure: >>> fl.Inflow( ... entities=[geometry["inflow"]], ... total_temperature=300 * fl.u.K, ... spec=fl.TotalPressure( ... value = 1.028e6 * fl.u.Pa, ... ), ... velocity_direction = (1, 0, 0), ... ) - Define inflow boundary condition with mass flow rate: >>> fl.Inflow( ... entities=[volume_mesh["fluid/inflow"]], ... total_temperature=300 * fl.u.K, ... spec=fl.MassFlowRate( ... value = 123 * fl.u.lb / fl.u.s, ... ramp_steps = 10, ... ), ... velocity_direction = (1, 0, 0), ... ) - Define inflow boundary condition with turbulence quantities: >>> fl.Inflow( ... entities=[volume_mesh["fluid/inflow"]], ... turbulence_quantities=fl.TurbulenceQuantities( ... turbulent_kinetic_energy=2.312e-3 * fl.u.m **2 / fl.u.s**2, ... specific_dissipation_rate= 1020 / fl.u.s, ... ) ... ) - Define inflow boundary condition with expressions for spatially varying total temperature and total pressure: >>> fl.Inflow( ... entities=[volumeMesh["fluid/inflow"]], ... total_temperature="1.0+0.2*pow(0.1*(1.0-y*y),2.0)", ... velocity_direction=(1.0, 0.0, 0.0), ... spec=fl.TotalPressure( ... value="pow(1.0+0.2*pow(0.1*(1.0-y*y),2.0),1.4/0.4)", ... ), ... ) ==== """ name: str | None = pd.Field("Inflow", description="Name of the `Inflow` boundary condition.") type: Literal["Inflow"] = pd.Field("Inflow", frozen=True) total_temperature: StringExpression | AbsoluteTemperature.Float64 = pd.Field( description="Specify the total temperature at the `Inflow` boundary." + " When a string expression is supplied the value" + " needs to nondimensionalized by the temperature defined in `operating_condition`." ) spec: TotalPressure | MassFlowRate | Supersonic = pd.Field( discriminator="type_name", description="Specify additional conditions at the `Inflow` boundary.", ) velocity_direction: Axis | None = pd.Field( None, description="Direction of the incoming flow. Must be a unit vector pointing " + "into the volume. If unspecified, the direction will be normal to the surface.", ) rotate_velocity_direction_with_mesh: bool = pd.Field( False, description="When True, the velocity direction vector rotates with the mesh at each " + "physical time step. Use this when the inflow boundary is inside a rotating zone and " + "the velocity direction should be specified relative to the body frame rather than the " + "inertial frame. Only relevant when `velocity_direction` is set.", ) entities: EntityList[Surface, MirroredSurface, WindTunnelGhostSurface] = pd.Field( alias="surfaces", description="List of boundaries with the `Inflow` boundary condition imposed.", ) @pd.model_validator(mode="after") def _ensure_velocity_direction_for_mesh_rotation(self): """`rotate_velocity_direction_with_mesh` is only meaningful when `velocity_direction` is set.""" if self.rotate_velocity_direction_with_mesh and self.velocity_direction is None: raise ValueError( "`rotate_velocity_direction_with_mesh` cannot be set when `velocity_direction` is not specified." ) return self
[docs] class SlipWall(BoundaryBase): """:class:`SlipWall` class defines the :code:`SlipWall` boundary condition. Example ------- Define :code:`SlipWall` boundary condition for entities with the naming pattern: :code:`"*/slipWall"` in the volume mesh. >>> fl.SlipWall(entities=volume_mesh["*/slipWall"] - Define :code:`SlipWall` boundary condition with automated farfield symmetry plane boundaries: >>> auto_farfield = fl.AutomatedFarfield() >>> fl.SlipWall( ... entities=[auto_farfield.symmetry_planes], ... turbulence_quantities= fl.TurbulenceQuantities( ... modified_viscosity_ratio=10, ... ) ... ) ==== """ name: str | None = pd.Field("Slip wall", description="Name of the `SlipWall` boundary condition.") type: Literal["SlipWall"] = pd.Field("SlipWall", frozen=True) entities: EntityList[Surface, MirroredSurface, GhostSurface, WindTunnelGhostSurface, GhostCircularPlane] = pd.Field( alias="surfaces", description="List of boundaries with the :code:`SlipWall` boundary condition imposed.", )
[docs] class SymmetryPlane(BoundaryBase): """ :class:`SymmetryPlane` defines the symmetric boundary condition. It is similar to :class:`SlipWall`, but the normal gradient of scalar quantities are forced to be zero on the symmetry plane. **Only planar surfaces are supported.** Example ------- >>> fl.SymmetryPlane(entities=volume_mesh["fluid/symmetry"]) - Define `SymmetryPlane` boundary condition with automated farfield symmetry plane boundaries: >>> auto_farfield = fl.AutomatedFarfield() >>> fl.SymmetryPlane( ... entities=[auto_farfield.symmetry_planes], ... ) ==== """ name: str | None = pd.Field("Symmetry", description="Name of the `SymmetryPlane` boundary condition.") type: Literal["SymmetryPlane"] = pd.Field("SymmetryPlane", frozen=True) entities: EntityList[Surface, MirroredSurface, GhostSurface, GhostCircularPlane] = pd.Field( alias="surfaces", description="List of boundaries with the `SymmetryPlane` boundary condition imposed.", )
[docs] class Periodic(Flow360BaseModel): """ :class:`Periodic` defines the translational or rotational periodic boundary condition. Example ------- - Define a translationally periodic boundary condition using :class:`Translational`: >>> fl.Periodic( ... surface_pairs=[ ... (volume_mesh["VOLUME/BOTTOM"], volume_mesh["VOLUME/TOP"]), ... (volume_mesh["VOLUME/RIGHT"], volume_mesh["VOLUME/LEFT"]), ... ], ... spec=fl.Translational(), ... ) - Define a rotationally periodic boundary condition using :class:`Rotational`: >>> fl.Periodic( ... surface_pairs=[(volume_mesh["VOLUME/PERIODIC-1"], ... volume_mesh["VOLUME/PERIODIC-2"])], ... spec=fl.Rotational() ... ) ==== """ name: str | None = pd.Field("Periodic", description="Name of the `Periodic` boundary condition.") type: Literal["Periodic"] = pd.Field("Periodic", frozen=True) entity_pairs: UniqueItemList[SurfacePair | GhostSurfacePair] = pd.Field( alias="surface_pairs", description="List of matching pairs of :class:`~flow360.Surface` or `~flow360.GhostSurface`. ", ) spec: Translational | Rotational = pd.Field( discriminator="type_name", description="Define the type of periodic boundary condition (translational/rotational) " + "via :class:`Translational`/:class:`Rotational`.", ) private_attribute_id: str = pd.Field(default_factory=generate_uuid, frozen=True)
[docs] @contextual_field_validator("entity_pairs", mode="after") @classmethod def ensure_surface_existence(cls, value, param_info: ParamsValidationInfo): """Ensure all boundaries will be present after mesher""" for surface_pair in value.items: check_deleted_surface_pair(surface_pair, param_info) return value
@contextual_field_validator("entity_pairs", mode="after") @classmethod def _ensure_quasi_3d_periodic_when_using_ghost_surface(cls, value, param_info: ParamsValidationInfo): """ When using ghost surface pairs, ensure the farfield type is quasi-3d-periodic. """ for surface_pair in value.items: if isinstance(surface_pair, GhostSurfacePair): if param_info.farfield_method != "quasi-3d-periodic": raise ValueError("Farfield type must be 'quasi-3d-periodic' when using GhostSurfacePair.") return value
[docs] class PorousJump(Flow360BaseModel): """ :class:`PorousJump` defines the Porous Jump boundary condition. Provide a flat list of every face that should be a porous-jump boundary via ``surfaces``. Each face must be, or become after meshing, one side of a multizone interface: the two coincident faces must already be present in the imported geometry, surface mesh, or volume mesh. List both sides of every interface; the donor/receiver pairing is recovered from the mesh metadata downstream. From a geometry the two coincident CAD faces must already exist and be enclosed by custom volumes; a single shared or non-manifold face is not turned into a porous-jump interface. Example ------- >>> fl.PorousJump( ... surfaces=[ ... volume_mesh["blk-1/Interface-blk-2"], ... volume_mesh["blk-2/Interface-blk-1"], ... volume_mesh["blk-1/Interface-blk-3"], ... volume_mesh["blk-3/Interface-blk-1"], ... ], ... darcy_coefficient = 1e6 / fl.u.m **2, ... forchheimer_coefficient = 1 / fl.u.m, ... thickness = 1 * fl.u.m, ... ) .. deprecated:: The ``surface_pairs=[(A, B), ...]`` pair form is accepted for one more minor release and is rewritten internally into the flat ``surfaces`` form. New code should use ``surfaces=`` directly. ==== """ name: str | None = pd.Field("PorousJump", description="Name of the `PorousJump` boundary condition.") type: Literal["PorousJump"] = pd.Field("PorousJump", frozen=True) entities: EntityList[Surface, MirroredSurface] = pd.Field( alias="surfaces", description=( "Flat list of boundaries that form porous-jump interfaces. " "Each face must be, or become after meshing, one side of a " "multizone interface; list both coincident faces of every " "interface. The donor/receiver pairing is recovered from mesh " "metadata downstream." ), ) darcy_coefficient: InverseArea.Float64 = pd.Field( description="Darcy coefficient of the porous media model which determines the scaling of the " + "viscous loss term. The value defines the coefficient for the axis normal " + "to the surface." ) forchheimer_coefficient: InverseLength.Float64 = pd.Field( description="Forchheimer coefficient of the porous media model which determines " + "the scaling of the inertial loss term." ) thickness: Length.Float64 = pd.Field(description="Thickness of the thin porous media on the surface") private_attribute_id: str = pd.Field(default_factory=generate_uuid, frozen=True) # Target version is the highest patch within the current minor so the # validator survives any 25.10.x patch but raises on the 25.11.0 bump. @pd.model_validator(mode="before") @classmethod @deprecation_reminder("25.10.999") def _expand_legacy_pair_input(cls, data): """Rewrite legacy ``surface_pairs`` / ``entity_pairs`` input into the canonical flat ``surfaces`` form. Mutual exclusion with ``surfaces`` is enforced here. Emits a :class:`DeprecationWarning` whenever the legacy form is used. """ if not isinstance(data, dict): return data legacy_key = next( (k for k in ("surface_pairs", "entity_pairs") if data.get(k) is not None), None, ) if legacy_key is None: return data if any(data.get(k) is not None for k in ("surfaces", "entities")): raise ValueError("PorousJump: provide either `surfaces` or `surface_pairs`, not both.") pairs = data.pop(legacy_key) if isinstance(pairs, dict) and "items" in pairs: pair_items = pairs["items"] elif isinstance(pairs, list): pair_items = pairs else: raise ValueError(f"PorousJump: `{legacy_key}` must be a list of surface pairs.") flat: list = [] for item in pair_items: if isinstance(item, dict) and "pair" in item: flat.extend(item["pair"]) elif isinstance(item, (list, tuple)) and len(item) == 2: flat.extend(item) elif hasattr(item, "pair"): flat.extend(item.pair) else: raise ValueError(f"PorousJump: unrecognized `{legacy_key}` element {item!r}.") data["surfaces"] = flat deprecation_message = ( "PorousJump: `surface_pairs` is deprecated; pass `surfaces=[A, B, ...]` " "as a flat list of boundaries instead. Donor/receiver pairing is " "recovered from mesh metadata downstream." ) # logger.warning surfaces via the client's bridge handler regardless of # Python's warning filters; warnings.warn keeps the standard hook for # tooling (pytest.warns, CI deprecation scanners). logger.warning(deprecation_message) warnings.warn(deprecation_message, DeprecationWarning, stacklevel=2) return data
[docs] @contextual_field_validator("entities", mode="after") @classmethod def ensure_porous_jump_interfaces(cls, value, param_info: ParamsValidationInfo): """Validate that every surface will exist after meshing and either is, or will become, an interface. A surface is accepted if it is already an interface OR will become one after meshing (it is a boundary of a CustomVolume, on the farfield enclosed set, or is a dual-belonging face). """ expanded = param_info.expand_entity_list(value) check_deleted_surface_in_entity_list(expanded, param_info) cv_boundary_ids: set = set() for cv_info in param_info.to_be_generated_custom_volumes.values(): cv_boundary_ids |= cv_info.get("boundary_surface_ids", set()) enclosed_ids = set(param_info.farfield_enclosed_entities or {}) dual_ids = set(param_info.farfield_cv_dual_belonging_ids or set()) for surface in expanded: if not isinstance(surface, Surface): continue if surface.private_attribute_is_interface: continue sid = surface.private_attribute_id if sid in cv_boundary_ids or sid in enclosed_ids or sid in dual_ids: continue raise ValueError(f"Boundary `{surface.name}` is not an interface") return value
SurfaceModelTypes = Union[ Wall, SlipWall, Freestream, Outflow, Inflow, Periodic, SymmetryPlane, PorousJump, ]