Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}

Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Cylinder>.findBestGas(depth: Double, environment: Environment, maxPPO2: Double, maxEND: Double): Cylinder? {
var ideal: Cylinder? = null
Expand Down Expand Up @@ -68,14 +52,9 @@ fun List<Cylinder>.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<Cylinder>.findBreathableFallbackGas(
depth: Double,
Expand All @@ -92,16 +71,8 @@ internal fun List<Cylinder>.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<Cylinder>.findBetterGasOrFallback(currentCylinder: Cylinder?, depth: Double, environment: Environment, maxPPO2: Double, maxEND: Double, minPPO2: Double = Gas.MIN_PPO2): Cylinder? {
val best = findBestGas(depth, environment, maxPPO2, maxEND)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,20 +78,15 @@ 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.
* @param inspiredGasPressure the partial pressure of the inspired inert gas at the current depth.
* @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
Expand All @@ -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,
Expand All @@ -153,7 +136,7 @@ internal fun ccrSchreinerInputs(
setpoint: Double,
): Pair<Double, Double> {
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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
)
}

Expand Down
Loading
Loading