Algo 04 Deco Stop Loop - matej-hron/decojs GitHub Wiki

Algo-04 — Deco Stop Loop

From the first stop inward to the surface, produce the list of mandatory stops: depth, duration, controlling gas. This is the main body of generateDecoSchedule.

Entry point

// js/deco/schedule.js:691 (signature)
export function generateDecoSchedule(
    tissuePressures, currentDepth, n2Fraction, gfLow, gfHigh,
    gases = null, options = {}
)

options.surfacePressure controls the surface target, pressure-to-depth conversion, GF endpoint, MOD shift, and stop grid for altitude dives. For an executable Standard-mode runtime, callers may also pass alignRuntimeDepartures: true and runtimeStart (the absolute runtime at bottom departure). The scheduler then waits at each level until both the destination ceiling is clear and the departure runtime is a whole minute.

Returns:

{
  stops: [{ depth: 9, time: 3.7, gas: 'Air', departureRuntime: 36 }, ...],
  gasSwitches: [{ depth: 21, gas: 'EAN50', gasId: 2 }, ...],
  totalTime: 18.2,          // minutes: ascent + stops
  totalAscentTime: 5.5,     // minutes spent moving (not stopped)
  pAnchor: 2.40,            // bar
  anchorDepth: 13.9,        // meters
  decisionAudit: { ... }    // present only when options.audit === true
}

Three explicit policies are selected by options.decoMode:

const decoMode = getDecoMode(options);
const continuousDeco = decoMode === DECO_MODES.CONTINUOUS;
const stopIncrement = continuousDeco ? 0.1 : STOP_INCREMENT;  // 3 m default
const timeIncrement = continuousDeco ? 0.1 : 1;                // 1 min default
const minimumStopTime = decoMode === DECO_MODES.STANDARD ? 1 : 0;
Mode Grid Minimum stay Purpose
standard 3 m / 1 min 1 min at every active level Conventional staged schedule; default
adaptive 3 m / 1 min none Study mode showing only mathematically required waits
continuous 0.1 m / 0.1 min none Fine-grid study visualization

The legacy continuousDeco: true input is still accepted and resolves to continuous; missing or false legacy values resolve safely to standard.

Decision audit

When options.audit is true, the scheduler returns decisionAudit with structured events rather than preformatted UI strings:

{
  version: 1,
  mode: 'standard',
  anchorDepth: 12,
  pAnchor: 2.21325,
  events: [
    { type: 'direct-ascent', decision: 'decompression', ... },
    { type: 'anchor-candidate', roundedCeilingDepth: 12, ... },
    { type: 'anchor-check', candidateDepth: 12, decision: 'accept', ... },
    { type: 'level-decision', depth: 12, targetDepth: 9, totalWait: 1, ... }
  ]
}

The trace distinguishes the mandatory staged-profile minute, additional time required by the ceiling check, runtime-alignment wait, and gas-switch time. Repeated minute-by-minute checks are folded into one level-decision event per depth so long schedules do not create unbounded presentation output. The Sandbox localizes these events only when rendering; the numerical model remains language-independent.

generateDecisionAudit(setup) in js/diveSetup.js reconstructs the tissue state at the end of the generated bottom segment and invokes this opt-in scheduler path. Audit-on and audit-off schedules are regression-tested for identical stops, gas switches, total time, and anchor.

High-level flow

flowchart TD
  A[Input: tissue state<br/>at bottom end] --> B[Compute gas switch points<br/>MOD, 3m grid, sorted deepest first]
  B --> C{Direct ascent<br/>passes GF High?}
  C -- yes --> E[Ascend direct,<br/>switch gases mid-ascent<br/>at their MODs]
  C -- no --> D[Find GF Low anchor<br/>for selected policy]
  D --> F[Ascend to first stop<br/>switching gases at MODs<br/>passed en route]
  F --> G[Stop loop: depth → 0<br/>in stopIncrement steps]
  G --> H[At each depth: check ceiling<br/>at destination GF]
  H -- ceiling ≤ next --> I[Record stop, ascend]
  H -- ceiling > next --> J[Haldane for<br/>timeIncrement, recheck]
  J -- exceeds cap --> K[throw DecoCapExceededError]
  I --> G
Loading

Gas-switch points

