Algo 05 Multi Gas Switching - matej-hron/decojs GitHub Wiki

Algo-05 β€” Multi-Gas Switching

Real technical dives carry one bottom gas plus one or more deco gases β€” EAN50 for shallow stops, pure Oβ‚‚ for the last 6 m. DecoJS handles this across three layers: MOD calculation, gas-priority selection inside the deco loop, and waypoint-level switch insertion for chart rendering.

MOD calculation

// js/diveSetup.js
export function calculateMOD(o2Fraction, maxPpO2 = 1.4, surfacePressure = 1, pressurePerMeter = PRESSURE_PER_METER) {
    if (o2Fraction <= 0) return Infinity;
    const maxAmbient = maxPpO2 / o2Fraction;
    const depth = (maxAmbient - surfacePressure) / pressurePerMeter;
    const nearestMeter = Math.round(depth);
    const tolerance = Number.EPSILON * Math.max(1, Math.abs(depth)) * 4;
    return Math.abs(depth - nearestMeter) <= tolerance
        ? nearestMeter
        : Math.floor(depth);
}

$$MOD = \left\lfloor \frac{p_{\mathrm{O_2,max}}/f_{\mathrm{O_2}} - p_{\mathrm{surface}}}{\rho g / 10^5} \right\rfloor \mathrm{m}$$

Default maxPpO2 = 1.4 for bottom gas; 1.6 for deco (relaxed because the diver is resting at a stop, not working). Non-integer depths round toward shallower. Values within floating-point tolerance of an exact integer remain at that integer, so EAN50 correctly reports 18 m at 1.4 bar rather than 17 m. The default surfacePressure = 1 preserves the established sea-level MOD convention; the altitude planner shifts this reference by the local atmospheric pressure difference. The editor passes the selected water conversion, so the same oxygen-pressure limit corresponds to a slightly deeper indicated MOD in freshwater and a slightly shallower one in seawater.

The deco loop then snaps MOD to the 3 m stop grid:

// js/deco/schedule.js:166
const switchDepth = Math.max(0, Math.floor(mod / stopIncrement) * stopIncrement);

Examples at $ppO_2 = 1.6$:

Gas $f_{O_2}$ MOD (m) switchDepth (3 m grid)
EAN50 0.50 22 21
EAN80 0.80 10 9
Pure Oβ‚‚ 1.00 6 6

The editor presents all three values for a deco gas: working MOD, deco MOD, and the recommended switch depth snapped down to the 3 m stop grid.

The partial-pressure chart still plots the exact physical value using SURFACE_PRESSURE = 1.01325 at sea level, so pure Oβ‚‚ at 6Β m is displayed as approximately 1.61Β bar. Operational limit warnings use the same nominal surface-pressure convention as MOD and therefore do not flag the recommended 6Β m Oβ‚‚ switch as an exceedance. Actual exposures deeper than the conventional MOD continue to trigger the warning.

Gas priority in the deco loop

When multiple deco gases are eligible at a depth (i.e. within MOD and not yet switched), DecoJS picks the one with the deepest MOD β€” the richest gas that's still safe. This enforces sequential switching: EAN50 at 21 m before Oβ‚‚ at 6 m, never the other way around.

// js/deco/schedule.js:176-210
function switchToBestGas(context, atDepth, recordSwitch = true) {
    const eligible = context.gasSwitchPoints.filter(gas =>
        atDepth <= gas.switchDepth &&
        gas.n2 < context.getCurrentN2() &&
        !context.usedGases.has(gas.id ?? gas.name)
    );
    if (eligible.length === 0) return false;

    const best = eligible.reduce((a, b) => (b.switchDepth > a.switchDepth ? b : a));
    const key = best.id ?? best.name;
    context.setCurrentGas(best.n2, best.name);
    context.usedGases.add(key);
    if (recordSwitch) {
        context.gasSwitches.push({ depth: atDepth, gas: best.name, gasId: key });
    }
    return true;
}

usedGases is a Set preventing re-selection of a gas already switched to. The key expression gas.id ?? gas.name (js/deco/schedule.js:183) handles gases without an explicit id field. The gas.n2 < currentN2 guard ensures we never "switch" to a gas with equal or worse inert-gas fraction than the current one.

Where switches are handled β€” three layers

1. Anchor search (findFirstStopAtGFLow)

The grid-iterating ascent simulation accepts gasSwitchPoints and applies them as it crosses each switch depth (via _simulateAscentWithGasSwitches). Without this, the dive ceiling at GF_low would be computed as if the diver stayed on bottom gas to the surface, yielding an incorrectly deep first stop. See Algo-03-First-Stop-Ramped-GF.

