zhl16 0.1.0

A no_std Rust implementation of core Bühlmann ZHL-16 tissue loading primitives.
Documentation
use zhl16::{
    BodyModelError, BodyState, Gas, GasMixture, GasMixtureError, Pressure, PressureUnit,
    QuantityError, Time, TimeUnit,
};

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}"
    );
}

fn assert_bar(actual: Pressure, expected: f64) {
    assert_close(actual.to(PressureUnit::Bar), expected, 1E-12);
}

fn gas_mixture(oxygen_fraction: f64, helium_fraction: f64) -> GasMixture {
    GasMixture::new(oxygen_fraction, helium_fraction).expect("valid gas mixture")
}

fn pressure(value: f64, unit: PressureUnit) -> Pressure {
    Pressure::new(value, unit).expect("valid pressure")
}

fn time(value: f64, unit: TimeUnit) -> Time {
    Time::new(value, unit).expect("valid time")
}

#[test]
fn accepts_trimix_as_oxygen_and_helium_and_derives_inert_fractions() {
    let trimix_10_20 = gas_mixture(0.10, 0.20);

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

#[test]
fn accepts_gas_operating_pressure_calculations() {
    let nitrox_32 = gas_mixture(0.32, 0.0);
    let trimix_18_45 = gas_mixture(0.18, 0.45);

    assert_bar(
        nitrox_32
            .max_operational_pressure(pressure(1.4, PressureUnit::Bar))
            .expect("positive PPO2"),
        4.375,
    );
    assert_bar(
        trimix_18_45.equivalent_narcotic_pressure(pressure(6.0, PressureUnit::Bar)),
        3.3,
    );
}

#[test]
fn rejects_gas_mixtures_with_typed_errors() {
    assert_eq!(
        GasMixture::new(0.80, 0.30),
        Err(GasMixtureError::FractionSumExceedsOne)
    );
    assert_eq!(
        GasMixture::new(-0.01, 0.0),
        Err(GasMixtureError::NegativeOxygenFraction)
    );
    assert_eq!(
        GasMixture::new(0.0, 1.0),
        Err(GasMixtureError::ZeroOxygenFraction)
    );
    assert_eq!(
        gas_mixture(0.32, 0.0).max_operational_pressure(pressure(0.0, PressureUnit::Bar)),
        Err(GasMixtureError::NonPositiveCriticalPartialPressureOfOxygen)
    );
}

#[test]
fn accepts_a_one_minute_trimix_exposure_and_updates_observable_tissues() {
    let mut body = BodyState::new(pressure(0.79, PressureUnit::Bar), Pressure::zero());
    let trimix_10_20 = gas_mixture(0.10, 0.20);

    body.evolve(
        pressure(2.0, PressureUnit::Bar),
        &trimix_10_20,
        time(60.0, TimeUnit::Seconds),
    )
    .expect("positive time step");

    let compartments = body.compartments();
    assert_eq!(compartments.len(), 16);

    assert_bar(compartments[0].get_nitrogen_pressure(), 0.868479510077358);
    assert_bar(compartments[0].get_helium_pressure(), 0.142854684350934);
    assert_bar(compartments[15].get_nitrogen_pressure(), 0.790617948898247);
    assert_bar(compartments[15].get_helium_pressure(), 0.001118888499687);

    assert!(
        compartments[0].get_nitrogen_pressure() > compartments[15].get_nitrogen_pressure(),
        "fastest compartment should take up more nitrogen than slowest compartment"
    );
    assert!(
        compartments[0].get_helium_pressure() > compartments[15].get_helium_pressure(),
        "fastest compartment should take up more helium than slowest compartment"
    );
}

#[test]
fn accepts_gradient_factor_queries_after_an_exposure() {
    let mut body = BodyState::new(pressure(0.79, PressureUnit::Bar), Pressure::zero());

    body.evolve(
        pressure(2.0, PressureUnit::Bar),
        &gas_mixture(0.10, 0.20),
        time(60.0, TimeUnit::Seconds),
    )
    .expect("positive time step");

    let compartments = body.compartments();

    assert_bar(
        compartments[0]
            .safe_ambient_pressure(0.85)
            .expect("valid gradient factor"),
        -0.021558210308153,
    );
    assert_bar(
        compartments[15]
            .safe_ambient_pressure(0.85)
            .expect("valid gradient factor"),
        0.575978611468123,
    );
    assert_bar(
        compartments[15]
            .safe_ambient_pressure(1.0)
            .expect("valid gradient factor"),
        0.539226909247077,
    );
    assert_bar(
        compartments[15]
            .safe_ambient_pressure(0.0)
            .expect("valid gradient factor"),
        0.791736837397934,
    );
}

#[test]
fn rejects_invalid_model_inputs_with_typed_errors() {
    let mut body = BodyState::new(pressure(0.79, PressureUnit::Bar), Pressure::zero());

    assert_eq!(
        body.evolve(
            pressure(2.0, PressureUnit::Bar),
            &gas_mixture(0.10, 0.20),
            Time::zero()
        ),
        Err(BodyModelError::NonPositiveTimeStep)
    );

    assert_eq!(
        body.compartments()[0].safe_ambient_pressure(-0.01),
        Err(BodyModelError::InvalidGradientFactor)
    );
    assert_eq!(
        body.compartments()[0].safe_ambient_pressure(1.01),
        Err(BodyModelError::InvalidGradientFactor)
    );
}

#[test]
fn accepts_public_quantity_conversions_needed_by_callers() {
    assert_close(
        time(1.0, TimeUnit::Hours).to(TimeUnit::Seconds),
        3600.0,
        1E-12,
    );
    assert_close(
        pressure(1.0, PressureUnit::Atmosphere).to(PressureUnit::Bar),
        1.01325,
        1E-12,
    );
    assert_close(
        pressure(2.0, PressureUnit::Bar).to(PressureUnit::Pascal),
        200_000.0,
        1E-12,
    );
}

#[test]
fn rejects_nan_quantities_with_typed_errors() {
    assert_eq!(
        Pressure::new(f64::NAN, PressureUnit::Bar),
        Err(QuantityError::NanValue)
    );
    assert_eq!(
        Time::new(f64::NAN, TimeUnit::Seconds),
        Err(QuantityError::NanValue)
    );
}