// js/deco/schedule.js:143-174
function calculateGasSwitchPoints(gases, switchPpO2, modSurfacePressure, pressurePerMeter, stopIncrement) {
    const gasSwitchPoints = [];
    for (const gas of gases.slice(1)) {
        if (!gas.o2 || gas.o2 <= 0 || !Number.isFinite(gas.o2)) continue;
        if (!Number.isFinite(gas.n2) || gas.n2 < 0 || gas.n2 > 1) continue;
        if (gas.o2 + gas.n2 > 1.001) continue;
        const mod = (switchPpO2 / gas.o2 - modSurfacePressure) / pressurePerMeter;
        if (!Number.isFinite(mod)) continue;
        const switchDepth = Math.max(0, Math.floor(mod / stopIncrement) * stopIncrement);
        gasSwitchPoints.push({ ...gas, switchDepth });
    }
    return gasSwitchPoints.sort((a, b) => b.switchDepth - a.switchDepth);
}

The result is an array of deco gases, each annotated with a switchDepth on the 3 m grid, sorted deepest first. This is the canonical form passed into findFirstStopAtGFLow and the stop loop.

Ascent to first stop, with mid-ascent switches

The ascent to the first stop is not a single Schreiner segment — it is split at every gas-switch depth that falls between currentDepth and firstStopDepth. This matters for richer deco gases whose MODs lie above the first stop; e.g., air bottom + EAN50, dive to 40 m, first stop at 12 m — EAN50 gets switched in at 21 m, so the 40 → 21 m and 21 → 12 m segments use different $f_{N_2}$.

// js/deco/schedule.js:468-498
const ascentSwitchDepths = [...new Set(context.gasSwitchPoints.map(g => g.switchDepth))]
    .filter(d => d < startDepth && d >= firstStopDepth)
    .sort((a, b) => b - a);  // deepest first

let currentAscentDepth = startDepth;
let currentTissues = { ...tissues };

for (const switchDepth of ascentSwitchDepths) {
    if (currentAscentDepth > switchDepth) {
        currentTissues = ascendScheduleSegment(
            context, currentTissues, currentAscentDepth, switchDepth
        );
        currentAscentDepth = switchDepth;
        if (context.switchToBestGas(switchDepth) && context.gasSwitchTime > 0) {
            currentTissues = holdAfterAscentSwitch(
                context, currentTissues, switchDepth, firstStopDepth
            );
        }
    }
}
if (currentAscentDepth > firstStopDepth) {
    currentTissues = ascendScheduleSegment(
        context, currentTissues, currentAscentDepth, firstStopDepth
    );
}
return currentTissues;

First stop policy

The standard mode follows Decotengu's staged first-stop convention. It rounds the current GF Low ceiling to the 3 m grid, simulates ascent to that level, recomputes the ceiling, and repeats until the next rounded ceiling is the current level. This prevents a single long simulated ascent from skipping a level that a conventional staged profile treats as active.

Adaptive and continuous study modes retain the shallowest-safe search in findFirstStopAtGFLow: they may pass through an intermediate level without waiting if the longer ascent itself clears the ceiling.

The stop loop

let pendingStopTime = 0;