2. Deco scheduler (generateDecoSchedule)

Gas switches can occur either:

  • During ascent to first stop, at MOD depths that lie between currentDepth and firstStopDepth (js/deco/schedule.js:468-498). Important when a rich deco gas is usable before the first mandatory stop.
  • On arrival at a stop depth, via switchToBestGas(depth) inside the stop loop (js/deco/schedule.js:532).
  • During ascent in a no-deco scenario, if the dive is within NDL but deco gases are still carried for ascent richness (js/deco/schedule.js:440-466).

3. Waypoint insertion (insertGasSwitchWaypoints)

// js/diveSetup.js:1009 (signature)
export function insertGasSwitchWaypoints(waypoints, gases, ascentRate = 10, maxPpO2 = 1.6, gasSwitchTime = DEFAULT_GAS_SWITCH_TIME)

Post-processes a complete waypoint array to add explicit switch waypoints at the correct depth/time. Pre-scans for existing stops to avoid inserting redundant waypoints where a deco stop is already scheduled at the switch depth:

// js/diveSetup.js:1040-1047
const existingStopDepths = new Set();
for (let i = 0; i < waypoints.length - 1; i++) {
    if (waypoints[i].depth === waypoints[i + 1].depth && waypoints[i].depth > 0) {
        existingStopDepths.add(waypoints[i].depth);
    }
}

When inserting, the function:

  • Rounds switch depth to 3 m grid with Math.floor(decoGas.mod / 3) * 3 (js/diveSetup.js:1071).
  • If there is no existing stop at that depth, inserts an arrival waypoint and a departure waypoint separated by gasSwitchTime minutes.
  • If there is an existing stop, merges: updates the stop's gasId rather than inserting a parallel row.
  • Marks every ascending waypoint with the currentGasId so downstream consumers (chart, tissue simulation) pick up the correct $f_{N_2}$.

generateDecoProfile uses the full scheduler path whenever one or more deco gases are configured, even if the bottom time is within NDL. This keeps the profile waypoints, MOD rounding, optional switch time, decision audit, and tissue-loading input identical to a decompression profile. The fast generateSimpleProfile early return is reserved for fresh NDL dives carrying only the bottom gas. A timed gas-switch hold does not turn an NDL profile into a decompression profile or contribute to totalDecoTime. If it coincides with a configured safety-stop depth, the combined hold lasts at least as long as the safety stop.

Gas-switch time

// js/diveSetup.js (constant)
export const DEFAULT_GAS_SWITCH_TIME = 0;

Configurable 0–5 min. Zero matches decotengu (an instantaneous switch) β€” the diver is conceptually assumed to verify the regulator + clear Oβ‚‚ windows within the natural stop duration. Conventions that add a minute or two at the switch as a safety margin are accommodated by setting gasSwitchTime > 0 β€” the time is added to the stop at the switch depth, and tissue loading is simulated for that time on the new gas (js/deco/schedule.js:359-420).

Gas fraction across a switch

Tissue pressure is continuous across a switch. What changes is the alveolar target $P_{alv}$ = $(P_{amb} - P_{H_2O}) \cdot f_{N_2,\text{new}}$, which drives the Schreiner / Haldane equations for subsequent segments. Because the new gas typically has a lower $f_{N_2}$, off-gassing accelerates immediately after the switch β€” the whole point of carrying deco gases.

Worked example

40 m on air + EAN50 + Oβ‚‚, 25 min bottom, $GF = 30/85$:

  1. Descent to 40 m on air; bottom time 25 min on air.
  2. Ascent 40 β†’ 21 m on air. At 21 m, switch to EAN50 (MOD 21 m on 3 m grid). usedGases = { ean50 }.
  3. Ascent 21 β†’ 15 m (first stop) on EAN50. Stop at 15 m, 12 m, 9 m on EAN50.
  4. At 6 m, switch to Oβ‚‚ (MOD 6 m on 3 m grid). usedGases = { ean50, o2 }.
  5. Stop at 6 m and 3 m on Oβ‚‚.
  6. Ascent 3 β†’ 0 m on Oβ‚‚.

Each switch is recorded in gasSwitches: [{depth: 21, gas: 'EAN50', gasId: 2}, {depth: 6, gas: 'O2', gasId: 3}] for chart annotation.

Regression β€” gas switch at time 0

tests/gasSwitchTime-zero-regression.test.mjs guards a subtle case: a 0-minute gas switch waypoint at time = 0 used to drift tissue state by one 10-second step. The fix ensures consecutive same-time waypoints are consumed without advancing the integration clock.

Cross-references