"""Gas mix value object for diveplan.
Represents an immutable O2/He/N2 breathing gas mixture. Provides partial
pressure calculations, operating limits, and best-mix selection.
"""
import re
from diveplan.core.config import DiveConfig
from diveplan.core.pressure import Pressure
__all__ = ["Gas"]
[docs]
class Gas:
"""Immutable O2/He/N2 breathing gas mixture.
Fractions are stored as floats (0.0-1.0). ``fn2`` is derived:
``fn2 = 1.0 - fo2 - fhe``. The three fractions always sum to 1.0
within a tolerance of 1e-6.
Use the named constructors (``air``, ``nitrox``, ``trimix``, …)
rather than the raw constructor wherever possible.
Args:
fo2: Oxygen fraction, e.g. ``0.21``.
fhe: Helium fraction. Defaults to ``0.0`` (no helium).
Raises:
ValueError: If any fraction is negative, or the fractions do not
sum to 1.0 within 1e-6.
Example:
>>> Gas(0.32)
Gas(fo2=0.32, fhe=0.00, fn2=0.68)
>>> Gas(0.21, fhe=0.35)
Gas(fo2=0.21, fhe=0.35, fn2=0.44)
"""
__slots__ = ("_fo2", "_fhe", "_fn2")
# For type checker only — actual storage is in __slots__ for immutability and memory efficiency.
_fo2: float
_fhe: float
_fn2: float
def __init__(self, fo2: float, fhe: float = 0.0) -> None:
fn2 = 1.0 - fo2 - fhe
if fo2 < 0:
raise ValueError(f"fo2 must be >= 0, got {fo2}")
if fhe < 0:
raise ValueError(f"fhe must be >= 0, got {fhe}")
if fn2 < 0:
raise ValueError(f"fn2 must be >= 0, got {fn2:.6f} (fo2={fo2}, fhe={fhe})")
if abs(fo2 + fhe + fn2 - 1.0) > 1e-6:
raise ValueError(
f"Gas fractions must sum to 1.0, got {fo2 + fhe + fn2:.8f}"
)
object.__setattr__(self, "_fo2", float(fo2))
object.__setattr__(self, "_fhe", float(fhe))
object.__setattr__(self, "_fn2", float(fn2))
def __setattr__(self, name: str, value: object) -> None:
raise AttributeError("Gas is immutable")
# ------------------------------------------------------------------
# Properties
# ------------------------------------------------------------------
@property
def fo2(self) -> float:
"""Oxygen fraction (0.0-1.0)."""
return self._fo2
@property
def fhe(self) -> float:
"""Helium fraction (0.0-1.0)."""
return self._fhe
@property
def fn2(self) -> float:
"""Nitrogen fraction (0.0-1.0), derived as ``1 - fo2 - fhe``."""
return self._fn2
@property
def name(self) -> str:
"""Human-readable name for this gas, e.g. "Air", "EAN32", "TX21/35"."""
return str(self)
# ------------------------------------------------------------------
# Named constructors
# ------------------------------------------------------------------
[docs]
@classmethod
def air(cls) -> Gas:
"""Return standard air (21 % O2, 79 % N2).
Returns:
A ``Gas`` representing atmospheric air.
"""
return cls(0.21, fhe=0.0)
[docs]
@classmethod
def oxygen(cls) -> Gas:
"""Return 100 % oxygen.
Returns:
A ``Gas`` with ``fo2=1.0``.
"""
return cls(1.0, fhe=0.0)
[docs]
@classmethod
def nitrox(cls, fo2: float) -> Gas:
"""Return a nitrox (O2/N2) mix with the given O2 fraction.
Args:
fo2: Oxygen fraction, e.g. ``0.32`` for EAN32.
Returns:
A ``Gas`` with no helium and nitrogen making up the remainder.
Raises:
ValueError: If ``fo2 <= 0`` or ``fo2 > 1``.
"""
if fo2 <= 0:
raise ValueError(f"nitrox fo2 must be > 0, got {fo2}")
return cls(fo2, fhe=0.0)
[docs]
@classmethod
def ean(cls, fo2: float) -> Gas:
"""Alias for :meth:`nitrox`.
Args:
fo2: Oxygen fraction, e.g. ``0.32`` for EAN32.
Returns:
A ``Gas`` with no helium.
"""
return cls.nitrox(fo2)
[docs]
@classmethod
def trimix(cls, fo2: float, fhe: float) -> Gas:
"""Return a trimix (O2/He/N2) gas.
Args:
fo2: Oxygen fraction.
fhe: Helium fraction.
Returns:
A ``Gas`` where nitrogen fills the remainder.
Raises:
ValueError: If ``fo2 <= 0``, ``fhe <= 0``, or fractions are
otherwise invalid.
"""
if fo2 <= 0:
raise ValueError(f"trimix fo2 must be > 0, got {fo2}")
if fhe <= 0:
raise ValueError(f"trimix fhe must be > 0, got {fhe}")
return cls(fo2, fhe=fhe)
# ------------------------------------------------------------------
# Name parser
# ------------------------------------------------------------------
[docs]
@classmethod
def from_name(cls, name: str) -> Gas:
"""Parse a gas mix from a human-readable name string.
Supported formats (case-insensitive):
* ``"air"``
* ``"oxygen"``
* ``"nx32"``, ``"ean32"``, ``"nitrox 32"`` — O2 % as integer
* ``"tx21/35"``, ``"trimix 21/35"`` — O2%/He%
Args:
name: Human-readable gas name.
Returns:
The corresponding ``Gas`` instance.
Raises:
ValueError: If the name cannot be parsed.
Example:
>>> Gas.from_name("ean32")
Gas(fo2=0.32, fhe=0.00, fn2=0.68)
>>> Gas.from_name("tx21/35")
Gas(fo2=0.21, fhe=0.35, fn2=0.44)
"""
s = name.strip().lower()
if s == "air":
return cls.air()
if s == "oxygen":
return cls.oxygen()
# nitrox: nx32, ean32, nitrox 32, nitrox32
m = re.fullmatch(r"(?:nx|ean|nitrox)\s*(\d+(?:\.\d+)?)", s)
if m:
return cls.nitrox(float(m.group(1)) / 100)
# trimix: tx21/35, trimix21/35, trimix 21/35
m = re.fullmatch(r"(?:tx|trimix)\s*(\d+(?:\.\d+)?)[/\s](\d+(?:\.\d+)?)", s)
if m:
return cls.trimix(
float(m.group(1)) / 100,
float(m.group(2)) / 100,
)
raise ValueError(f"Cannot parse gas name: {name!r}")
# ------------------------------------------------------------------
# Partial pressures
# ------------------------------------------------------------------
[docs]
def ppo2(self, pressure: Pressure) -> Pressure:
"""Return the partial pressure of O2 at the given ambient pressure.
Args:
pressure: Ambient pressure.
Returns:
Partial pressure of oxygen.
"""
return pressure * self._fo2
[docs]
def pphe(self, pressure: Pressure) -> Pressure:
"""Return the partial pressure of He at the given ambient pressure.
Args:
pressure: Ambient pressure.
Returns:
Partial pressure of helium.
"""
return pressure * self._fhe
[docs]
def ppn2(self, pressure: Pressure) -> Pressure:
"""Return the partial pressure of N2 at the given ambient pressure.
Args:
pressure: Ambient pressure.
Returns:
Partial pressure of nitrogen.
"""
return pressure * self._fn2
# ------------------------------------------------------------------
# Limits
# ------------------------------------------------------------------
[docs]
def mod(self, *, ppo2_bar: float) -> Pressure:
"""Return the maximum operating depth (MOD) for a given ppO2 limit.
Args:
ppo2_bar: ppO2 ceiling in bar, e.g. ``1.4``.
Returns:
The MOD as a :class:`Pressure`.
Raises:
ValueError: If ``ppo2_bar <= 0``.
Example:
>>> from diveplan.core.pressure import Pressure
>>> Gas.nitrox(0.32).mod(ppo2_bar=1.4).bar
4.375
"""
if ppo2_bar <= 0:
raise ValueError(f"ppo2_bar must be > 0, got {ppo2_bar}")
return Pressure.from_bar(ppo2_bar / self._fo2)
[docs]
def end(self, pressure: Pressure) -> Pressure:
"""Return the equivalent narcotic depth (END) at the given pressure.
Helium is assumed non-narcotic; the narcotic fraction is
``fo2 + fn2``.
Args:
pressure: Ambient pressure at depth.
Returns:
The END as a :class:`Pressure`.
"""
narcotic_fraction = self._fo2 + self._fn2
return pressure * narcotic_fraction
[docs]
def is_breathable(self, pressure: Pressure) -> bool:
"""Whether this gas is breathable at the given ambient pressure.
Breathable means the ppO2 sits within the configured
``[min_ppo2_bar, deco_ppo2_bar]`` window (from
:func:`DiveConfig.current`). The *deco* limit is used because
this is the gas-switching question — switches happen during the
ascent, where the deco ppO2 applies; compare against
``mod(ppo2_bar=...)`` for an explicit working limit.
Args:
pressure: Ambient pressure at depth.
"""
limits = DiveConfig.current().gas
ppo2 = self.ppo2(pressure).bar
return limits.min_ppo2_bar <= ppo2 <= limits.deco_ppo2_bar
[docs]
def best_mix(
self,
depth: float | Pressure,
*,
trimix: bool = False,
hypoxic: bool = False,
) -> Gas:
"""Return the optimal gas mix for the given depth.
Maximises ``fo2`` within the ppO2 and END/ppN2 constraints
taken from :func:`diveplan.core.config.DiveConfig.current`.
``hypoxic=True`` implies ``trimix=True``.
Args:
depth: Target depth as metres (``float``) or a
:class:`~diveplan.core.pressure.Pressure`.
trimix: Allow helium in the mix.
hypoxic: Allow fo2 below ``min_ppo2_bar`` at surface
(implies ``trimix=True``).
Returns:
The best ``Gas`` for the given depth and constraints.
Raises:
ValueError: If the constraints cannot be satisfied (e.g.
``best_mix(120, trimix=False)``).
Example:
>>> Gas.air().best_mix(30)
Gas(fo2=0.32, fhe=0.00, fn2=0.68)
"""
if hypoxic:
trimix = True
if not isinstance(depth, Pressure):
pressure = Pressure.from_depth_m(float(depth))
else:
pressure = depth
cfg = DiveConfig.current().gas
max_ppo2 = cfg.deco_ppo2_bar if hypoxic else cfg.max_ppo2_bar
min_ppo2 = cfg.min_ppo2_bar
max_end_mbar = Pressure.from_depth_m(cfg.max_end_m).mbar
max_ppn2_bar = cfg.max_ppn2_bar
# Best fo2: highest fraction that keeps ppO2 <= max_ppo2
best_fo2 = min(1.0, max_ppo2 / pressure.bar)
if not hypoxic:
min_fo2 = min_ppo2 / pressure.bar
if min_fo2 > 1.0:
raise ValueError(
f"Cannot satisfy min ppO2 {min_ppo2} bar at {pressure.depth_m:.1f} m with any nitrox mix"
)
best_fo2 = max(best_fo2, min_fo2)
if not trimix:
# Pure nitrox — check END (narcotic fraction = 1 since fhe = 0)
fn2 = 1.0 - best_fo2
ppn2 = pressure.bar * fn2
if ppn2 > max_ppn2_bar or pressure.mbar > max_end_mbar:
raise ValueError(
f"Cannot satisfy END/ppN2 constraints at {pressure.depth_m:.1f} m without helium — use trimix=True"
)
return Gas(best_fo2, fhe=0.0)
# Trimix: find minimum helium to satisfy END and ppN2 limits.
# narcotic_fraction = fo2 + fn2 = 1 - fhe
# END constraint: pressure_mbar * (1 - fhe) <= max_end_mbar
# ppN2 constraint: pressure_bar * (1 - fo2 - fhe) <= max_ppn2_bar
end_limit_fhe = max(0.0, 1.0 - max_end_mbar / pressure.mbar)
ppn2_limit_fhe = max(0.0, 1.0 - best_fo2 - max_ppn2_bar / pressure.bar)
min_fhe = max(end_limit_fhe, ppn2_limit_fhe)
fn2 = 1.0 - best_fo2 - min_fhe
if fn2 < -1e-9:
raise ValueError(
f"Cannot satisfy constraints at {pressure.depth_m:.1f} m: fo2={best_fo2:.3f} + fhe={min_fhe:.3f} > 1.0"
)
return Gas(best_fo2, fhe=max(0.0, min_fhe))
# ------------------------------------------------------------------
# Dunder
# ------------------------------------------------------------------
def __eq__(self, other: object) -> bool:
if not isinstance(other, Gas):
return NotImplemented
return abs(self._fo2 - other._fo2) < 1e-9 and abs(self._fhe - other._fhe) < 1e-9
def __hash__(self) -> int:
return hash((round(self._fo2, 9), round(self._fhe, 9)))
def __repr__(self) -> str:
return f"Gas(fo2={self._fo2:.2f}, fhe={self._fhe:.2f}, fn2={self._fn2:.2f})"
def __str__(self) -> str:
o2_pct = round(self._fo2 * 100)
he_pct = round(self._fhe * 100)
if he_pct == 0:
if o2_pct == 21:
return "Air"
if o2_pct == 100:
return "O2"
return f"EAN{o2_pct}"
return f"TX{o2_pct}/{he_pct}"