while (depth > 0) {
    if (switchToBestGas(depth) && gasSwitchTime > 0) {
        tissues = simulateDepthTime(tissues, depth, gasSwitchTime, currentN2);
        pendingStopTime += gasSwitchTime;
    }

    if (pendingStopTime < minimumStopTime) {
        const mandatoryWait = minimumStopTime - pendingStopTime;
        tissues = simulateDepthTime(tissues, depth, mandatoryWait, currentN2);
        pendingStopTime = minimumStopTime;
    }

    const nextStopDepth = Math.max(0, Math.round((depth - stopIncrement) * 10) / 10);
    const delta = depth - nextStopDepth;
    const ascentTime = delta / ASCENT_SPEED;

    // Can-we-ascend check uses GF at the *destination* depth.
    const gfThere = interpolateGF(getAmbientPressure(nextStopDepth), pAnchor, gfLow, gfHigh);
    const { ceilingDepth } = getDiveCeiling(tissues, gfThere);

    if (ceilingDepth <= nextStopDepth) {
        // Optional operational mode: wait to the next whole absolute runtime
        // minute, then loop and re-check the ceiling before departure.
        if (alignRuntimeDepartures && scheduleRuntime % 1 !== 0) {
            const alignmentWait = Math.ceil(scheduleRuntime) - scheduleRuntime;
            tissues = simulateDepthTime(tissues, depth, alignmentWait, currentN2);
            pendingStopTime += alignmentWait;
            scheduleRuntime += alignmentWait;
            continue;
        }
        if (pendingStopTime > 0) {
            stops.push({
                depth: Math.round(depth * 10) / 10,
                time: Math.round(pendingStopTime * 10) / 10,
                gas: currentGasName,
                departureRuntime: scheduleRuntime
            });
            pendingStopTime = 0;
        }
        tissues = simulateDepthChange(tissues, depth, nextStopDepth, ascentTime, currentN2);
        totalAscentTime += ascentTime;
        depth = nextStopDepth;
    } else {
        tissues = simulateDepthTime(tissues, depth, timeIncrement, currentN2);
        pendingStopTime = Math.round((pendingStopTime + timeIncrement) * 10) / 10;

        if (pendingStopTime > DECO_STOP_MAX_MINUTES) {
            throw new DecoCapExceededError(depth, stops, DECO_STOP_MAX_MINUTES);
        }
    }
}

Per-iteration logic:

  • Compute the GF at the destination depth (one step shallower than current). The ramp gives a higher GF as the diver moves toward the surface, so the destination GF is the relevant constraint for "can I move there now?".
  • In standard mode, simulate enough time to reach one minute at the current level before the ascent test. A configured gas-switch stay counts toward this minute; it is not added twice.
  • If the dive ceiling under the destination GF clears nextStopDepth, ascend (no Schreiner off-gassing credit is applied to the short inter-stop ascent itself). Otherwise wait timeIncrement minutes at depth (Haldane) and re-test.
  • With operational runtime alignment enabled, a safe fractional departure is extended to the next whole absolute runtime minute. The loop then recomputes the ceiling from the updated tissue state; extra time at depth is never assumed to be automatically conservative.
  • In adaptive and continuous study modes, stops are only recorded if tissue loading or gas-switch handling produced positive waiting time. Their first recorded wait may therefore be shallower than anchorDepth.

Safety cap

// js/deco/environment.js:32
export const DECO_STOP_MAX_MINUTES = 300;

If a single stop exceeds 300 min, DecoCapExceededError is thrown (js/deco/config.js:39-52). This is the algorithm's way of flagging "this profile is outside the usable domain" — typically the GF is too aggressive for the exposure, or the chosen gas cannot off-gas this tissue fast enough. Callers should surface the error rather than present a silently-truncated plan.

Worked example

40 m air + EAN50 deco, 25 min bottom, $GF = 30/85$.

  • Gas switch points: EAN50 MOD at $\mathrm{ppO_2} = 1.6$ → 22 m → switchDepth = 21 m.
  • pAnchor ≈ 2.40 bar (14 m), first stop at 15 m (3 m grid).
  • Ascent 40 → 21 m on air, switch to EAN50, 21 → 15 m on EAN50. First stop at 15 m on EAN50 (since 15 ≤ 21).
  • Stop loop from 15 m: typical result around
    • 15 m: 1 min
    • 12 m: 1 min
    • 9 m: 3 min
    • 6 m: 5 min
    • 3 m: 9 min
    • total deco ≈ 19 min, totalTime ≈ 22 min (ascent + stops).

Stop durations commonly grow toward the surface as the controlling compartment shifts toward slower tissues, but monotonic growth is not an algorithmic invariant. Gas changes, compartment changes, and one-minute rounding can produce an equal or locally shorter shallower stop.

In the optional departure-aligned schedule, time may include a fractional alignment wait (for example 1.7 min after arriving at a stop at runtime 34.3). The Sandbox instead keeps the legacy whole-minute model stops and applies a presentation-only practical runtime convention: moves after a stop at the planner's ASCENT_SPEED (10 m/min, 18 seconds per 3 m, as in the profile chart), with ascent runtimes rounded UP to whole minutes so a printed departure is never earlier than the model's. Adaptive and Continuous study modes do not use departure alignment.

Cross-references

⚠️ **GitHub.com Fallback** ⚠️