Source code for flow360.component.simulation.operating_condition.operating_condition

"""Operating conditions for the simulation framework."""

from typing import Literal, Optional, Tuple, Union

import pydantic as pd
from typing_extensions import Self

import flow360.component.simulation.units as u
from flow360.component.simulation.framework.base_model import Flow360BaseModel
from flow360.component.simulation.framework.expressions import StringExpression
from flow360.component.simulation.framework.multi_constructor_model_base import (
    MultiConstructorBaseModel,
)
from flow360.component.simulation.models.material import Air, Water
from flow360.component.simulation.operating_condition.atmosphere_model import (
    StandardAtmosphereModel,
)
from flow360.component.simulation.unit_system import (
    AbsoluteTemperatureType,
    AngleType,
    DeltaTemperatureType,
    DensityType,
    LengthType,
    PressureType,
    VelocityType,
    ViscosityType,
)
from flow360.component.simulation.user_code.core.types import (
    Expression,
    ValueOrExpression,
)
from flow360.component.simulation.validation.validation_context import (
    CASE,
    CaseField,
    ConditionalField,
    context_validator,
    get_validation_info,
)
from flow360.log import log

# pylint: disable=no-member
VelocityVectorType = Union[
    Tuple[StringExpression, StringExpression, StringExpression], VelocityType.Vector
]


class ThermalStateCache(Flow360BaseModel):
    """[INTERNAL] Cache for thermal state inputs"""

    # pylint: disable=no-member
    altitude: Optional[LengthType] = None
    temperature_offset: Optional[DeltaTemperatureType] = None


