zhl16 0.1.0

A no_std Rust implementation of core Bühlmann ZHL-16 tissue loading primitives.
Documentation
use crate::{Pressure, PressureUnit};
use core::fmt;

#[derive(Copy, Clone)]
pub enum Gas {
    Nitrogen = 0,
    Helium = 1,
}

impl Gas {
    pub const ALL: [Gas; 2] = [Gas::Nitrogen, Gas::Helium];
}

#[derive(Copy, Clone, Debug, PartialEq)]
pub struct GasMixture {
    oxygen_fraction: f64,
    helium_fraction: f64,
}

#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum GasMixtureError {
    NonFiniteOxygenFraction,
    NonFiniteHeliumFraction,
    ZeroOxygenFraction,
    NegativeOxygenFraction,
    NegativeHeliumFraction,
    FractionSumExceedsOne,
    NonPositiveCriticalPartialPressureOfOxygen,
}

impl fmt::Display for GasMixtureError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            GasMixtureError::NonFiniteOxygenFraction => {
                f.write_str("oxygen fraction must be finite")
            }
            GasMixtureError::NonFiniteHeliumFraction => {
                f.write_str("helium fraction must be finite")
            }
            GasMixtureError::NegativeOxygenFraction => {
                f.write_str("oxygen fraction must be non-negative")
            }
            GasMixtureError::ZeroOxygenFraction => {
                f.write_str("oxygen fraction must be greater than zero")
            }
            GasMixtureError::NegativeHeliumFraction => {
                f.write_str("helium fraction must be non-negative")
            }
            GasMixtureError::FractionSumExceedsOne => {
                f.write_str("oxygen and helium fractions must not sum above 1.0")
            }
            GasMixtureError::NonPositiveCriticalPartialPressureOfOxygen => {
                f.write_str("critical partial pressure of oxygen must be positive")
            }
        }
    }
}

impl GasMixture {
    pub fn new(oxygen_fraction: f64, helium_fraction: f64) -> Result<Self, GasMixtureError> {
        if !oxygen_fraction.is_finite() {
            return Err(GasMixtureError::NonFiniteOxygenFraction);
        }

        if !helium_fraction.is_finite() {
            return Err(GasMixtureError::NonFiniteHeliumFraction);
        }

        if oxygen_fraction < 0.0 {
            return Err(GasMixtureError::NegativeOxygenFraction);
        }

        if oxygen_fraction == 0.0 {
            return Err(GasMixtureError::ZeroOxygenFraction);
        }

        if helium_fraction < 0.0 {
            return Err(GasMixtureError::NegativeHeliumFraction);
        }

        if oxygen_fraction + helium_fraction > 1.0 {
            return Err(GasMixtureError::FractionSumExceedsOne);
        }

        Ok(Self {
            oxygen_fraction,
            helium_fraction,
        })
    }

    pub const fn nitrogen_fraction(&self) -> f64 {
        1. - self.oxygen_fraction - self.helium_fraction
    }

    pub const fn helium_fraction(&self) -> f64 {
        self.helium_fraction
    }

    pub const fn oxygen_fraction(&self) -> f64 {
        self.oxygen_fraction
    }

    pub const fn inert_fraction(&self, gas: Gas) -> f64 {
        match gas {
            Gas::Nitrogen => self.nitrogen_fraction(),
            Gas::Helium => self.helium_fraction(),
        }
    }

    pub fn max_operational_pressure(
        &self,
        critical_partial_pressure_of_oxygen: Pressure,
    ) -> Result<Pressure, GasMixtureError> {
        if !(critical_partial_pressure_of_oxygen.to(PressureUnit::Pascal) > 0.0) {
            return Err(GasMixtureError::NonPositiveCriticalPartialPressureOfOxygen);
        }

        Ok(critical_partial_pressure_of_oxygen / self.oxygen_fraction)
    }

