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
1 change: 1 addition & 0 deletions flow360/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@
from flow360.component.simulation.models.material import (
Air,
FrozenSpecies,
Gas,
NASA9Coefficients,
NASA9CoefficientSet,
SolidMaterial,
Expand Down
256 changes: 166 additions & 90 deletions flow360/component/simulation/models/material.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@
VelocityType,
ViscosityType,
)
from flow360.component.simulation.validation.validation_context import (
add_validation_warning,
contextual_model_validator,
)

# =============================================================================
# NASA 9-Coefficient Polynomial Utility Functions
Expand Down Expand Up @@ -417,124 +421,96 @@ def get_dynamic_viscosity(


# pylint: disable=no-member, missing-function-docstring
class Air(MaterialBase):
class Gas(MaterialBase):
"""
Represents the material properties for air.
This sets specific material properties for air,
including dynamic viscosity, specific heat ratio, gas constant, and Prandtl number.
Represents a generic gas material with thermally perfect gas properties.

The thermodynamic properties are specified using NASA 9-coefficient polynomials
for temperature-dependent specific heats via the `thermally_perfect_gas` parameter.
By default, coefficients are set to reproduce a constant gamma=1.4 (calorically perfect gas).
This class provides configurable gas properties including the specific gas constant,
dynamic viscosity, thermally perfect gas model (NASA 9-coefficient polynomials),
and Prandtl numbers. All fields must be explicitly specified (no defaults),
making it suitable for any gas species.

For air specifically, use the :class:`Air` subclass which provides standard
air defaults.

Example
-------

>>> fl.Air(
... dynamic_viscosity=1.063e-05 * fl.u.Pa * fl.u.s
... )

With custom NASA 9-coefficient polynomial for single species:

>>> fl.Air(
>>> fl.Gas(
... gas_constant=188.92 * fl.u.m**2 / fl.u.s**2 / fl.u.K,
... dynamic_viscosity=fl.Sutherland(
... reference_viscosity=1.47e-5 * fl.u.Pa * fl.u.s,
... reference_temperature=293.15 * fl.u.K,
... effective_temperature=240.0 * fl.u.K,
... ),
... thermally_perfect_gas=fl.ThermallyPerfectGas(
... species=[
... fl.FrozenSpecies(
... name="Air",
... name="CO2",
... nasa_9_coefficients=fl.NASA9Coefficients(
... temperature_ranges=[
... fl.NASA9CoefficientSet(
... temperature_range_min=200.0 * fl.u.K,
... temperature_range_max=1000.0 * fl.u.K,
... coefficients=[...],
... ),
... fl.NASA9CoefficientSet(
... temperature_range_min=1000.0 * fl.u.K,
... temperature_range_max=6000.0 * fl.u.K,
... coefficients=[...],
... coefficients=[0.0, 0.0, 4.46, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
... ),
... ]
... ),
... mass_fraction=1.0,
... )
... ]
... )
... )

With multi-species thermally perfect gas:

>>> fl.Air(
... thermally_perfect_gas=fl.ThermallyPerfectGas(
... species=[
... fl.FrozenSpecies(name="N2", nasa_9_coefficients=..., mass_fraction=0.7555),
... fl.FrozenSpecies(name="O2", nasa_9_coefficients=..., mass_fraction=0.2316),
... fl.FrozenSpecies(name="Ar", nasa_9_coefficients=..., mass_fraction=0.0129),
... ]
... )
... ),
... prandtl_number=0.77,
... )

====
"""

type: Literal["air"] = pd.Field("air", frozen=True)
name: str = pd.Field("air")
type: Literal["gas"] = pd.Field("gas", frozen=True)
name: str = pd.Field("gas")
gas_constant: SpecificHeatCapacityType.Positive = pd.Field(
description="Specific gas constant (R) in units of energy per mass per temperature. "
"For example, air has R = 287.0529 J/(kg*K)."
)
dynamic_viscosity: Union[Sutherland, ViscosityType.NonNegative] = pd.Field(
Sutherland(
reference_viscosity=1.716e-5 * u.Pa * u.s,
reference_temperature=273.15 * u.K,
# pylint: disable=fixme
# TODO: validation error for effective_temperature not equal 110.4 K
effective_temperature=110.4 * u.K,
),
description=(
"The dynamic viscosity model or value for air. Defaults to a `Sutherland` "
"model with standard atmospheric conditions."
),
description="The dynamic viscosity model or constant value for the gas."
)
thermally_perfect_gas: ThermallyPerfectGas = pd.Field(
default_factory=lambda: ThermallyPerfectGas(
species=[
FrozenSpecies(
name="Air",
nasa_9_coefficients=NASA9Coefficients(
temperature_ranges=[
NASA9CoefficientSet(
temperature_range_min=200.0 * u.K,
temperature_range_max=6000.0 * u.K,
# For constant gamma=1.4: cp/R = gamma/(gamma-1) = 1.4/0.4 = 3.5
# In NASA9 format, constant cp/R is the a2 coefficient (index 2)
coefficients=[0.0, 0.0, 3.5, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
),
]
),
mass_fraction=1.0,
)
]
),
description=(
"Thermally perfect gas model with NASA 9-coefficient polynomials for "
"temperature-dependent thermodynamic properties. Defaults to a single-species "
"'Air' with coefficients that reproduce constant gamma=1.4 (calorically perfect gas). "
"temperature-dependent thermodynamic properties. "
"For multi-species gas mixtures, specify multiple FrozenSpecies with their "
"respective mass fractions."
),
)
prandtl_number: pd.PositiveFloat = pd.Field(
0.72,
description="Laminar Prandtl number. Default is 0.72 for air.",
description="Laminar Prandtl number.",
)
turbulent_prandtl_number: pd.PositiveFloat = pd.Field(
0.9,
description="Turbulent Prandtl number. Default is 0.9.",
)

@contextual_model_validator(mode="after")
def _warn_if_air_gas_constant(self):
if self.type != "gas":
return self # Skip for subclasses like Air
gas_constant_value = self.gas_constant.to("m**2/s**2/K").v.item()
if abs(gas_constant_value - 287.0529) < 1e-3:
add_validation_warning(
"The gas_constant value 287.0529 J/(kg*K) corresponds to the standard gas "
"constant for air. If you are simulating a different gas, please update the "
"gas_constant accordingly."
)
return self

def get_specific_heat_ratio(self, temperature: AbsoluteTemperatureType) -> pd.PositiveFloat:
"""
Computes the specific heat ratio (gamma) at a given temperature from NASA polynomial.

For thermally perfect gas, gamma = cp/cv = (cp/R) / (cp/R - 1) varies with temperature.
The cp/R is computed from the NASA 9-coefficient polynomial:
cp/R = a0*T^-2 + a1*T^-1 + a2 + a3*T + a4*T^2 + a5*T^3 + a6*T^4
cp/R = a0*T^-2 + a1*T^-1 + a2 + a3*T + a4*T^2 + a5*T^3 + a6*T^4

Parameters
----------
Expand Down Expand Up @@ -574,32 +550,19 @@ def _get_coefficients_at_temperature(self, temp_k: float) -> list:
coeffs[i] += species.mass_fraction * species_coeffs[i]
return coeffs

@property
def gas_constant(self) -> SpecificHeatCapacityType.Positive:
"""
Returns the specific gas constant for air.

Returns
-------
SpecificHeatCapacityType.Positive
The specific gas constant for air.
"""

return 287.0529 * u.m**2 / u.s**2 / u.K

@pd.validate_call
def get_pressure(
self, density: DensityType.Positive, temperature: AbsoluteTemperatureType
) -> PressureType.Positive:
"""
Calculates the pressure of air using the ideal gas law.
Calculates the pressure using the ideal gas law.

Parameters
----------
density : DensityType.Positive
The density of the air.
The density of the gas.
temperature : AbsoluteTemperatureType
The temperature of the air.
The temperature of the gas.

Returns
-------
Expand All @@ -612,7 +575,7 @@ def get_pressure(
@pd.validate_call
def get_speed_of_sound(self, temperature: AbsoluteTemperatureType) -> VelocityType.Positive:
"""
Calculates the speed of sound in air at a given temperature.
Calculates the speed of sound at a given temperature.

For thermally perfect gas, uses the temperature-dependent gamma from the NASA polynomial.

Expand All @@ -635,7 +598,7 @@ def get_dynamic_viscosity(
self, temperature: AbsoluteTemperatureType
) -> ViscosityType.NonNegative:
"""
Calculates the dynamic viscosity of air at a given temperature.
Calculates the dynamic viscosity at a given temperature.

Parameters
----------
Expand All @@ -654,6 +617,119 @@ def get_dynamic_viscosity(
return self.dynamic_viscosity


class Air(Gas):
"""
Represents the material properties for air.
This sets specific material properties for air,
including dynamic viscosity, specific heat ratio, gas constant, and Prandtl number.

The thermodynamic properties are specified using NASA 9-coefficient polynomials
for temperature-dependent specific heats via the `thermally_perfect_gas` parameter.
By default, coefficients are set to reproduce a constant gamma=1.4 (calorically perfect gas).

Example
-------

>>> fl.Air(
... dynamic_viscosity=1.063e-05 * fl.u.Pa * fl.u.s
... )

With custom NASA 9-coefficient polynomial for single species:

>>> fl.Air(
... thermally_perfect_gas=fl.ThermallyPerfectGas(
... species=[
... fl.FrozenSpecies(
... name="Air",
... nasa_9_coefficients=fl.NASA9Coefficients(
... temperature_ranges=[
... fl.NASA9CoefficientSet(
... temperature_range_min=200.0 * fl.u.K,
... temperature_range_max=1000.0 * fl.u.K,
... coefficients=[...],
... ),
... fl.NASA9CoefficientSet(
... temperature_range_min=1000.0 * fl.u.K,
... temperature_range_max=6000.0 * fl.u.K,
... coefficients=[...],
... ),
... ]
... ),
... mass_fraction=1.0,
... )
... ]
... )
... )

With multi-species thermally perfect gas:

>>> fl.Air(
... thermally_perfect_gas=fl.ThermallyPerfectGas(
... species=[
... fl.FrozenSpecies(name="N2", nasa_9_coefficients=..., mass_fraction=0.7555),
... fl.FrozenSpecies(name="O2", nasa_9_coefficients=..., mass_fraction=0.2316),
... fl.FrozenSpecies(name="Ar", nasa_9_coefficients=..., mass_fraction=0.0129),
... ]
... )
... )

====
"""

type: Literal["air"] = pd.Field("air", frozen=True)
name: str = pd.Field("air")
gas_constant: SpecificHeatCapacityType.Positive = pd.Field(
287.0529 * u.m**2 / u.s**2 / u.K,
frozen=True,
description="Specific gas constant for air (287.0529 J/(kg*K)).",
)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Air gas_constant changed from immutable property to overridable field

Medium Severity

The Air.gas_constant was previously a read-only @property that always returned 287.0529 * u.m**2 / u.s**2 / u.K. Now it's a pydantic Field with frozen=True. While frozen=True prevents mutation after construction, it does not prevent providing a different value during construction or deserialization. This means Air(gas_constant=300 * u.m**2/u.s**2/u.K) or deserializing JSON with a corrupted gas_constant value will silently create an Air with a physically incorrect gas constant. The _warn_if_air_gas_constant validator explicitly skips Air instances via the self.type != "gas" check, so no warning fires either.

Fix in Cursor Fix in Web

dynamic_viscosity: Union[Sutherland, ViscosityType.NonNegative] = pd.Field(
Sutherland(
reference_viscosity=1.716e-5 * u.Pa * u.s,
reference_temperature=273.15 * u.K,
# pylint: disable=fixme
# TODO: validation error for effective_temperature not equal 110.4 K
effective_temperature=110.4 * u.K,
),
description=(
"The dynamic viscosity model or value for air. Defaults to a `Sutherland` "
"model with standard atmospheric conditions."
),
)
thermally_perfect_gas: ThermallyPerfectGas = pd.Field(
default_factory=lambda: ThermallyPerfectGas(
species=[
FrozenSpecies(
name="Air",
nasa_9_coefficients=NASA9Coefficients(
temperature_ranges=[
NASA9CoefficientSet(
temperature_range_min=200.0 * u.K,
temperature_range_max=6000.0 * u.K,
# For constant gamma=1.4: cp/R = gamma/(gamma-1) = 1.4/0.4 = 3.5
# In NASA9 format, constant cp/R is the a2 coefficient (index 2)
coefficients=[0.0, 0.0, 3.5, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
),
]
),
mass_fraction=1.0,
)
]
),
description=(
"Thermally perfect gas model with NASA 9-coefficient polynomials for "
"temperature-dependent thermodynamic properties. Defaults to a single-species "
"'Air' with coefficients that reproduce constant gamma=1.4 (calorically perfect gas). "
"For multi-species gas mixtures, specify multiple FrozenSpecies with their "
"respective mass fractions."
),
)
prandtl_number: pd.PositiveFloat = pd.Field(
0.72,
description="Laminar Prandtl number. Default is 0.72 for air.",
)


class SolidMaterial(MaterialBase):
"""
Represents the solid material properties for heat transfer volume.
Expand Down Expand Up @@ -719,4 +795,4 @@ class Water(MaterialBase):


SolidMaterialTypes = SolidMaterial
FluidMaterialTypes = Union[Air, Water]
FluidMaterialTypes = Union[Gas, Air, Water]
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
from flow360.component.simulation.framework.multi_constructor_model_base import (
MultiConstructorBaseModel,
)
from flow360.component.simulation.models.material import Air, Water
from flow360.component.simulation.models.material import Air, Gas, Water
from flow360.component.simulation.operating_condition.atmosphere_model import (
StandardAtmosphereModel,
)
Expand Down Expand Up @@ -77,7 +77,9 @@ class ThermalState(MultiConstructorBaseModel):
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.")
material: Union[Gas, Air] = pd.Field(
Air(), frozen=True, discriminator="type", 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
Expand Down
Loading
Loading