Source code for solprop.physdata.transport

# SPDX-FileCopyrightText: 2026 John C. Travers and contributors
#
# SPDX-License-Identifier: GPL-3.0-or-later

r"""Transport properties of the fill gases: viscosity, sound speed, mean free path.

The steady-flow fill of a hollow fibre (:mod:`solprop.gasflow`) needs three gas
properties beyond the equation of state: the dynamic viscosity, which sets the
Poiseuille conductance; the speed of sound, which bounds the throughput of an end
that exhausts into vacuum; and the mean free path, which says whether the gas is a
continuum at all. All three are **dilute-gas** values here, which is what the laminar
flow law and the choke use.

The material properties are optmat's (SEP 3), through the
:class:`~solprop.physdata.species.OptmatGas` a built-in gas or species carries: the
dilute viscosity, the ideal-gas heat capacity and the molar mass of
``optmat.gas.transport``, each over optmat's own temperature range; the real-gas
sound speed of its equation of state; and, for a mixture, optmat's Wilke rule
(``mixture_viscosity``; C. R. Wilke, J. Chem. Phys. **18**, 517 (1950),
https://doi.org/10.1063/1.1747673, about 1 to 2 % accurate). solprop keeps:

* the **dilute sound speed** :math:`c = \sqrt{\gamma R T / M}` with the dilute
  heat-capacity ratio :math:`\gamma = c_p / c_v`, :math:`c_v = c_p - R`, and for a
  mixture the mole-weighted molar mass and
  :math:`\gamma = \sum x_i c_{p,i} / \sum x_i c_{v,i}` (ideal-gas mixing of heat
  capacities);
* the **mean free path**, the viscosity-based kinetic-theory form
  :math:`\lambda = (\eta / p)\sqrt{\pi k_B T / (2 m)}`, which needs no collision
  diameter. Other conventions exist; the Knudsen thresholds that use it assume this
  form and the core diameter.

A temperature outside a gas's range is refused, as is a custom
:class:`~solprop.physdata.species.Species` that does not carry optmat's equation of
state, which carries none of these data.

Pressures are in **bar** at this interface (``docs/dev/conventions.md`` section 1).
"""

import math
from collections.abc import Mapping
from typing import Final

from solprop.constants import BAR, K_B, N_A
from solprop.exceptions import MaterialError
from solprop.physdata.gases import Gas, as_gas
from solprop.physdata.species import OptmatGas, Species

__all__ = [
    "heat_capacity_ratio",
    "mean_free_path",
    "molar_mass",
    "sound_speed",
    "temperature_range",
    "viscosity",
]

# A single gas: a built-in gas's name, a Gas, or a Species carrying optmat's data.
GasLike = Gas | str | Species

# A single gas, or a mapping of gases to mole fractions summing to one. The mapping is
# spelled out per key type because a mapping is invariant in its key: a dict of names,
# of members or of species is each accepted as it is written. Neither alias is in
# __all__: they are annotations, documented where they are used.
Composition = (
    GasLike
    | Mapping[Gas, float]
    | Mapping[str, float]
    | Mapping[Species, float]
    | Mapping[GasLike, float]
)

_GAS_CONSTANT: Final = K_B * N_A


