Source code for encomp.gases

"""
Functions related to gases: normal volume to mass conversion, compressibility, etc...

A *normal* volume (Nm³, at 0 °C and 1 atm) is a distinct dimensionality
(:class:`~encomp.utypes.NormalVolume`, dimensions ``[normal] * [length]³``), so it can
never be confused with an actual volume at process conditions. The functions here
convert between mass, actual volume and normal volume; the normal-volume side is
typed (and returned) as ``NormalVolume`` / ``NormalVolumeFlow`` with ``Nm³``-based
units.

Gas-condition literals use the values in :data:`encomp.constants.CONSTANTS`:
``"N"`` means normal conditions (0 °C, 1 atm) and ``"S"`` means standard
conditions (15 °C, 1 atm).

The functions here name their fluid parameter ``fluid_name``, whereas
:class:`encomp.fluids.Fluid` names it ``name``. That is intentional, not an
inconsistency: ``name`` is unambiguous on a fluid object, while a free function that
also takes volumes, masses and conditions has to say *whose* name it is.

.. note::
    Humid-air-specific conversions are not implemented here; use
    :class:`encomp.fluids.HumidAir` for humid-air properties.
"""

from typing import Any, Literal, cast, overload

from .constants import CONSTANTS
from .conversion import convert_volume_mass
from .fluids import Fluid
from .misc import isinstance_types
from .units import Quantity
from .utypes import (
    MT,
    Density,
    Mass,
    MassFlow,
    MolarMass,
    NormalVolume,
    NormalVolumeFlow,
    Pressure,
    Temperature,
    Volume,
    VolumeFlow,
)

__all__ = [
    "GasCondition",
    "GasConditionInput",
    "actual_volume_to_normal_volume",
    "convert_gas_volume",
    "ideal_gas_density",
    "mass_from_actual_volume",
    "mass_from_normal_volume",
    "mass_to_actual_volume",
    "mass_to_normal_volume",
    "normal_volume_to_actual_volume",
]

type GasCondition = tuple[Quantity[Pressure, Any], Quantity[Temperature, Any]]
type GasConditionInput = GasCondition | Literal["N", "S"]


def _n_s_condition(condition: Literal["N", "S"]) -> GasCondition:
    # read CONSTANTS on every call: each access returns a fresh Quantity, so a caller that
    # converts the returned condition in place cannot alter the constant
    if condition == "N":
        return CONSTANTS.normal_conditions_pressure, CONSTANTS.normal_conditions_temperature

    return CONSTANTS.standard_conditions_pressure, CONSTANTS.standard_conditions_temperature


# multiplying/dividing by this converts between the actual-volume and normal-volume
# dimensionalities without changing the magnitude (Nm³ = normal * m³)
_NORMAL = Quantity(1.0, "normal")


def _resolve_gas_condition(condition: object, name: str) -> GasCondition:
    if isinstance(condition, str):
        if condition in ("N", "S"):
            return _n_s_condition(condition)  # ty: ignore[invalid-argument-type]

        raise ValueError(f"{name} must be 'N', 'S', or a (pressure, temperature) tuple, got {condition!r}")

    if not isinstance(condition, tuple):
        raise TypeError(f"{name} must be 'N', 'S', or a (pressure, temperature) tuple, got {condition!r}")

    # cast, not an annotation: pyright strict needs the Unknown tuple elements laundered,
    # which only cast() does; ty resolves the same type on its own and calls this redundant
    condition_tuple = cast("tuple[object, ...]", condition)  # ty: ignore[redundant-cast]
    if len(condition_tuple) != 2:
        raise TypeError(f"{name} must be 'N', 'S', or a (pressure, temperature) tuple, got {condition!r}")

    P, T = condition_tuple

    if not isinstance_types(P, Quantity[Pressure, Any]) or not isinstance_types(T, Quantity[Temperature, Any]):
        raise TypeError(
            f"{name} must be 'N', 'S', or a (pressure, temperature) tuple, got ({type(P).__name__}, {type(T).__name__})"
        )

    return P, T