[docs] class ThermalState(MultiConstructorBaseModel): """ Represents the thermal state of a fluid with specific properties. Example ------- >>> fl.ThermalState( ... temperature=300 * fl.u.K, ... density=1.225 * fl.u.kg / fl.u.m**3, ... material=fl.Air() ... ) ==== """ # pylint: disable=fixme # TODO: remove frozen and throw warning if temperature/density is modified after construction from atmospheric model type_name: Literal["ThermalState"] = pd.Field("ThermalState", frozen=True) temperature: AbsoluteTemperatureType = pd.Field( 288.15 * u.K, frozen=True, description="The temperature of the fluid." ) density: DensityType.Positive = pd.Field( 1.225 * u.kg / u.m**3, frozen=True, description="The density of the fluid." ) material: Air = pd.Field(Air(), frozen=True, description="The material of the fluid.") private_attribute_input_cache: ThermalStateCache = ThermalStateCache() private_attribute_constructor: Literal["from_standard_atmosphere", "default"] = pd.Field( default="default", frozen=True ) # pylint: disable=no-self-argument, not-callable, unused-argument
[docs] @MultiConstructorBaseModel.model_constructor @pd.validate_call def from_standard_atmosphere( cls, altitude: LengthType = 0 * u.m, temperature_offset: DeltaTemperatureType = 0 * u.K, ): """ Constructs a :class:`ThermalState` instance from the standard atmosphere model. Parameters ---------- altitude : LengthType, optional The altitude at which the thermal state is calculated. Defaults to ``0 * u.m``. temperature_offset : DeltaTemperatureType, optional The temperature offset to be applied to the standard temperature at the given altitude. Defaults to ``0 * u.K``. Returns ------- ThermalState A thermal state representing the atmospheric conditions at the specified altitude and temperature offset. Notes ----- - This method uses the :class:`StandardAtmosphereModel` to compute the standard atmospheric conditions based on the given altitude. - The ``temperature_offset`` allows for adjustments to the standard temperature, simulating non-standard atmospheric conditions. Examples -------- Create a thermal state at an altitude of 10,000 meters: >>> thermal_state = ThermalState.from_standard_atmosphere(altitude=10000 * u.m) >>> thermal_state.temperature <calculated_temperature> >>> thermal_state.density <calculated_density> Apply a temperature offset of -5 Fahrenheit at 5,000 meters: >>> thermal_state = ThermalState.from_standard_atmosphere( ... altitude=5000 * u.m, ... temperature_offset=-5 * u.delta_degF ... ) >>> thermal_state.temperature <adjusted_temperature> >>> thermal_state.density <adjusted_density> """ standard_atmosphere_model = StandardAtmosphereModel( altitude.in_units(u.m).value, temperature_offset.in_units(u.K).value ) # Construct and return the thermal state state = cls( density=standard_atmosphere_model.density * u.kg / u.m**3, temperature=standard_atmosphere_model.temperature * u.K, material=Air(), ) return state
@property def altitude(self) -> Optional[LengthType]: """Return user specified altitude.""" if not self.private_attribute_input_cache.altitude: log.warning("Altitude not provided from input") return self.private_attribute_input_cache.altitude @property def temperature_offset(self) -> Optional[DeltaTemperatureType]: """Return user specified temperature offset.""" if not self.private_attribute_input_cache.temperature_offset: log.warning("Temperature offset not provided from input") return self.private_attribute_input_cache.temperature_offset @property def speed_of_sound(self) -> VelocityType.Positive: """Computes speed of sound.""" return self.material.get_speed_of_sound(self.temperature) @property def pressure(self) -> PressureType.Positive: """Computes pressure.""" return self.material.get_pressure(self.density, self.temperature) @property def dynamic_viscosity(self) -> ViscosityType.Positive: """Computes dynamic viscosity.""" return self.material.get_dynamic_viscosity(self.temperature)
class GenericReferenceConditionCache(Flow360BaseModel): """[INTERNAL] Cache for GenericReferenceCondition inputs""" thermal_state: Optional[ThermalState] = None mach: Optional[pd.PositiveFloat] = None
[docs] class GenericReferenceCondition(MultiConstructorBaseModel): """ Operating condition defines the physical (non-geometrical) reference values for the problem. Example ------- - Define :class:`GenericReferenceCondition` with :py:meth:`from_mach`: >>> fl.GenericReferenceCondition.from_mach( ... mach=0.2, ... thermal_state=ThermalState(), ... ) - Define :class:`GenericReferenceCondition` with :py:attr:`velocity_magnitude`: >>> fl.GenericReferenceCondition(velocity_magnitude=40 * fl.u.m / fl.u.s) ==== """ type_name: Literal["GenericReferenceCondition"] = pd.Field( "GenericReferenceCondition", frozen=True ) velocity_magnitude: Optional[ValueOrExpression[VelocityType.Positive]] = ConditionalField( context=CASE, description="Freestream velocity magnitude. Used as reference velocity magnitude" + " when :py:attr:`reference_velocity_magnitude` is not specified. Cannot change once specified.", frozen=True, ) thermal_state: ThermalState = pd.Field( ThermalState(), description="Reference and freestream thermal state. Defaults to US standard atmosphere at sea level.", ) private_attribute_input_cache: GenericReferenceConditionCache = GenericReferenceConditionCache() # pylint: disable=no-self-argument, not-callable
[docs] @MultiConstructorBaseModel.model_constructor @pd.validate_call def from_mach( cls, mach: pd.PositiveFloat, thermal_state: ThermalState = ThermalState(), ): """Constructs a reference condition from Mach number and thermal state.""" velocity_magnitude = mach * thermal_state.speed_of_sound return cls(velocity_magnitude=velocity_magnitude, thermal_state=thermal_state)
@property def mach(self) -> pd.PositiveFloat: """Computes Mach number.""" return (self.velocity_magnitude / self.thermal_state.speed_of_sound).value @pd.field_validator("thermal_state", mode="after") @classmethod def _update_input_cache(cls, value, info: pd.ValidationInfo): setattr(info.data["private_attribute_input_cache"], info.field_name, value) return value
class AerospaceConditionCache(Flow360BaseModel): """[INTERNAL] Cache for AerospaceCondition inputs""" mach: Optional[pd.NonNegativeFloat] = None reynolds_mesh_unit: Optional[pd.PositiveFloat] = None project_length_unit: Optional[LengthType.Positive] = None alpha: Optional[AngleType] = None beta: Optional[AngleType] = None temperature: Optional[AbsoluteTemperatureType] = None thermal_state: Optional[ThermalState] = pd.Field(None, alias="atmosphere") reference_mach: Optional[pd.PositiveFloat] = None
[docs] class AerospaceCondition(MultiConstructorBaseModel): """ Operating condition for aerospace applications. Defines both reference parameters used to compute nondimensional coefficients in postprocessing and the default :class:`Freestream` boundary condition for the simulation. Example ------- - Define :class:`AerospaceCondition` with :py:meth:`from_mach`: >>> fl.AerospaceCondition.from_mach( ... mach=0, ... alpha=-90 * fl.u.deg, ... thermal_state=fl.ThermalState(), ... reference_mach=0.69, ... ) - Define :class:`AerospaceCondition` with :py:attr:`velocity_magnitude`: >>> fl.AerospaceCondition(velocity_magnitude=40 * fl.u.m / fl.u.s) ==== """ type_name: Literal["AerospaceCondition"] = pd.Field("AerospaceCondition", frozen=True) alpha: AngleType = ConditionalField(0 * u.deg, description="The angle of attack.", context=CASE) beta: AngleType = ConditionalField(0 * u.deg, description="The side slip angle.", context=CASE) velocity_magnitude: Optional[ValueOrExpression[VelocityType.NonNegative]] = ConditionalField( description="Freestream velocity magnitude. Used as reference velocity magnitude" + " when :py:attr:`reference_velocity_magnitude` is not specified.", context=CASE, frozen=True, ) thermal_state: ThermalState = pd.Field( ThermalState(), alias="atmosphere", description="Reference and freestream thermal state. Defaults to US standard atmosphere at sea level.", ) reference_velocity_magnitude: Optional[VelocityType.Positive] = CaseField( None, description="Reference velocity magnitude. Is required when :py:attr:`velocity_magnitude` is 0.", frozen=True, ) private_attribute_input_cache: AerospaceConditionCache = AerospaceConditionCache() # pylint: disable=too-many-arguments, no-self-argument, not-callable
[docs] @MultiConstructorBaseModel.model_constructor @pd.validate_call def from_mach( cls, mach: pd.NonNegativeFloat, alpha: AngleType = 0 * u.deg, beta: AngleType = 0 * u.deg, thermal_state: ThermalState = ThermalState(), reference_mach: Optional[pd.PositiveFloat] = None, ): """ Constructs an :class:`AerospaceCondition` instance from a Mach number and thermal state. Parameters ---------- mach : float Freestream Mach number (non-negative). Used as reference Mach number when ``reference_mach`` is not specified. alpha : AngleType, optional The angle of attack. Defaults to ``0 * u.deg``. beta : AngleType, optional The side slip angle. Defaults to ``0 * u.deg``. thermal_state : ThermalState, optional Reference and freestream thermal state. Defaults to US standard atmosphere at sea level. reference_mach : float, optional Reference Mach number (positive). If provided, calculates the reference velocity magnitude. Returns ------- AerospaceCondition An instance of :class:`AerospaceCondition` with the calculated velocity magnitude and provided parameters. Notes ----- - The ``velocity_magnitude`` is calculated as ``mach * thermal_state.speed_of_sound``. - If ``reference_mach`` is provided, the ``reference_velocity_magnitude`` is calculated as ``reference_mach * thermal_state.speed_of_sound``. Examples -------- Create an aerospace condition with a Mach number of 0.85: >>> condition = AerospaceCondition.from_mach(mach=0.85) >>> condition.velocity_magnitude <calculated_value> Specify angle of attack and side slip angle: >>> condition = AerospaceCondition.from_mach(mach=0.85, alpha=5 * u.deg, beta=2 * u.deg) Include a custom thermal state and reference Mach number: >>> custom_thermal = ThermalState(temperature=250 * u.K) >>> condition = AerospaceCondition.from_mach( ... mach=0.85, ... thermal_state=custom_thermal, ... reference_mach=0.8 ... ) """ velocity_magnitude = mach * thermal_state.speed_of_sound reference_velocity_magnitude = ( reference_mach * thermal_state.speed_of_sound if reference_mach else None ) return cls( velocity_magnitude=velocity_magnitude, alpha=alpha, beta=beta, thermal_state=thermal_state, reference_velocity_magnitude=reference_velocity_magnitude, )
# pylint: disable=too-many-arguments
[docs] @MultiConstructorBaseModel.model_constructor @pd.validate_call def from_mach_reynolds( cls, mach: pd.PositiveFloat, reynolds_mesh_unit: pd.PositiveFloat, project_length_unit: Optional[LengthType.Positive], alpha: AngleType = 0 * u.deg, beta: AngleType = 0 * u.deg, temperature: AbsoluteTemperatureType = 288.15 * u.K, reference_mach: Optional[pd.PositiveFloat] = None, ): """ Create an `AerospaceCondition` from Mach number and Reynolds number. This function computes the thermal state based on the given Mach number, Reynolds number, and temperature, and returns an `AerospaceCondition` object initialized with the computed thermal state and given aerodynamic angles. Parameters ---------- mach : NonNegativeFloat Freestream Mach number (must be non-negative). reynolds_mesh_unit : PositiveFloat Freestream Reynolds number scaled to mesh unit (must be positive). For example if the mesh unit is 1 mm, the reynolds_mesh_unit should be equal to a Reynolds number that has the characteristic length of 1 mm. project_length_unit: LengthType.Positive Project length unit used to compute the density (must be positive). alpha : AngleType, optional Angle of attack. Default is 0 degrees. beta : AngleType, optional Sideslip angle. Default is 0 degrees. temperature : AbsoluteTemperatureType, optional Freestream static temperature (must be a positive temperature value). Default is 288.15 Kelvin. reference_mach : PositiveFloat, optional Reference Mach number. Default is None. Returns ------- AerospaceCondition An instance of :class:`AerospaceCondition` with calculated velocity, thermal state and provided parameters. Example ------- Example usage: >>> condition = fl.AerospaceCondition.from_mach_reynolds( ... mach=0.85, ... reynolds_mesh_unit=1e6, ... project_length_unit=1 * u.mm, ... temperature=288.15 * u.K, ... alpha=2.0 * u.deg, ... beta=0.0 * u.deg, ... reference_mach=0.85, ... ) >>> print(condition) AerospaceCondition(...) """ if temperature.units is u.K and temperature.value == 288.15: log.info("Default value of 288.15 K will be used as temperature.") if project_length_unit is None: validation_info = get_validation_info() if validation_info is None or validation_info.project_length_unit is None: raise ValueError("Project length unit must be provided.") project_length_unit = validation_info.project_length_unit material = Air() velocity = mach * material.get_speed_of_sound(temperature) density = ( reynolds_mesh_unit * material.get_dynamic_viscosity(temperature) / (velocity * project_length_unit) ) thermal_state = ThermalState(temperature=temperature, density=density) velocity_magnitude = mach * thermal_state.speed_of_sound reference_velocity_magnitude = ( reference_mach * thermal_state.speed_of_sound if reference_mach else None ) log.info( """Density and viscosity were calculated based on input data, ThermalState will be automatically created.""" ) # pylint: disable=no-value-for-parameter return cls( velocity_magnitude=velocity_magnitude, alpha=alpha, beta=beta, thermal_state=thermal_state, reference_velocity_magnitude=reference_velocity_magnitude, )
@property def _evaluated_velocity_magnitude(self) -> VelocityType.Positive: if isinstance(self.velocity_magnitude, Expression): return self.velocity_magnitude.evaluate( raise_on_non_evaluable=True, force_evaluate=True ) return self.velocity_magnitude @pd.model_validator(mode="after") @context_validator(context=CASE) def check_valid_reference_velocity(self) -> Self: """Ensure reference velocity is provided when freestream velocity is 0.""" if self.velocity_magnitude is None: return self if self.reference_velocity_magnitude is not None: return self evaluated_velocity_magnitude = self._evaluated_velocity_magnitude if evaluated_velocity_magnitude.value == 0: raise ValueError( "Reference velocity magnitude/Mach must be provided when freestream velocity magnitude/Mach is 0." ) return self @property def mach(self) -> pd.PositiveFloat: """Computes Mach number.""" return (self._evaluated_velocity_magnitude / self.thermal_state.speed_of_sound).value @pd.field_validator("alpha", "beta", "thermal_state", mode="after") @classmethod def _update_input_cache(cls, value, info: pd.ValidationInfo): setattr(info.data["private_attribute_input_cache"], info.field_name, value) return value
[docs] @pd.validate_call def flow360_reynolds_number(self, length_unit: LengthType.Positive): """ Computes length_unit based Reynolds number. :math:`Re = \\rho_{\\infty} \\cdot U_{\\infty} \\cdot L_{grid}/\\mu_{\\infty}` where - :math:`\\rho_{\\infty}` is the freestream fluid density. - :math:`U_{\\infty}` is the freestream velocity magnitude. - :math:`L_{grid}` is physical length represented by unit length in the given mesh/geometry file. - :math:`\\mu_{\\infty}` is the dynamic eddy viscosity of the fluid of freestream. Parameters ---------- length_unit : LengthType.Positive Physical length represented by unit length in the given mesh/geometry file. """ return ( self.thermal_state.density * self._evaluated_velocity_magnitude * length_unit / self.thermal_state.dynamic_viscosity ).value
[docs] class LiquidOperatingCondition(Flow360BaseModel): """ Operating condition for simulation of water as the only material. Example ------- >>> fl.LiquidOperatingCondition( ... velocity_magnitude=10 * fl.u.m / fl.u.s, ... alpha=-90 * fl.u.deg, ... beta=0 * fl.u.deg, ... material=fl.Water(name="Water"), ... reference_velocity_magnitude=5 * fl.u.m / fl.u.s, ... ) ==== """ type_name: Literal["LiquidOperatingCondition"] = pd.Field( "LiquidOperatingCondition", frozen=True ) alpha: AngleType = ConditionalField(0 * u.deg, description="The angle of attack.", context=CASE) beta: AngleType = ConditionalField(0 * u.deg, description="The side slip angle.", context=CASE) velocity_magnitude: Optional[ValueOrExpression[VelocityType.NonNegative]] = ConditionalField( context=CASE, description="Incoming flow velocity magnitude. Used as reference velocity magnitude" + " when :py:attr:`reference_velocity_magnitude` is not specified. Cannot change once specified.", frozen=True, ) reference_velocity_magnitude: Optional[VelocityType.Positive] = CaseField( None, description="Reference velocity magnitude. Is required when :py:attr:`velocity_magnitude` is 0." " Used as the velocity scale for nondimensionalization.", frozen=True, ) material: Water = pd.Field( Water(name="Water"), description="Type of liquid material used.", ) @property def _evaluated_velocity_magnitude(self) -> VelocityType.Positive: if isinstance(self.velocity_magnitude, Expression): return self.velocity_magnitude.evaluate( raise_on_non_evaluable=True, force_evaluate=True ) return self.velocity_magnitude @pd.model_validator(mode="after") @context_validator(context=CASE) def check_valid_reference_velocity(self) -> Self: """Ensure reference velocity is provided when freestream velocity is 0.""" if self.velocity_magnitude is None: return self if self.reference_velocity_magnitude is not None: return self evaluated_velocity_magnitude = self._evaluated_velocity_magnitude if evaluated_velocity_magnitude.value == 0: raise ValueError( "Reference velocity magnitude/Mach must be provided when freestream velocity magnitude/Mach is 0." ) return self
# pylint: disable=fixme # TODO: AutomotiveCondition OperatingConditionTypes = Union[ GenericReferenceCondition, AerospaceCondition, LiquidOperatingCondition ]