[docs] def temperature_range(gas: GasLike) -> tuple[float, float]: """Return the temperatures over which a gas's transport properties are valid. Parameters ---------- gas : Gas, str or Species A built-in gas, or a species carrying optmat's equation of state. Returns ------- tuple of float The lowest and highest temperature in K, optmat's. Raises ------ MaterialError If the gas is unknown or a custom species. Examples -------- >>> from solprop.physdata.transport import temperature_range >>> temperature_range("N2O") (182.33, 525.0) """ return _member(gas, "transport").temperature_range
def _member(gas: GasLike, quantity: str) -> OptmatGas: """Return the data source behind a declaration, or refuse a custom species. Parameters ---------- gas : Gas, str or Species The gas. quantity : str The property asked for, named in the error. Returns ------- OptmatGas The optmat equation of state and transport of a name or a species. Raises ------ MaterialError If the gas is unknown, or a custom species without optmat's data. """ if isinstance(gas, Species): state = gas.equation_of_state if isinstance(state, OptmatGas): return state raise MaterialError( f"custom species {gas.name!r} carries no {quantity} data: the gas-flow " "calculation (viscosity, sound speed, mean free path) needs a built-in " "gas (solprop.physdata.Gas), whose properties are optmat's" ) # A name is the built-in gas, as for species(name): a flow built from a name and a # fill built from it agree. return OptmatGas(as_gas(gas)) def _composition( gas: Composition, quantity: str ) -> tuple[tuple[OptmatGas, float], ...]: """Resolve a gas or a mole-fraction mapping to its data sources and fractions. Parameters ---------- gas : Gas, str, Species or Mapping One gas, or gases to mole fractions summing to one. quantity : str The property asked for, named in errors. Returns ------- tuple ``(member, fraction)`` pairs with positive fractions, one per distinct gas. Raises ------ MaterialError If a fraction is negative or not finite, the fractions do not sum to one, or a gas carries no optmat data. """ if not isinstance(gas, Mapping): return ((_member(gas, quantity), 1.0),) merged: dict[OptmatGas, float] = {} for key, fraction in gas.items(): value = float(fraction) if not math.isfinite(value) or value < 0.0: raise MaterialError("mole fractions must be finite and not negative") member = _member(key, quantity) merged[member] = merged.get(member, 0.0) + value total = math.fsum(merged.values()) # A distribution normalised in floating point is one to a few ulps per entry. if not merged or abs(total - 1.0) > 1e-12: raise MaterialError(f"mole fractions must sum to one, got {total:.17g}") return tuple((member, value) for member, value in merged.items() if value > 0.0) def _viscosity(parts: tuple[tuple[OptmatGas, float], ...], temperature: float) -> float: """Return a composition's dilute viscosity. Parameters ---------- parts : tuple ``(OptmatGas, fraction)`` pairs from :func:`_composition`. temperature : float Temperature in K. Returns ------- float Dynamic viscosity in Pa s: the gas's own, or optmat's Wilke rule over the constituents (air's resolved into its own). """ from optmat.gas.transport import mixture_viscosity # noqa: PLC0415 - optmat is lazy members = [member for member, _ in parts] values = [member.viscosity(temperature) for member in members] if len(parts) == 1: return values[0] fractions: dict[str, float] = {} for member, (_, share) in zip(members, parts, strict=True): for name, fraction in member.constituents: fractions[name] = fractions.get(name, 0.0) + share * fraction return float(mixture_viscosity(fractions, temperature))
[docs] def molar_mass(gas: Composition) -> float: """Return the molar mass of a gas, or the mole-weighted mean of a mixture. optmat's molar mass of each gas; for ``Gas.Air`` that of the pseudo-pure dry air its equation of state describes. Parameters ---------- gas : Gas, str, Species or Mapping One built-in gas, or gases to mole fractions summing to one. Returns ------- float Molar mass in kg mol^-1. Raises ------ MaterialError If a gas is unknown or a custom species, or the fractions are invalid. Examples -------- >>> from solprop.physdata.transport import molar_mass >>> f"{molar_mass('N2'):.6f}" '0.028013' >>> f"{molar_mass({'He': 0.5, 'Ar': 0.5}):.6f}" '0.021975' """ return math.fsum( fraction * member.molar_mass for member, fraction in _composition(gas, "molar-mass") )
[docs] def viscosity(gas: Composition, temperature: float) -> float: """Return the dilute dynamic viscosity of a gas or a mixture. Parameters ---------- gas : Gas, str, Species or Mapping One built-in gas, or gases to mole fractions summing to one, which take Wilke's mixing rule. temperature : float Temperature in K, inside :func:`temperature_range` of every gas. Returns ------- float Dynamic viscosity in Pa s. Raises ------ MaterialError If a gas is unknown or a custom species, the fractions are invalid, or the temperature is outside a gas's range. Examples -------- >>> from solprop.physdata.transport import viscosity >>> f"{viscosity('N2', 293.15):.4e}" '1.7560e-05' >>> f"{viscosity('Xe', 300.0):.4e}" '2.3146e-05' """ return _viscosity(_composition(gas, "viscosity"), temperature)
[docs] def heat_capacity_ratio(gas: Composition, temperature: float) -> float: """Return the dilute heat-capacity ratio of a gas or a mixture. Parameters ---------- gas : Gas, str, Species or Mapping One built-in gas, or gases to mole fractions summing to one. temperature : float Temperature in K, inside :func:`temperature_range` of every gas. Returns ------- float ``gamma = sum(x c_p) / sum(x (c_p - R))``, dimensionless. Raises ------ MaterialError If a gas is unknown or a custom species, the fractions are invalid, or the temperature is outside a gas's range. Examples -------- >>> from solprop.physdata.transport import heat_capacity_ratio >>> f"{heat_capacity_ratio('Ar', 293.15):.4f}" '1.6667' >>> f"{heat_capacity_ratio('N2', 293.15):.3f}" '1.400' """ parts = _composition(gas, "heat-capacity") isobaric = math.fsum( fraction * member.molar_heat_capacity(temperature) for member, fraction in parts ) return isobaric / (isobaric - _GAS_CONSTANT)
[docs] def sound_speed( gas: Composition, temperature: float, pressure_bar: float | None = None ) -> float: """Return the adiabatic speed of sound in a gas or a mixture. Parameters ---------- gas : Gas, str, Species or Mapping One built-in gas, or gases to mole fractions summing to one. temperature : float Temperature in K, inside :func:`temperature_range` of every gas. pressure_bar : float or None, optional ``None`` (the default) gives the dilute value ``sqrt(gamma R T / M)`` of :func:`heat_capacity_ratio` and :func:`molar_mass`. A pressure in **bar**, > 0, gives the real-gas value from the equation of state, for a pure gas only. Returns ------- float Speed of sound in m s^-1. Raises ------ MaterialError If a gas is unknown or a custom species, the fractions are invalid, the temperature is outside a gas's range, the pressure is not positive, or a pressure is given for a mixture. Examples -------- >>> from solprop.physdata.transport import sound_speed >>> f"{sound_speed('N2', 293.15):.1f}" '349.0' >>> f"{sound_speed('N2', 293.15, 1.0):.1f}" '349.1' """ if pressure_bar is None: ratio = heat_capacity_ratio(gas, temperature) return math.sqrt(ratio * _GAS_CONSTANT * temperature / molar_mass(gas)) parts = _composition(gas, "sound-speed") if len(parts) != 1: raise MaterialError( "a real-gas sound speed at a stated pressure is available for a pure gas " "only; pass pressure_bar=None for a mixture's dilute value" ) if not (math.isfinite(pressure_bar) and pressure_bar > 0.0): raise MaterialError(f"pressure must be positive, got {pressure_bar} bar") return parts[0][0].sound_speed(pressure_bar, temperature)
[docs] def mean_free_path(gas: Composition, pressure_bar: float, temperature: float) -> float: r"""Return the mean free path of a gas from its viscosity. Uses :math:`\lambda = (\eta / p)\sqrt{\pi k_B T / (2 m)}` with the molecular mass :math:`m = M / N_A` (for a mixture, the mole-weighted mean) and the viscosity of :func:`viscosity`: the hard-sphere kinetic-theory form written so that it needs no collision diameter. Parameters ---------- gas : Gas, str, Species or Mapping One built-in gas, or gases to mole fractions summing to one. pressure_bar : float Pressure in **bar**, > 0. temperature : float Temperature in K, inside :func:`temperature_range` of every gas. Returns ------- float Mean free path in m. Raises ------ MaterialError If the pressure is not positive, or as :func:`viscosity`. Examples -------- >>> from solprop.physdata.transport import mean_free_path >>> f"{mean_free_path('N2', 1.0, 293.15) * 1e9:.1f}" '64.9' """ if not (math.isfinite(pressure_bar) and pressure_bar > 0.0): raise MaterialError(f"pressure must be positive, got {pressure_bar} bar") speed = math.sqrt(math.pi * _GAS_CONSTANT * temperature / (2.0 * molar_mass(gas))) return viscosity(gas, temperature) / (pressure_bar * BAR) * speed