diff --git a/domain/src/commonMain/kotlin/org/neotech/app/abysner/domain/core/model/BreathingMode.kt b/domain/src/commonMain/kotlin/org/neotech/app/abysner/domain/core/model/BreathingMode.kt index 41f42c7..959d7a4 100644 --- a/domain/src/commonMain/kotlin/org/neotech/app/abysner/domain/core/model/BreathingMode.kt +++ b/domain/src/commonMain/kotlin/org/neotech/app/abysner/domain/core/model/BreathingMode.kt @@ -24,5 +24,12 @@ sealed class BreathingMode { val ccrSetpointOrNull: Double? get() = (this as? ClosedCircuit)?.setpoint + + companion object { + + fun ccr(setpoint: Double) = ClosedCircuit(setpoint) + + fun oc() = BreathingMode.OpenCircuit + } } diff --git a/domain/src/commonMain/kotlin/org/neotech/app/abysner/domain/core/model/Configuration.kt b/domain/src/commonMain/kotlin/org/neotech/app/abysner/domain/core/model/Configuration.kt index b94ec7f..cfd0e82 100644 --- a/domain/src/commonMain/kotlin/org/neotech/app/abysner/domain/core/model/Configuration.kt +++ b/domain/src/commonMain/kotlin/org/neotech/app/abysner/domain/core/model/Configuration.kt @@ -44,12 +44,12 @@ data class Configuration( val altitude: Double = 0.0, val algorithm: Algorithm = Algorithm.BUHLMANN_ZH16C, /** - * CCR low O₂ setpoint (bar) used during descent. Kept low to provide a safety buffer + * CCR low O2 setpoint (bar) used during descent. Kept low to provide a safety buffer * against hypoxia and reduce solenoid firing during depth changes. */ val ccrLowSetpoint: Double = 0.7, /** - * CCR high O₂ setpoint (bar) used during bottom time and the entire ascent. A higher + * CCR high O2 setpoint (bar) used during bottom time and the entire ascent. A higher * setpoint reduces inert gas loading and improves decompression efficiency. */ val ccrHighSetpoint: Double = 1.2, diff --git a/domain/src/commonMain/kotlin/org/neotech/app/abysner/domain/core/model/GasSelection.kt b/domain/src/commonMain/kotlin/org/neotech/app/abysner/domain/core/model/GasSelection.kt index 3175ff9..f0f891c 100644 --- a/domain/src/commonMain/kotlin/org/neotech/app/abysner/domain/core/model/GasSelection.kt +++ b/domain/src/commonMain/kotlin/org/neotech/app/abysner/domain/core/model/GasSelection.kt @@ -16,30 +16,14 @@ import org.neotech.app.abysner.domain.core.physics.depthInMetersToBar import kotlin.math.round /** - * Returns the best gas in the list for the current depth, based on given O2 MOD [maxPPO2] and END - * ([maxEND]) constraints. + * Returns the best gas in the list for the current depth, based on O2 MOD [maxPPO2] and END + * [maxEND] constraints. Picks the highest O2 fraction that satisfies both. If nothing satisfies + * the END constraint, the END constraint is dropped and the highest-O2 gas within MOD is returned + * instead. Returns null if no gas satisfies the O2 MOD at all. * - * This functions tracks two separate best candidates: - * - Ideal: satisfies both O2 MOD and END constraints → highest O2 fraction wins. - * - Fallback: if ideal does not satisfy any gas the END constraint is dropped → highest O2 fraction wins. - * - * Returns null when no candidate satisfies the O2 MOD. Callers that want automatic fallback - * behavior can use [findBetterGasOrFallback] instead. - * - * A word on density: - * Enriched gases (e.g. EANx 50) are inherently denser than air at any given depth, because - * O2 is heavier than N2. Including density as a constraint would therefore always bias the - * algorithm away from the higher-O2 deco gases that are specifically chosen for their superior - * off-gassing effect. In practice this is usually a non-issue: a gas's O2 MOD already limits it to - * depths where its density is within safe limits. However, if the user selects a non-ideal gas for - * a section of the dive, the decompression ascent planned automatically may ping between different - * gases due to the density constraints. - * - * Regardless, density is surfaced to the user as a warning in the limits table so they can make - * informed planning choices, but for the above reasons it does not influence automatic gas - * selection at runtime. - * - * @return the best cylinder for the given depth, or null if no cylinder satisfies the O2 MOD. + * Density is intentionally not included as a constraint here: higher-O2 deco gases are denser, so + * including density would bias the selection away from exactly the gases chosen for their + * off-gassing properties. Density warnings are shown to the user separately. */ fun List.findBestGas(depth: Double, environment: Environment, maxPPO2: Double, maxEND: Double): Cylinder? { var ideal: Cylinder? = null @@ -68,14 +52,9 @@ fun List.findBestGas(depth: Double, environment: Environment, maxPPO2: } /** - * Last-resort fallback when [findBestGas] returns null (no gas satisfies the O2 MOD at depth). If - * any non-hypoxic candidates exist (PPO2 ≥ [minPPO2] at [depth]), the one with the lowest O2 - * fraction is returned to minimize O2 toxicity. If all candidates are hypoxic, the one with the - * highest O2 fraction is returned instead, as it produces the highest PPO2 and is therefore the - * least hypoxic option available. - * - * @return the safest cylinder from an oxygen point of view for the given depth, or null if the list - * is empty. + * Last-resort fallback when [findBestGas] returns null (no gas satisfies the O2 MOD at this depth). + * Prefers the lowest-O2 non-hypoxic candidate to minimize toxicity risk. If everything is hypoxic, + * picks the highest-O2 option as the least-bad choice. Returns null if the list is empty. */ internal fun List.findBreathableFallbackGas( depth: Double, @@ -92,16 +71,8 @@ internal fun List.findBreathableFallbackGas( } /** - * Convenience combination of [findBestGas] and [findBreathableFallbackGas]. Returns the best gas - * for the given depth. If no gas satisfies the O2 MOD, falls back to [findBreathableFallbackGas], - * but only when the fallback gas is genuinely better than [currentCylinder]. - * - * "Better" is defined by the same criteria [findBreathableFallbackGas] uses to rank candidates: if - * [currentCylinder] is already an equal or better choice than the fallback, it is returned instead - * and no switch occurs. - * - * @return the best or fallback gas for the given depth, or [currentCylinder] if no switch is - * warranted, or null if both the list and [currentCylinder] are null/empty. + * Combines [findBestGas] with [findBreathableFallbackGas]. Only switches to the fallback if it is + * actually a better choice than [currentCylinder] (lower O2 fraction when MOD is already exceeded). */ fun List.findBetterGasOrFallback(currentCylinder: Cylinder?, depth: Double, environment: Environment, maxPPO2: Double, maxEND: Double, minPPO2: Double = Gas.MIN_PPO2): Cylinder? { val best = findBestGas(depth, environment, maxPPO2, maxEND) diff --git a/domain/src/commonMain/kotlin/org/neotech/app/abysner/domain/decompression/algorithm/buhlmann/Buhlmann.kt b/domain/src/commonMain/kotlin/org/neotech/app/abysner/domain/decompression/algorithm/buhlmann/Buhlmann.kt index 45fc23e..8ffa03c 100644 --- a/domain/src/commonMain/kotlin/org/neotech/app/abysner/domain/decompression/algorithm/buhlmann/Buhlmann.kt +++ b/domain/src/commonMain/kotlin/org/neotech/app/abysner/domain/decompression/algorithm/buhlmann/Buhlmann.kt @@ -267,7 +267,7 @@ data class TissueCompartment( } /** - * CCR tissue loading for inert gases (N₂ and He). Internally uses [schreinerEquation] by + * CCR tissue loading for inert gases (N2 and He). Internally uses [schreinerEquation] by * computing an effective inspired gas pressure and rate via [ccrSchreinerInputs], which * linearizes the CCR inert gas input so the same Schreiner equation applies to both OC and CCR. * diff --git a/domain/src/commonMain/kotlin/org/neotech/app/abysner/domain/decompression/algorithm/buhlmann/BuhlmannUtilities.kt b/domain/src/commonMain/kotlin/org/neotech/app/abysner/domain/decompression/algorithm/buhlmann/BuhlmannUtilities.kt index 2b946d2..60ed9b6 100644 --- a/domain/src/commonMain/kotlin/org/neotech/app/abysner/domain/decompression/algorithm/buhlmann/BuhlmannUtilities.kt +++ b/domain/src/commonMain/kotlin/org/neotech/app/abysner/domain/decompression/algorithm/buhlmann/BuhlmannUtilities.kt @@ -78,11 +78,8 @@ internal fun pressureChangeInBarsPerMinute(beginPressure: Double, endPressure: D /** * Performs the Schreiner equation for one inert gas type and calculates the inert gas pressure in a - * compartment. - * - * **Water vapor pressure**: - * This function is unaware of water vapor pressure. The caller must subtract water vapor pressure - * from ambient pressure before computing [inspiredGasPressure] and [inspiredGasRate] (see + * compartment. This function is unaware of water vapor pressure, the caller must subtract it from + * ambient before computing [inspiredGasPressure] and [inspiredGasRate] (see * [TissueCompartment.addPressureChange]). * * @param initialTissuePressure the initial partial inert gas pressure for this compartment. @@ -90,8 +87,6 @@ internal fun pressureChangeInBarsPerMinute(beginPressure: Double, endPressure: D * @param halfTime the half-time for this compartment in minutes. * @param inspiredGasRate the rate at which the inspired inert gas partial pressure changes per * minute (due to depth change). - * - * @return the new partial inert gas pressure in this compartment. */ internal fun schreinerEquation(initialTissuePressure: Double, inspiredGasPressure: Double, time: Double, halfTime: Double, inspiredGasRate: Double): Double { val timeConstant = ln(2.0) / halfTime @@ -102,48 +97,36 @@ internal fun schreinerEquation(initialTissuePressure: Double, inspiredGasPressur * Computes the effective inspired inert gas pressure and its rate of change for a CCR segment, * for use as drop-in replacements for the OC values passed to [schreinerEquation]. * - * **Water vapor pressure**: - * This function is unaware of water vapor pressure (likewise [schreinerEquation]). The caller must - * add water vapor pressure to the setpoint before passing it in. - * - * **Ambient-setpoint transitions**: - * This function assumes the entire segment stays on one side of the setpoint pressure (ambient - * stays above or below the setpoint for the full segment). If a segment crosses the setpoint - * during ascent or descent, the caller must split it into two sub-segments at the point where - * ambient equals the setpoint, and call this function separately for each. - * - * **Why this works**: - * On OC the inspired inert gas fraction is fixed, so the inspired inert gas partial pressure - * changes linearly with ambient pressure. On CCR the O₂ partial pressure is held constant at the - * setpoint, so the inert gas partial pressure is: + * On OC the inspired inert gas fraction is fixed, so the inspired partial pressure changes linearly + * with ambient pressure. On CCR the O2 partial pressure is held constant at the setpoint, so the + * inert gas partial pressure becomes: * * ``` * (ambient - setpoint) * inertFraction / (1 - oxygenFractionDiluent) * ``` * - * Where `(ambient - setpoint)` is the pressure left for non-O₂ gases. Since `inertFraction` is - * defined relative to the whole diluent (including its O₂), dividing by `(1 - oxygenFractionDiluent)` - * rescales it to exclude the diluent's O₂, which is already accounted for in the setpoint. + * `(ambient - setpoint)` is the pressure left over for non-O2 gases. Since `inertFraction` is + * relative to the whole diluent (including its O2), dividing by `(1 - oxygenFractionDiluent)` + * rescales it to exclude the diluent O2 that the setpoint already accounts for. The result is + * still linear in ambient pressure, so the same Schreiner equation applies to both OC and CCR, + * just with different starting values and slope. * - * Since ambient pressure changes linearly during a segment, the inspired inert gas pressure is also - * linear in time, with a constant rate of change. The Schreiner equation solves for any linear - * input, so the same equation handles both OC and CCR: only the starting value and slope differ. + * Like [schreinerEquation] this function is unaware of water vapor pressure, the caller must add it + * to the setpoint before passing it in. Also assumes the segment stays on one side of the setpoint + * (no crossing during ascent/descent), the caller is responsible for splitting if needed (see + * [TissueCompartment.addPressureChangeCcr]). * - * Verified by tests against the Helling CCR Schreiner equation and a brute-force iterative Haldane - * simulation (see [BuhlmannUtilitiesTest]). + * Verified against the Helling CCR Schreiner equation and a brute-force Haldane simulation (see + * [BuhlmannUtilitiesTest]). * * @param startPressure absolute ambient pressure at segment start * @param pressureRate change in ambient pressure per minute - * @param inertFraction fraction of the inert gas (He or N₂) in the diluent - * @param oxygenFractionDiluent fraction of O₂ in the diluent - * @param setpoint water-vapor-corrected O₂ setpoint + * @param inertFraction fraction of the inert gas (He or N2) in the diluent + * @param oxygenFractionDiluent fraction of O2 in the diluent (must be < 1.0) + * @param setpoint water-vapor-corrected O2 setpoint * - * @return Pair(inspiredGasPressure, inspiredGasRate) for [schreinerEquation], or (0.0, 0.0) when - * startPressure < setpoint (ambient is below the setpoint, the loop cannot reach the setpoint and - * maxes out at pure O₂, so no inert gas is inspired). When startPressure == setpoint, - * inspiredGasPressure is naturally 0 (no diluent in loop yet at this depth) but inspiredGasRate is - * non-zero because as the diver descends, ambient pressure will exceed the setpoint and inert gas - * begins to appear in the loop. + * @return (inspiredGasPressure, inspiredGasRate) for [schreinerEquation], or (0.0, 0.0) when + * ambient is below the setpoint (loop maxes out on pure O2, no inert gas inspired). */ internal fun ccrSchreinerInputs( startPressure: Double, @@ -153,7 +136,7 @@ internal fun ccrSchreinerInputs( setpoint: Double, ): Pair { require(oxygenFractionDiluent < 1.0) { - "Diluent should contain at least some inert gas, 100% O₂ is unrealistic for CCR diving" + "Diluent should contain at least some inert gas, 100% O2 is unrealistic for CCR diving" } require(inertFraction in 0.0..1.0) { "Inert fraction must be between 0.0 and 1.0, got: $inertFraction" diff --git a/domain/src/commonMain/kotlin/org/neotech/app/abysner/domain/decompression/model/DiveSegment.kt b/domain/src/commonMain/kotlin/org/neotech/app/abysner/domain/decompression/model/DiveSegment.kt index ac462b7..a2c21a2 100644 --- a/domain/src/commonMain/kotlin/org/neotech/app/abysner/domain/decompression/model/DiveSegment.kt +++ b/domain/src/commonMain/kotlin/org/neotech/app/abysner/domain/decompression/model/DiveSegment.kt @@ -51,7 +51,7 @@ data class DiveSegment( /** * Whether this segment is breathed open-circuit or closed-circuit (with a specific setpoint). - * Used by O₂ toxicity calculations and gas planning to determine the ppO₂ model. + * Used by O2 toxicity calculations and gas planning to determine the ppO2 model. */ val breathingMode: BreathingMode = BreathingMode.OpenCircuit, diff --git a/domain/src/commonMain/kotlin/org/neotech/app/abysner/domain/diveplanning/DivePlanner.kt b/domain/src/commonMain/kotlin/org/neotech/app/abysner/domain/diveplanning/DivePlanner.kt index 1e6a6ff..5bc118b 100644 --- a/domain/src/commonMain/kotlin/org/neotech/app/abysner/domain/diveplanning/DivePlanner.kt +++ b/domain/src/commonMain/kotlin/org/neotech/app/abysner/domain/diveplanning/DivePlanner.kt @@ -186,8 +186,8 @@ class DivePlanner( alternativeAccents = decompressionPlanner.getAlternativeAccents(), cylinders = cylinders.toPersistentList(), configuration = configuration, - totalCns = OxygenToxicityCalculator().calculateCns(segments, configuration.environment), - totalOtu = OxygenToxicityCalculator().calculateOtu(segments, configuration.environment) + totalCns = OxygenToxicityCalculator.calculateCns(segments, configuration.environment), + totalOtu = OxygenToxicityCalculator.calculateOtu(segments, configuration.environment) ) } diff --git a/domain/src/commonMain/kotlin/org/neotech/app/abysner/domain/gasplanning/OxygenToxicityCalculator.kt b/domain/src/commonMain/kotlin/org/neotech/app/abysner/domain/gasplanning/OxygenToxicityCalculator.kt index fce4d1a..79794bd 100644 --- a/domain/src/commonMain/kotlin/org/neotech/app/abysner/domain/gasplanning/OxygenToxicityCalculator.kt +++ b/domain/src/commonMain/kotlin/org/neotech/app/abysner/domain/gasplanning/OxygenToxicityCalculator.kt @@ -1,6 +1,6 @@ /* * Abysner - Dive planner - * Copyright (C) 2024 Neotech + * Copyright (C) 2024-2026 Neotech * * Abysner is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License version 3, @@ -12,9 +12,11 @@ package org.neotech.app.abysner.domain.gasplanning +import org.neotech.app.abysner.domain.core.model.BreathingMode import org.neotech.app.abysner.domain.core.model.Environment import org.neotech.app.abysner.domain.core.physics.depthInMetersToBar import org.neotech.app.abysner.domain.decompression.model.DiveSegment +import org.neotech.app.abysner.domain.decompression.algorithm.buhlmann.ccrSchreinerInputs import kotlin.math.exp import kotlin.math.pow @@ -28,25 +30,23 @@ import kotlin.math.pow * - https://thetheoreticaldiver.org/wordpress/index.php/2018/12/05/a-few-thoughts-on-oxygen-toxicity/ * - https://github.com/jirkapok/GasPlanner/blob/master/projects/scuba-physics/src/lib/cnsCalculator.ts */ -class OxygenToxicityCalculator { +object OxygenToxicityCalculator { fun calculateCns(segments: List, environment: Environment): Double { var cns = 0.0 segments.forEach { - cns += calculateCns(it.cylinder.gas.oxygenFraction, it.startDepth, it.endDepth, environment, it.duration) + val averagePressure = depthInMetersToBar((it.startDepth + it.endDepth) / 2.0, environment).value + val ppO2 = effectivePartialOxygenPressure(it.cylinder.gas.oxygenFraction, averagePressure, it.breathingMode) + cns += calculateCns(ppO2, it.duration) } return cns } - private fun calculateCns(fO2: Double, startDepth: Double, endDepth: Double, environment: Environment, duration: Int): Double { - val avgPressure = depthInMetersToBar((startDepth + endDepth) / 2.0, environment).value - val ppO2 = fO2 * avgPressure - - if(ppO2 <= MINIMUM_CNS_PPO2) { + private fun calculateCns(ppO2: Double, duration: Int): Double { + if (ppO2 <= MINIMUM_CNS_PPO2) { return 0.0 } - - val exponent = this.getCnsPpo2Slope(ppO2) + val exponent = getCnsPpo2Slope(ppO2) return (duration * 60.0) * exp(exponent) * 100.0 } @@ -58,9 +58,9 @@ class OxygenToxicityCalculator { * * Note: In 2025 research suggested relaxing the ppO2 = 1.3 bar single-exposure limit from * 180 to 240 min. If the NOAA table is updated accordingly, the ppO2 ≤ 1.5 curve fit below - * should be re-fitted against the new values. - * "Revised guideline for CNS oxygen toxicity exposure limits when using an inspired PO2 of - * 1.3 atmospheres." https://doi.org/10.28920/dhm55.3.262-270 + * should be re-fitted against the new values. See: "Revised guideline for CNS oxygen toxicity + * exposure limits when using an inspired PO2 of 1.3 atmospheres." + * https://doi.org/10.28920/dhm55.3.262-270 */ private fun getCnsPpo2Slope(ppO2: Double): Double { if(ppO2 <= 1.5) { @@ -70,34 +70,35 @@ class OxygenToxicityCalculator { } fun calculateOtu(segments: List, environment: Environment): Double { - // For this calculation it should not matter if segments are multiple minutes long, but for CNS this - // seems to be more important (or even necessary). + // For this calculation it should not matter if segments are multiple minutes long, but for + // CNS this seems to be more important (or even necessary). var otu = 0.0 segments.forEach { - val o2 = it.cylinder.gas.oxygenFraction - otu += this.calculateOtu(it.duration, o2, it.startDepth, it.endDepth, environment) + val startAmbientPressure = depthInMetersToBar(it.startDepth, environment).value + val endAmbientPressure = depthInMetersToBar(it.endDepth, environment).value + val ppo2Start = effectivePartialOxygenPressure(it.cylinder.gas.oxygenFraction, startAmbientPressure, it.breathingMode) + val ppo2End = effectivePartialOxygenPressure(it.cylinder.gas.oxygenFraction, endAmbientPressure, it.breathingMode) + otu += this.calculateOtu(it.duration, ppo2Start, ppo2End) } return otu } - private fun calculateOtu(duration: Int, pO2: Double, startDepth: Double, endDepth: Double, environment: Environment): Double { + private fun calculateOtu(duration: Int, ppo2AtStart: Double, ppo2AtEnd: Double): Double { var durationInMinutes = duration.toDouble() - val startAAP = depthInMetersToBar(startDepth, environment).value - val endAAP = depthInMetersToBar(endDepth, environment).value - var ppo2Start = startAAP * pO2 - var ppo2End = endAAP * pO2 + var ppo2Start = ppo2AtStart + var ppo2End = ppo2AtEnd if ((ppo2Start <= MINIMAL_OTU_PPO2) && (ppo2End <= MINIMAL_OTU_PPO2)) { - // If both start and end partial pressure is below a PPO2 of 0.5, then calculating OTU is - // pointless since the effect only really starts at 0.5. + // If both start and end partial pressure is below a PPO2 of 0.5, then calculating OTU + // is pointless since the effect only really starts at 0.5. return 0.0 } // only part of the segment bellow limit if (ppo2Start <= MINIMAL_OTU_PPO2) { - // PPO2 at start is lower then the minimum, only take into account the part of the segment - // that is higher then the minimum value, then also change ppo2start ot the minimum, - // to make sure calculations start form there. + // PPO2 at start is lower than the minimum: only take into account the part of the + // segment that is higher than the minimum value, also change ppo2start to the + // minimum, to make sure calculations take into account only the valid section. durationInMinutes = durationInMinutes * (ppo2End - MINIMAL_OTU_PPO2) / (ppo2End - ppo2Start) ppo2Start = MINIMAL_OTU_PPO2 } else if (ppo2End <= MINIMAL_OTU_PPO2) { @@ -105,19 +106,21 @@ class OxygenToxicityCalculator { ppo2End = MINIMAL_OTU_PPO2 } - // Robert in his blog post "A few thoughts on oxygen toxicity" suggests a new formula for OTU - // based on Erik Baker his paper "Oxygen Toxicity Calculations", this formula allows calculating - // both ascents an descents as well as equal depth sections all at once (without divide by zero issues): + // Robert in his blog post "A few thoughts on oxygen toxicity" suggests a new formula for + // OTU based on Erik Baker his paper "Oxygen Toxicity Calculations", this formula allows + // calculating ascents, descents and flat sections all at once (without divide by zero + // issues): // https://thetheoreticaldiver.org/wordpress/index.php/2018/12/05/a-few-thoughts-on-oxygen-toxicity/ + // The new formula uses a new variable called Pm which is calculated like this: // Pm = (Pa + Pb) / 2 // This variable is then used in the main formula // - // ...(Pm - 0.5) / 0.5... + // (Pm - 0.5) / 0.5 // // Or expanded: // - // ...((Pa + Pb) / 2 - 0.5 / 0.5)... + // ((Pa + Pb) / 2 - 0.5 / 0.5) // // Which is basically saying divide by 2, subtract 0.5 then multiply by 2 again, so one could // have subtracted 1.0 directly instead, for the same result: @@ -125,7 +128,34 @@ class OxygenToxicityCalculator { val rate = pm.pow(5.0 / 6.0) * (1.0 - 5.0 * (ppo2End - ppo2Start).pow(2) / 216 / (pm * pm)) return rate * durationInMinutes } -} -private const val MINIMAL_OTU_PPO2 = 0.5 -private const val MINIMUM_CNS_PPO2 = 0.5 + /** + * Returns the effective ppO2 for a given ambient pressure and breathing mode. + * + * For open circuit this is simply fO2 * ambientPressure. For closed circuit, the ppO2 cannot + * exceed ambient pressure at shallow depths, and the assumption is made that if the ppO2 from + * just the diluent is higher than the setpoint, the setpoint will never be reached (same + * assumption as [ccrSchreinerInputs]). In reality the ppO2 will eventually drop due to + * metabolic consumption. This transient is not modeled, matching the approach used by other CCR + * planners. + */ + internal fun effectivePartialOxygenPressure( + fO2Diluent: Double, + ambientPressure: Double, + breathingMode: BreathingMode, + ): Double = when (breathingMode) { + // Note: it seems kinda weird that we don't correct for water vapor pressure here, but I + // guess it makes sense since the tables the calculations here are based on empirical + // testing where they did not look specifically at the true inspired O2? But rather ambient + // PPO2? Anyhow, not correcting is the conservative choice anyway. + is BreathingMode.OpenCircuit -> + fO2Diluent * ambientPressure + is BreathingMode.ClosedCircuit -> { + val diluentPpO2 = fO2Diluent * ambientPressure + minOf(maxOf(breathingMode.setpoint, diluentPpO2), ambientPressure) + } + } + + private const val MINIMAL_OTU_PPO2 = 0.5 + private const val MINIMUM_CNS_PPO2 = 0.5 +} diff --git a/domain/src/commonTest/kotlin/org/neotech/app/abysner/domain/TestUtilities.kt b/domain/src/commonTest/kotlin/org/neotech/app/abysner/domain/TestUtilities.kt deleted file mode 100644 index 0fdce9b..0000000 --- a/domain/src/commonTest/kotlin/org/neotech/app/abysner/domain/TestUtilities.kt +++ /dev/null @@ -1,19 +0,0 @@ -/* - * Abysner - Dive planner - * Copyright (C) 2024 Neotech - * - * Abysner is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License version 3, - * as published by the Free Software Foundation. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see https://www.gnu.org/licenses/. - */ - -package org.neotech.app.abysner.domain - -import kotlin.math.pow - -fun tenthAtDecimalPoint(decimalPlaces: Int): Double { - return 10.0.pow(-decimalPlaces) -} diff --git a/domain/src/commonTest/kotlin/org/neotech/app/abysner/domain/core/model/CylinderTest.kt b/domain/src/commonTest/kotlin/org/neotech/app/abysner/domain/core/model/CylinderTest.kt index ebe7e00..da26559 100644 --- a/domain/src/commonTest/kotlin/org/neotech/app/abysner/domain/core/model/CylinderTest.kt +++ b/domain/src/commonTest/kotlin/org/neotech/app/abysner/domain/core/model/CylinderTest.kt @@ -12,7 +12,6 @@ package org.neotech.app.abysner.domain.core.model -import org.neotech.app.abysner.domain.tenthAtDecimalPoint import kotlin.test.Test import kotlin.test.assertEquals @@ -23,7 +22,7 @@ class CylinderTest { // Steel 12 liter tank val cylinder = Cylinder(Gas.Air, 232.0, 12.0) - assertEquals(2316.0, cylinder.capacityAt(pressure = 200.0), DOUBLE_PRECISION_DELTA) + assertEquals(2316.0, cylinder.capacityAt(pressure = 200.0), 1.0) } @Test @@ -31,8 +30,6 @@ class CylinderTest { // Steel 12 liter tank val cylinder = Cylinder(Gas.Air, 232.0, 12.0) - assertEquals(99.0, cylinder.pressureAt(volume = 1200.0), DOUBLE_PRECISION_DELTA) + assertEquals(99.0, cylinder.pressureAt(volume = 1200.0), 1.0) } } - -private val DOUBLE_PRECISION_DELTA = tenthAtDecimalPoint(0) diff --git a/domain/src/commonTest/kotlin/org/neotech/app/abysner/domain/core/model/GasTest.kt b/domain/src/commonTest/kotlin/org/neotech/app/abysner/domain/core/model/GasTest.kt index 551f81b..cba4444 100644 --- a/domain/src/commonTest/kotlin/org/neotech/app/abysner/domain/core/model/GasTest.kt +++ b/domain/src/commonTest/kotlin/org/neotech/app/abysner/domain/core/model/GasTest.kt @@ -12,7 +12,6 @@ package org.neotech.app.abysner.domain.core.model -import org.neotech.app.abysner.domain.tenthAtDecimalPoint import kotlin.test.Test import kotlin.test.assertEquals @@ -108,4 +107,4 @@ class GasTest { } } -private val DOUBLE_PRECISION_DELTA = tenthAtDecimalPoint(2) +private val DOUBLE_PRECISION_DELTA = 1e-2 diff --git a/domain/src/commonTest/kotlin/org/neotech/app/abysner/domain/core/physics/PolynomialRealGasModelTest.kt b/domain/src/commonTest/kotlin/org/neotech/app/abysner/domain/core/physics/PolynomialRealGasModelTest.kt index cf20031..aa74d0d 100644 --- a/domain/src/commonTest/kotlin/org/neotech/app/abysner/domain/core/physics/PolynomialRealGasModelTest.kt +++ b/domain/src/commonTest/kotlin/org/neotech/app/abysner/domain/core/physics/PolynomialRealGasModelTest.kt @@ -14,7 +14,6 @@ package org.neotech.app.abysner.domain.core.physics import org.neotech.app.abysner.domain.core.model.Cylinder import org.neotech.app.abysner.domain.core.model.Gas -import org.neotech.app.abysner.domain.tenthAtDecimalPoint import kotlin.test.Test import kotlin.test.assertEquals @@ -36,4 +35,4 @@ class PolynomialRealGasModelTest { } } -private val DOUBLE_TOLERANCE = tenthAtDecimalPoint(6) +private val DOUBLE_TOLERANCE = 1e-6 diff --git a/domain/src/commonTest/kotlin/org/neotech/app/abysner/domain/core/physics/PressureTest.kt b/domain/src/commonTest/kotlin/org/neotech/app/abysner/domain/core/physics/PressureTest.kt index 6e468a3..d7abffd 100644 --- a/domain/src/commonTest/kotlin/org/neotech/app/abysner/domain/core/physics/PressureTest.kt +++ b/domain/src/commonTest/kotlin/org/neotech/app/abysner/domain/core/physics/PressureTest.kt @@ -13,7 +13,6 @@ package org.neotech.app.abysner.domain.core.physics import org.neotech.app.abysner.domain.core.model.Environment -import org.neotech.app.abysner.domain.tenthAtDecimalPoint import kotlin.test.Test import kotlin.test.assertEquals @@ -30,7 +29,7 @@ class PressureTest { @Test fun altitudeToPressure_convertsCorrectly() { - assertEquals(ATMOSPHERIC_PRESSURE_AT_SEA_LEVEL, altitudeToPressure(0.0), tenthAtDecimalPoint(4)) + assertEquals(ATMOSPHERIC_PRESSURE_AT_SEA_LEVEL, altitudeToPressure(0.0), 1e-4) assertEquals(0.7099843196815809, altitudeToPressure(3000.0), DOUBLE_TOLERANCE) } @@ -41,4 +40,4 @@ class PressureTest { } } -private val DOUBLE_TOLERANCE = tenthAtDecimalPoint(4) +private val DOUBLE_TOLERANCE = 1e-4 diff --git a/domain/src/commonTest/kotlin/org/neotech/app/abysner/domain/decompression/algorithm/buhlmann/BuhlmannUtilitiesTest.kt b/domain/src/commonTest/kotlin/org/neotech/app/abysner/domain/decompression/algorithm/buhlmann/BuhlmannUtilitiesTest.kt index 554eaa6..bafb5a9 100644 --- a/domain/src/commonTest/kotlin/org/neotech/app/abysner/domain/decompression/algorithm/buhlmann/BuhlmannUtilitiesTest.kt +++ b/domain/src/commonTest/kotlin/org/neotech/app/abysner/domain/decompression/algorithm/buhlmann/BuhlmannUtilitiesTest.kt @@ -31,7 +31,7 @@ class BuhlmannUtilitiesTest { /** * A diver ascending from a deco stop with a 1.3 bar setpoint. Once ambient drops below * 1.3 bar (around 3 meters), the setpoint can no longer be maintained and the loop - * transitions to pure O₂, so no inert gas is inspired. + * transitions to pure O2, so no inert gas is inspired. */ @Test fun ccrSchreinerInputs_returnsZeroWhenAmbientBelowSetpoint() { @@ -113,7 +113,7 @@ class BuhlmannUtilitiesTest { * * Note: No water vapor correction, since that responsibility lies elsewhere in the code base. * - * Tests the same four ZH-16C N₂ compartments (half-times 5.0, 18.5, 54.3, 635.0 min) as found + * Tests the same four ZH-16C N2 compartments (half-times 5.0, 18.5, 54.3, 635.0 min) as found * in the forum post. */ @Test diff --git a/domain/src/commonTest/kotlin/org/neotech/app/abysner/domain/decompression/model/CompactSimilarSegmentsTest.kt b/domain/src/commonTest/kotlin/org/neotech/app/abysner/domain/decompression/model/CompactSimilarSegmentsTest.kt index 86ed269..6aabba4 100644 --- a/domain/src/commonTest/kotlin/org/neotech/app/abysner/domain/decompression/model/CompactSimilarSegmentsTest.kt +++ b/domain/src/commonTest/kotlin/org/neotech/app/abysner/domain/decompression/model/CompactSimilarSegmentsTest.kt @@ -24,44 +24,6 @@ class CompactSimilarSegmentsTest { private val airCylinder = Cylinder.steel12Liter(Gas.Air) private val nitroxCylinder = Cylinder.aluminium80Cuft(Gas.Nitrox50) - private fun flatSegment( - start: Int, - depth: Double, - duration: Int, - type: DiveSegment.Type = DiveSegment.Type.FLAT, - cylinder: Cylinder = airCylinder, - gfCeilingAtEnd: Double = 0.0, - breathingMode: BreathingMode = BreathingMode.OpenCircuit, - ) = DiveSegment( - start = start, - duration = duration, - startDepth = depth, - endDepth = depth, - cylinder = cylinder, - gfCeilingAtEnd = gfCeilingAtEnd, - type = type, - breathingMode = breathingMode, - ) - - private fun travelSegment( - start: Int, - startDepth: Double, - endDepth: Double, - duration: Int, - cylinder: Cylinder = airCylinder, - gfCeilingAtEnd: Double = 0.0, - breathingMode: BreathingMode = BreathingMode.OpenCircuit, - ) = DiveSegment( - start = start, - duration = duration, - startDepth = startDepth, - endDepth = endDepth, - cylinder = cylinder, - gfCeilingAtEnd = gfCeilingAtEnd, - type = if (startDepth < endDepth) DiveSegment.Type.DECENT else DiveSegment.Type.ASCENT, - breathingMode = breathingMode, - ) - @Test fun compactSimilarSegments_mergesFlatSegmentsOfSameTypeAndGas() { val segments = mutableListOf( @@ -211,8 +173,8 @@ class CompactSimilarSegmentsTest { @Test fun compactSimilarSegments_doesNotMergeFlatSegmentsOfDifferentBreathingMode() { val segments = mutableListOf( - flatSegment(start = 0, depth = 9.0, duration = 3, breathingMode = BreathingMode.OpenCircuit), - flatSegment(start = 3, depth = 9.0, duration = 2, breathingMode = BreathingMode.ClosedCircuit(1.3)), + flatSegment(start = 0, depth = 9.0, duration = 3, breathingMode = BreathingMode.oc()), + flatSegment(start = 3, depth = 9.0, duration = 2, breathingMode = BreathingMode.ccr(1.3)), ) val result = segments.compactSimilarSegments() assertEquals(2, result.size, "Expected segments with different breathing modes to remain separate") @@ -220,7 +182,7 @@ class CompactSimilarSegmentsTest { @Test fun compactSimilarSegments_mergesFlatSegmentsOfSameBreathingMode() { - val ccr = BreathingMode.ClosedCircuit(1.3) + val ccr = BreathingMode.ccr(1.3) val segments = mutableListOf( flatSegment(start = 0, depth = 9.0, duration = 3, breathingMode = ccr), flatSegment(start = 3, depth = 9.0, duration = 2, breathingMode = ccr), @@ -234,8 +196,8 @@ class CompactSimilarSegmentsTest { @Test fun compactSimilarSegments_doesNotMergeTravelSegmentsOfDifferentBreathingMode() { val segments = mutableListOf( - travelSegment(start = 0, startDepth = 9.0, endDepth = 6.0, duration = 1, breathingMode = BreathingMode.ClosedCircuit(1.3)), - travelSegment(start = 1, startDepth = 6.0, endDepth = 3.0, duration = 1, breathingMode = BreathingMode.OpenCircuit), + travelSegment(start = 0, startDepth = 9.0, endDepth = 6.0, duration = 1, breathingMode = BreathingMode.ccr(1.3)), + travelSegment(start = 1, startDepth = 6.0, endDepth = 3.0, duration = 1, breathingMode = BreathingMode.oc()), ) val result = segments.compactSimilarSegments() assertEquals(2, result.size, "Expected travel segments with different breathing modes to remain separate") @@ -244,11 +206,52 @@ class CompactSimilarSegmentsTest { @Test fun compactSimilarSegments_doesNotMergeFlatSegmentsWithDifferentSetpoints() { val segments = mutableListOf( - flatSegment(start = 0, depth = 9.0, duration = 3, breathingMode = BreathingMode.ClosedCircuit(0.7)), - flatSegment(start = 3, depth = 9.0, duration = 2, breathingMode = BreathingMode.ClosedCircuit(1.3)), + flatSegment(start = 0, depth = 9.0, duration = 3, breathingMode = BreathingMode.ccr(0.7)), + flatSegment(start = 3, depth = 9.0, duration = 2, breathingMode = BreathingMode.ccr(1.3)), ) val result = segments.compactSimilarSegments() assertEquals(2, result.size, "Expected CCR segments with different setpoints to remain separate") } + + private fun flatSegment( + start: Int, + depth: Double, + duration: Int, + type: DiveSegment.Type = DiveSegment.Type.FLAT, + cylinder: Cylinder = airCylinder, + gfCeilingAtEnd: Double = 0.0, + breathingMode: BreathingMode = BreathingMode.oc(), + ) = DiveSegment( + start = start, + duration = duration, + startDepth = depth, + endDepth = depth, + cylinder = cylinder, + gfCeilingAtEnd = gfCeilingAtEnd, + type = type, + breathingMode = breathingMode, + ) + + private fun travelSegment( + start: Int, + startDepth: Double, + endDepth: Double, + duration: Int, + cylinder: Cylinder = airCylinder, + breathingMode: BreathingMode = BreathingMode.oc(), + ) = DiveSegment( + start = start, + duration = duration, + startDepth = startDepth, + endDepth = endDepth, + cylinder = cylinder, + gfCeilingAtEnd = 0.0, + type = if (startDepth < endDepth) { + DiveSegment.Type.DECENT + } else { + DiveSegment.Type.ASCENT + }, + breathingMode = breathingMode, + ) } diff --git a/domain/src/commonTest/kotlin/org/neotech/app/abysner/domain/diveplanning/DivePlannerTest.kt b/domain/src/commonTest/kotlin/org/neotech/app/abysner/domain/diveplanning/DivePlannerTest.kt index 5a94cd4..4d48f07 100644 --- a/domain/src/commonTest/kotlin/org/neotech/app/abysner/domain/diveplanning/DivePlannerTest.kt +++ b/domain/src/commonTest/kotlin/org/neotech/app/abysner/domain/diveplanning/DivePlannerTest.kt @@ -22,7 +22,6 @@ import org.neotech.app.abysner.domain.core.model.Gas import org.neotech.app.abysner.domain.core.model.Salinity import org.neotech.app.abysner.domain.decompression.model.DiveSegment import org.neotech.app.abysner.domain.diveplanning.model.DiveProfileSection -import org.neotech.app.abysner.domain.tenthAtDecimalPoint import kotlin.test.Test import kotlin.test.assertEquals @@ -50,8 +49,8 @@ class DivePlannerTest { // println(divePlan.toString(compact = false)) - assertEquals(2.731, divePlan.totalCns, tenthAtDecimalPoint(3)) - assertEquals(5.443, divePlan.totalOtu, tenthAtDecimalPoint(3)) + assertEquals(2.731, divePlan.totalCns, 1e-3) + assertEquals(5.443, divePlan.totalOtu, 1e-3) plan.assertSegment(0, DiveSegment.Type.DECENT, startDepth = 0.0, endDepth = 20.0, duration = 4, gas = bottomGas) plan.assertSegment(1, DiveSegment.Type.FLAT, startDepth = 20.0, endDepth = 20.0, duration = 16, gas = bottomGas) @@ -85,8 +84,8 @@ class DivePlannerTest { // println(divePlan.toString(compact = false)) - assertEquals(11.526, divePlan.totalCns, tenthAtDecimalPoint(3)) - assertEquals(34.091, divePlan.totalOtu, tenthAtDecimalPoint(3)) + assertEquals(11.526, divePlan.totalCns, 1e-3) + assertEquals(34.091, divePlan.totalOtu, 1e-3) plan.assertSegment(0, DiveSegment.Type.DECENT, startDepth = 0.0, endDepth = 30.0, duration = 6, gas = bottomGas) plan.assertSegment(1, DiveSegment.Type.FLAT, startDepth = 30.0, endDepth = 30.0, duration = 24, gas = bottomGas) @@ -124,8 +123,8 @@ class DivePlannerTest { // println(divePlan.toString(compact = false)) - assertEquals(8.614, divePlan.totalCns, tenthAtDecimalPoint(3)) - assertEquals(24.112, divePlan.totalOtu, tenthAtDecimalPoint(3)) + assertEquals(8.614, divePlan.totalCns, 1e-3) + assertEquals(24.112, divePlan.totalOtu, 1e-3) plan.assertSegment(0, DiveSegment.Type.DECENT, startDepth = 0.0, endDepth = 45.0, duration = 9, gas = bottomGas) plan.assertSegment(1, DiveSegment.Type.FLAT, startDepth = 45.0, endDepth = 45.0, duration = 6, gas = bottomGas) @@ -163,8 +162,8 @@ class DivePlannerTest { // println(divePlan.toString(compact = false)) - assertEquals(14.867, divePlan.totalCns, tenthAtDecimalPoint(3)) - assertEquals(39.917, divePlan.totalOtu, tenthAtDecimalPoint(3)) + assertEquals(14.867, divePlan.totalCns, 1e-3) + assertEquals(39.917, divePlan.totalOtu, 1e-3) plan.assertSegment(0, DiveSegment.Type.DECENT, startDepth = 0.0, endDepth = 60.0, duration = 12, gas = bottomGas) plan.assertSegment(1, DiveSegment.Type.FLAT, startDepth = 60.0, endDepth = 60.0, duration = 8, gas = bottomGas) @@ -211,8 +210,8 @@ class DivePlannerTest { // println(divePlan.toString(compact = false)) - assertEquals(8.480, divePlan.totalCns, tenthAtDecimalPoint(3)) - assertEquals(25.542, divePlan.totalOtu, tenthAtDecimalPoint(3)) + assertEquals(8.480, divePlan.totalCns, 1e-3) + assertEquals(25.542, divePlan.totalOtu, 1e-3) plan.assertSegment(0, DiveSegment.Type.DECENT, startDepth = 0.0, endDepth = 40.0, duration = 8, gas = bottomGas) plan.assertSegment(1, DiveSegment.Type.FLAT, startDepth = 40.0, endDepth = 40.0, duration = 2, gas = bottomGas) @@ -284,8 +283,8 @@ class DivePlannerTest { // println(plan.toString(compact = false)) - assertEquals(6.754, plan.totalCns, tenthAtDecimalPoint(3)) - assertEquals(20.510, plan.totalOtu, tenthAtDecimalPoint(3)) + assertEquals(16.514, plan.totalCns, 1e-3) + assertEquals(46.548, plan.totalOtu, 1e-3) val low = BreathingMode.ClosedCircuit(0.7) val high = BreathingMode.ClosedCircuit(1.2) @@ -329,8 +328,8 @@ class DivePlannerTest { // println(plan.toString(compact = false)) - assertEquals(7.139, plan.totalCns, tenthAtDecimalPoint(3)) - assertEquals(21.450, plan.totalOtu, tenthAtDecimalPoint(3)) + assertEquals(13.254, plan.totalCns, 1e-3) + assertEquals(37.111, plan.totalOtu, 1e-3) val low = BreathingMode.ClosedCircuit(0.7) val high = BreathingMode.ClosedCircuit(1.2) @@ -375,8 +374,8 @@ class DivePlannerTest { // println(plan.toString(compact = false)) - assertEquals(2.764, plan.totalCns, tenthAtDecimalPoint(3)) - assertEquals(6.113, plan.totalOtu, tenthAtDecimalPoint(3)) + assertEquals(28.775, plan.totalCns, 1e-3) + assertEquals(80.898, plan.totalOtu, 1e-3) val low = BreathingMode.ClosedCircuit(0.7) val high = BreathingMode.ClosedCircuit(1.2) diff --git a/domain/src/commonTest/kotlin/org/neotech/app/abysner/domain/gasplanning/GasPlannerTest.kt b/domain/src/commonTest/kotlin/org/neotech/app/abysner/domain/gasplanning/GasPlannerTest.kt index 92ca8cb..5857da1 100644 --- a/domain/src/commonTest/kotlin/org/neotech/app/abysner/domain/gasplanning/GasPlannerTest.kt +++ b/domain/src/commonTest/kotlin/org/neotech/app/abysner/domain/gasplanning/GasPlannerTest.kt @@ -18,7 +18,6 @@ import org.neotech.app.abysner.domain.core.model.Gas import org.neotech.app.abysner.domain.core.model.Salinity import org.neotech.app.abysner.domain.diveplanning.DivePlanner import org.neotech.app.abysner.domain.diveplanning.model.DiveProfileSection -import org.neotech.app.abysner.domain.tenthAtDecimalPoint import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue @@ -150,8 +149,8 @@ class GasPlannerTest { val gasPlan = GasPlanner().calculateGasPlan(divePlan) - assertEquals(3770.0, gasPlan[0].totalGasRequirement, tenthAtDecimalPoint(0)) - assertEquals(3824.0, gasPlan[1].totalGasRequirement, tenthAtDecimalPoint(0)) + assertEquals(3770.0, gasPlan[0].totalGasRequirement, 1.0) + assertEquals(3824.0, gasPlan[1].totalGasRequirement, 1.0) } /** @@ -195,8 +194,8 @@ class GasPlannerTest { assertEquals(2, airEntries.size) // Since both cylinders are identical each must receive exactly half - assertEquals(airEntries[0].normalRequirement, airEntries[1].normalRequirement, tenthAtDecimalPoint(0)) - assertEquals(airEntries[0].extraEmergencyRequirement, airEntries[1].extraEmergencyRequirement, tenthAtDecimalPoint(0)) + assertEquals(airEntries[0].normalRequirement, airEntries[1].normalRequirement, 1.0) + assertEquals(airEntries[0].extraEmergencyRequirement, airEntries[1].extraEmergencyRequirement, 1.0) } /** @@ -243,6 +242,6 @@ class GasPlannerTest { val expectedRatio = backMount.capacity() / stage.capacity() val actualRatio = backMountEntry.totalGasRequirement / stageEntry.totalGasRequirement - assertEquals(expectedRatio, actualRatio, tenthAtDecimalPoint(2)) + assertEquals(expectedRatio, actualRatio, 1e-2) } } diff --git a/domain/src/commonTest/kotlin/org/neotech/app/abysner/domain/gasplanning/OxygenToxicityCalculatorTest.kt b/domain/src/commonTest/kotlin/org/neotech/app/abysner/domain/gasplanning/OxygenToxicityCalculatorTest.kt new file mode 100644 index 0000000..422a034 --- /dev/null +++ b/domain/src/commonTest/kotlin/org/neotech/app/abysner/domain/gasplanning/OxygenToxicityCalculatorTest.kt @@ -0,0 +1,120 @@ +/* + * Abysner - Dive planner + * Copyright (C) 2026 Neotech + * + * Abysner is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License version 3, + * as published by the Free Software Foundation. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see https://www.gnu.org/licenses/. + */ + +package org.neotech.app.abysner.domain.gasplanning + +import org.neotech.app.abysner.domain.core.model.BreathingMode +import org.neotech.app.abysner.domain.core.model.Cylinder +import org.neotech.app.abysner.domain.core.model.Environment +import org.neotech.app.abysner.domain.core.model.Gas +import org.neotech.app.abysner.domain.core.physics.depthInMetersToBar +import org.neotech.app.abysner.domain.decompression.model.DiveSegment +import kotlin.test.Test +import kotlin.test.assertEquals + +class OxygenToxicityCalculatorTest { + + private val environment = Environment.Default + + /** + * A flat 30 meters deep and 30 minute long CCR dive at setpoint 1.3 bar, with Trimix 21/35 as + * diluent should produce the same CNS as an open-circuit dive breathing a gas that gives 1.3 + * bar ppO2 at 30 meters (ignoring ascent and descent). + */ + @Test + fun calculateCns_ccrMatchesOcAtEquivalentPpO2() { + val setpoint = 1.3 + val depth = 30.0 + val duration = 30 + val ambientPressure = depthInMetersToBar(depth, environment).value + + val ccrSegments = flatSegment(depth, duration, Gas.Trimix2135, BreathingMode.ccr(setpoint)) + + val equivalentOxygenFraction = setpoint / ambientPressure + val equivalentGas = Gas(oxygenFraction = equivalentOxygenFraction, heliumFraction = 0.0) + val ocSegments = flatSegment(depth, duration, equivalentGas, BreathingMode.oc()) + + val ccrCns = OxygenToxicityCalculator.calculateCns(ccrSegments, environment) + val ocCns = OxygenToxicityCalculator.calculateCns(ocSegments, environment) + + assertEquals(ocCns, ccrCns, 1e-6) + } + + /** + * At 0 meters with a 1.3 setpoint, the ppO2 is capped at ambient because it is physically + * impossible for the loop to exceed ambient pressure. At most, it should match open circuit + * breathing pure oxygen. + */ + @Test + fun calculateCns_ccrShallowCapsAtAmbientPpO2() { + val ccrSegments = flatSegment(0.0, 30, Gas.Air, BreathingMode.ccr(1.3)) + val ocSegments = flatSegment(0.0, 30, Gas.Oxygen, BreathingMode.oc()) + + val ccrCns = OxygenToxicityCalculator.calculateCns(ccrSegments, environment) + val ocCns = OxygenToxicityCalculator.calculateCns(ocSegments, environment) + + assertEquals(ocCns, ccrCns, 1e-6) + } + + /** + * The ppO2 of air diluent at 60 meters with a setpoint of 1.3 exceeds the setpoint, so the CNS + * should match open-circuit CNS on the same gas. Abysner assumes the loop ppO2 does not + * equalize down to the setpoint in these cases, matching other planning software. + */ + @Test + fun calculateCns_ccrUsesTrueDiluentPpO2WhenDiluentExceedsSetpoint() { + val ccrSegments = flatSegment(60.0, 10, Gas.Air, BreathingMode.ccr(1.3)) + val ocSegments = flatSegment(60.0, 10, Gas.Air, BreathingMode.oc()) + + val ccrCns = OxygenToxicityCalculator.calculateCns(ccrSegments, environment) + val ocCns = OxygenToxicityCalculator.calculateCns(ocSegments, environment) + assertEquals(ocCns, ccrCns, 1e-6) + } + + /** + * A flat 30 meters deep and 30 minute long CCR dive at setpoint 1.3 bar, with Trimix 21/35 as + * diluent should produce the same OTU as an open-circuit dive breathing a gas that gives 1.3 + * bar ppO2 at 30 meters (ignoring ascent and descent). + */ + @Test + fun calculateOtu_ccrMatchesOcAtEquivalentPpO2() { + val setpoint = 1.3 + val depth = 30.0 + val duration = 30 + val ambientPressure = depthInMetersToBar(depth, environment).value + + val ccrSegments = flatSegment(depth, duration, Gas.Trimix2135, BreathingMode.ccr(setpoint)) + + val equivalentOxygenFraction = setpoint / ambientPressure + val equivalentGas = Gas(oxygenFraction = equivalentOxygenFraction, heliumFraction = 0.0) + val ocSegments = flatSegment(depth, duration, equivalentGas, BreathingMode.oc()) + + val ccrOtu = OxygenToxicityCalculator.calculateOtu(ccrSegments, environment) + val ocOtu = OxygenToxicityCalculator.calculateOtu(ocSegments, environment) + + assertEquals(ocOtu, ccrOtu, 1e-6) + } + + private fun flatSegment(depth: Double, duration: Int, gas: Gas, breathingMode: BreathingMode) = + listOf( + DiveSegment( + start = 0, + duration = duration, + startDepth = depth, + endDepth = depth, + cylinder = Cylinder.steel12Liter(gas), + gfCeilingAtEnd = 0.0, + type = DiveSegment.Type.FLAT, + breathingMode = breathingMode, + ) + ) +}