[docs] def ideal_gas_density( P: Quantity[Pressure, MT], T: Quantity[Temperature, MT] | Quantity[Temperature, float], M: Quantity[MolarMass, MT] | Quantity[MolarMass, float], ) -> Quantity[Density, MT]: """ Returns the density :math:`\\rho` of an ideal gas. For an ideal gas, density is calculated from the ideal gas law: .. math:: \\rho = \\frac{p M}{R T} The gas constant :math:`R` is :math:`8.31446261815324 \\; \\frac{\\text{kg} \\, \\text{m}^2}{\\text{s}^2 \\, \\text{K} \\, \\text{mol}}` (exact by the 2019 SI definition). The magnitude type of the result follows ``P``; ``T`` and ``M`` may be scalars alongside a vector ``P`` (a scalar molar mass is the common case). Parameters ---------- P : Quantity[Pressure, MT] Absolute pressure of the gas T : Quantity[Temperature, MT] | Quantity[Temperature, float] Temperature of the gas M : Quantity[MolarMass, MT] | Quantity[MolarMass, float] Molar mass of the gas Returns ------- Quantity[Density, MT] Density of the ideal gas at the specified temperature and pressure """ # directly from ideal gas law # override the inferred type here since it's sure to be Density rho = (P * M) / (CONSTANTS.R * T.to("K").unknown()) # ty: ignore[unsupported-operator] return rho.to("kg/m³").asdim(Density)
@overload def convert_gas_volume( V1: Quantity[Volume, MT], condition_1: GasConditionInput = "N", condition_2: GasConditionInput = "N", fluid_name: str = "Air", ) -> Quantity[Volume, MT]: ... @overload def convert_gas_volume( V1: Quantity[VolumeFlow, MT], condition_1: GasConditionInput = "N", condition_2: GasConditionInput = "N", fluid_name: str = "Air", ) -> Quantity[VolumeFlow, MT]: ...
[docs] def convert_gas_volume( V1: Quantity[Volume, Any] | Quantity[VolumeFlow, Any], condition_1: GasConditionInput = "N", condition_2: GasConditionInput = "N", fluid_name: str = "Air", ) -> Quantity[Volume, Any] | Quantity[VolumeFlow, Any]: """ Converts the volume :math:`V_1` (at :math:`T_1, P_1`) to :math:`V_2` (at :math:`T_2, P_2`); the dimensionality (volume or volume flow) and the units of the input are preserved. Uses compressibility factors from CoolProp. The values for :math:`P_i, T_i` are passed as a tuple ``(P, T)`` (pressure first) using the parameters ``condition_1`` and ``condition_2``. Optionally, the literals ``"N"`` and ``"S"`` can be passed to indicate normal and standard conditions. ``"N"`` is 0 °C and 1 atm (``CONSTANTS.normal_conditions_*``); ``"S"`` is 15 °C and 1 atm (``CONSTANTS.standard_conditions_*``). Both conditions default to ``"N"``, so calling this with only ``V1`` converts nothing (it still evaluates :math:`Z` twice, at the same state). Pass at least one of ``condition_1`` / ``condition_2`` for a conversion to take place. Parameters ---------- V1 : Quantity[Volume, MT] | Quantity[VolumeFlow, MT] Volume or volume flow :math:`V_1` at condition 1 condition_1 : tuple[Quantity[Pressure, Any], Quantity[Temperature, Any]] | Literal['N', 'S'], optional Pressure and temperature at condition 1, by default 'N' condition_2 : tuple[Quantity[Pressure, Any], Quantity[Temperature, Any]] | Literal['N', 'S'], optional Pressure and temperature at condition 2, by default 'N' fluid_name : str, optional CoolProp name of the fluid, by default 'Air' Returns ------- Quantity[Volume, MT] | Quantity[VolumeFlow, MT] Volume or volume flow :math:`V_2` at condition 2, in the units of ``V1`` """ if isinstance_types(V1, Quantity[NormalVolume, Any]) or isinstance_types(V1, Quantity[NormalVolumeFlow, Any]): raise TypeError( "convert_gas_volume() only accepts actual Volume or VolumeFlow inputs; " "use normal_volume_to_actual_volume() for NormalVolume or NormalVolumeFlow inputs" ) P1, T1 = _resolve_gas_condition(condition_1, "condition_1") P2, T2 = _resolve_gas_condition(condition_2, "condition_2") Z1 = Fluid(fluid_name, T=T1, P=P1).Z Z2 = Fluid(fluid_name, T=T2, P=P2).Z # from ideal gas law PV = nRT: n and R are constant # also considers compressibility factor Z V2 = V1 * (P1 / P2) * (T2.to("K") / T1.to("K")) * (Z2 / Z1) # volume at P2, T2 in same units as V1; V1 and V2 share dimensionality # but the type checker cannot correlate the two union members return V2.to(cast(Any, V1.u))
def _tag_normal( volume: Quantity[Volume, Any] | Quantity[VolumeFlow, Any], ) -> Quantity[NormalVolume, Any] | Quantity[NormalVolumeFlow, Any]: # reinterpret a plain volume (already evaluated at normal conditions) as a # normal volume: multiply by 1 [normal] (magnitude-preserving) and present in # the canonical Nm³-based unit if isinstance_types(volume, Quantity[Volume, Any]): return (volume * _NORMAL).to("Nm³").asdim(NormalVolume) return (volume * _NORMAL).to("Nm³/h").asdim(NormalVolumeFlow) def _strip_normal( volume: ( Quantity[NormalVolume, Any] | Quantity[NormalVolumeFlow, Any] | Quantity[Volume, Any] | Quantity[VolumeFlow, Any] ), ) -> Quantity[Volume, Any] | Quantity[VolumeFlow, Any]: # the inverse of _tag_normal: a normal volume becomes the equivalent plain # volume at normal conditions. A plain Volume/VolumeFlow input (the legacy # calling convention, predating the NormalVolume dimensionality) passes # through unchanged and is interpreted as a volume at normal conditions. if isinstance_types(volume, Quantity[NormalVolume, Any]): return (volume / _NORMAL).to("m³").asdim(Volume) if isinstance_types(volume, Quantity[NormalVolumeFlow, Any]): return (volume / _NORMAL).to("m³/h").asdim(VolumeFlow) return volume @overload def mass_to_normal_volume(mass: Quantity[Mass, MT], fluid_name: str = "Air") -> Quantity[NormalVolume, MT]: ... @overload def mass_to_normal_volume(mass: Quantity[MassFlow, MT], fluid_name: str = "Air") -> Quantity[NormalVolumeFlow, MT]: ...
[docs] def mass_to_normal_volume( mass: Quantity[Mass, Any] | Quantity[MassFlow, Any], fluid_name: str = "Air" ) -> Quantity[NormalVolume, Any] | Quantity[NormalVolumeFlow, Any]: """ Convert mass to normal volume. Parameters ---------- mass : Quantity[Mass, MT] | Quantity[MassFlow, MT] Input mass or mass flow fluid_name : str, optional Name of the fluid, by default 'Air' Returns ------- Quantity[NormalVolume, MT] | Quantity[NormalVolumeFlow, MT] Corresponding normal volume (Nm³) or normal volume flow (Nm³/h) """ rho = Fluid( fluid_name, P=CONSTANTS.normal_conditions_pressure, T=CONSTANTS.normal_conditions_temperature, ).D ret = convert_volume_mass(mass, rho=rho) return _tag_normal(cast("Quantity[Volume, Any] | Quantity[VolumeFlow, Any]", ret))
@overload def mass_to_actual_volume( mass: Quantity[Mass, MT], condition: GasConditionInput, fluid_name: str = "Air", ) -> Quantity[Volume, MT]: ... @overload def mass_to_actual_volume( mass: Quantity[MassFlow, MT], condition: GasConditionInput, fluid_name: str = "Air", ) -> Quantity[VolumeFlow, MT]: ...
[docs] def mass_to_actual_volume( mass: Quantity[Mass, Any] | Quantity[MassFlow, Any], condition: GasConditionInput, fluid_name: str = "Air", ) -> Quantity[Volume, Any] | Quantity[VolumeFlow, Any]: """ Convert mass to actual volume. Parameters ---------- mass : Quantity[Mass, MT] | Quantity[MassFlow, MT] Input mass or mass flow condition : tuple[Quantity[Pressure, Any], Quantity[Temperature, Any]] | Literal['N', 'S'] Condition at which to calculate the actual volume fluid_name : str, optional Name of the fluid, by default 'Air' Returns ------- Quantity[Volume, MT] | Quantity[VolumeFlow, MT] Corresponding actual volume or actual volume flow """ P, T = _resolve_gas_condition(condition, "condition") rho = Fluid(fluid_name, P=P, T=T).D ret = convert_volume_mass(mass, rho=rho) return cast("Quantity[Volume, Any] | Quantity[VolumeFlow, Any]", ret)
@overload def mass_from_normal_volume(volume: Quantity[NormalVolume, MT], fluid_name: str = "Air") -> Quantity[Mass, MT]: ... @overload def mass_from_normal_volume( volume: Quantity[NormalVolumeFlow, MT], fluid_name: str = "Air" ) -> Quantity[MassFlow, MT]: ...
[docs] def mass_from_normal_volume( volume: ( Quantity[NormalVolume, Any] | Quantity[NormalVolumeFlow, Any] | Quantity[Volume, Any] | Quantity[VolumeFlow, Any] ), fluid_name: str = "Air", ) -> Quantity[Mass, Any] | Quantity[MassFlow, Any]: """ Convert normal volume to mass. A plain ``Volume``/``VolumeFlow`` input is also accepted at runtime (the legacy calling convention) and is interpreted as a volume at normal conditions; new code should pass a ``NormalVolume``/``NormalVolumeFlow`` (Nm³-based units). Parameters ---------- volume : Quantity[NormalVolume, MT] | Quantity[NormalVolumeFlow, MT] Input normal volume or normal volume flow fluid_name : str, optional Name of the fluid, by default 'Air' Returns ------- Quantity[Mass, MT] | Quantity[MassFlow, MT] Corresponding mass or mass flow """ rho = Fluid( fluid_name, P=CONSTANTS.normal_conditions_pressure, T=CONSTANTS.normal_conditions_temperature, ).D ret = convert_volume_mass(_strip_normal(volume), rho=rho) return cast("Quantity[Mass, Any] | Quantity[MassFlow, Any]", ret)
@overload def mass_from_actual_volume( volume: Quantity[Volume, MT], condition: GasConditionInput, fluid_name: str = "Air", ) -> Quantity[Mass, MT]: ... @overload def mass_from_actual_volume( volume: Quantity[VolumeFlow, MT], condition: GasConditionInput, fluid_name: str = "Air", ) -> Quantity[MassFlow, MT]: ...
[docs] def mass_from_actual_volume( volume: Quantity[Volume, Any] | Quantity[VolumeFlow, Any], condition: GasConditionInput, fluid_name: str = "Air", ) -> Quantity[Mass, Any] | Quantity[MassFlow, Any]: """ Convert actual volume to mass. Parameters ---------- volume : Quantity[Volume, MT] | Quantity[VolumeFlow, MT] Input actual volume or actual volume flow condition : tuple[Quantity[Pressure, Any], Quantity[Temperature, Any]] | Literal['N', 'S'] Condition at which to calculate the mass fluid_name : str, optional Name of the fluid, by default 'Air' Returns ------- Quantity[Mass, MT] | Quantity[MassFlow, MT] Corresponding mass or mass flow """ P, T = _resolve_gas_condition(condition, "condition") rho = Fluid(fluid_name, P=P, T=T).D ret = convert_volume_mass(volume, rho=rho) return cast("Quantity[Mass, Any] | Quantity[MassFlow, Any]", ret)
@overload def actual_volume_to_normal_volume( volume: Quantity[Volume, Any], condition: GasConditionInput, fluid_name: str = "Air", ) -> Quantity[NormalVolume, Any]: ... @overload def actual_volume_to_normal_volume( volume: Quantity[VolumeFlow, Any], condition: GasConditionInput, fluid_name: str = "Air", ) -> Quantity[NormalVolumeFlow, Any]: ...
[docs] def actual_volume_to_normal_volume( volume: Quantity[Volume, Any] | Quantity[VolumeFlow, Any], condition: GasConditionInput, fluid_name: str = "Air", ) -> Quantity[NormalVolume, Any] | Quantity[NormalVolumeFlow, Any]: """ Convert actual volume to normal volume. Parameters ---------- volume : Quantity[Volume, Any] | Quantity[VolumeFlow, Any] Input actual volume or actual volume flow condition : tuple[Quantity[Pressure, Any], Quantity[Temperature, Any]] | Literal['N', 'S'] Condition at which the input volume is evaluated fluid_name : str, optional Name of the fluid, by default 'Air' Returns ------- Quantity[NormalVolume, Any] | Quantity[NormalVolumeFlow, Any] Corresponding normal volume (Nm³) or normal volume flow (Nm³/h) """ v_normal_conditions = convert_gas_volume(volume, condition_1=condition, condition_2="N", fluid_name=fluid_name) return _tag_normal(v_normal_conditions)
@overload def normal_volume_to_actual_volume( volume: Quantity[NormalVolume, Any], condition: GasConditionInput, fluid_name: str = "Air", ) -> Quantity[Volume, Any]: ... @overload def normal_volume_to_actual_volume( volume: Quantity[NormalVolumeFlow, Any], condition: GasConditionInput, fluid_name: str = "Air", ) -> Quantity[VolumeFlow, Any]: ...
[docs] def normal_volume_to_actual_volume( volume: ( Quantity[NormalVolume, Any] | Quantity[NormalVolumeFlow, Any] | Quantity[Volume, Any] | Quantity[VolumeFlow, Any] ), condition: GasConditionInput, fluid_name: str = "Air", ) -> Quantity[Volume, Any] | Quantity[VolumeFlow, Any]: """ Convert normal volume to actual volume. A plain ``Volume``/``VolumeFlow`` input is also accepted at runtime (the legacy calling convention) and is interpreted as a volume at normal conditions; new code should pass a ``NormalVolume``/``NormalVolumeFlow`` (Nm³-based units). Parameters ---------- volume : Quantity[NormalVolume, Any] | Quantity[NormalVolumeFlow, Any] Input normal volume or normal volume flow condition : tuple[Quantity[Pressure, Any], Quantity[Temperature, Any]] | Literal['N', 'S'] Condition at which to calculate the actual volume fluid_name : str, optional Name of the fluid, by default 'Air' Returns ------- Quantity[Volume, Any] | Quantity[VolumeFlow, Any] Corresponding actual volume or actual volume flow """ return convert_gas_volume(_strip_normal(volume), condition_1="N", condition_2=condition, fluid_name=fluid_name)