    pub fn equivalent_narcotic_pressure(&self, ambient_pressure: Pressure) -> Pressure {
        ambient_pressure * (self.oxygen_fraction() + self.nitrogen_fraction())
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn assert_close(actual: f64, expected: f64, epsilon: f64) {
        let diff = (actual - expected).abs();

        assert!(
            diff <= epsilon,
            "expected {actual} to be within {epsilon} of {expected}"
        );
    }

    #[test]
    fn gas_mixture_derives_nitrogen_fraction() {
        let mixture = GasMixture::new(0.10, 0.20).expect("valid gas mixture");

        assert_close(mixture.oxygen_fraction(), 0.10, 1E-12);
        assert_close(mixture.helium_fraction(), 0.20, 1E-12);
        assert_close(mixture.nitrogen_fraction(), 0.70, 1E-12);
        assert_close(mixture.inert_fraction(Gas::Nitrogen), 0.70, 1E-12);
        assert_close(mixture.inert_fraction(Gas::Helium), 0.20, 1E-12);
    }

    #[test]
    fn max_operational_pressure_scales_oxygen_limit_by_oxygen_fraction() {
        let nitrox_32 = GasMixture::new(0.32, 0.0).expect("valid gas mixture");
        let mod_pressure = nitrox_32
            .max_operational_pressure(Pressure::new_unchecked(1.4, crate::PressureUnit::Bar))
            .expect("positive PPO2");

        assert_close(mod_pressure.to(crate::PressureUnit::Bar), 4.375, 1E-12);
    }

    #[test]
    fn equivalent_narcotic_pressure_preserves_air_pressure() {
        let air = GasMixture::new(0.21, 0.0).expect("valid gas mixture");
        let equivalent = air
            .equivalent_narcotic_pressure(Pressure::new_unchecked(3.0, crate::PressureUnit::Bar));

        assert_close(equivalent.to(crate::PressureUnit::Bar), 3.0, 1E-12);
    }

    #[test]
    fn equivalent_narcotic_pressure_ignores_helium_fraction() {
        let trimix_18_45 = GasMixture::new(0.18, 0.45).expect("valid gas mixture");
        let equivalent = trimix_18_45
            .equivalent_narcotic_pressure(Pressure::new_unchecked(6.0, crate::PressureUnit::Bar));

        assert_close(equivalent.to(crate::PressureUnit::Bar), 3.3, 1E-12);
    }

    #[test]
    fn gas_mixture_reports_invalid_inputs_as_errors() {
        assert_eq!(
            GasMixture::new(f64::NAN, 0.0),
            Err(GasMixtureError::NonFiniteOxygenFraction)
        );
        assert_eq!(
            GasMixture::new(0.21, f64::INFINITY),
            Err(GasMixtureError::NonFiniteHeliumFraction)
        );
        assert_eq!(
            GasMixture::new(-0.01, 0.0),
            Err(GasMixtureError::NegativeOxygenFraction)
        );
        assert_eq!(
            GasMixture::new(0.0, 1.0),
            Err(GasMixtureError::ZeroOxygenFraction)
        );
        assert_eq!(
            GasMixture::new(0.21, -0.01),
            Err(GasMixtureError::NegativeHeliumFraction)
        );
        assert_eq!(
            GasMixture::new(0.80, 0.30),
            Err(GasMixtureError::FractionSumExceedsOne)
        );
    }

    #[test]
    fn max_operational_pressure_reports_non_positive_ppo2() {
        let nitrox_32 = GasMixture::new(0.32, 0.0).expect("valid gas mixture");

        assert_eq!(
            nitrox_32.max_operational_pressure(Pressure::zero()),
            Err(GasMixtureError::NonPositiveCriticalPartialPressureOfOxygen)
        );
        assert_eq!(
            nitrox_32
                .max_operational_pressure(Pressure::new_unchecked(-1.0, crate::PressureUnit::Bar)),
            Err(GasMixtureError::NonPositiveCriticalPartialPressureOfOxygen)
        );
        assert_eq!(
            nitrox_32.max_operational_pressure(Pressure::new_unchecked(
                f64::NAN,
                crate::PressureUnit::Bar
            )),
            Err(GasMixtureError::NonPositiveCriticalPartialPressureOfOxygen)
        );
    }
}