# 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
```javascript
// js/decoModel.js:899 (signature)
export function generateDecoSchedule(tissuePressures, currentDepth, n2Fraction, gfLow, gfHigh, gases = null, options = {})
```
Returns:
```javascript
{
stops: [{ depth: 9, time: 3, gas: 'Air' }, ...],
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
}
```
Two modes are selected by `options.continuousDeco`:
```javascript
// js/decoModel.js:900-904
const { switchPpO2 = 1.6, continuousDeco = false, gasSwitchTime = 0 } = options;
const stopIncrement = continuousDeco ? 0.1 : STOP_INCREMENT; // 3 m default
const timeIncrement = continuousDeco ? 0.1 : 1; // 1 min default
```
Standard mode produces a 3 m / 1 min staircase matching decotengu and dive-computer output. Continuous mode (0.1 m / 0.1 min) is for educational visualization — the dive profile renders as a smooth curve instead of steps.
## High-level flow
```mermaid
flowchart TD
A[Input: tissue state
at bottom end] --> B[Compute gas switch points
MOD, 3m grid, sorted deepest first]
B --> C[findFirstStopAtGFLow
with gasSwitchPoints]
C --> D{firstStop == 0?}
D -- yes --> E[Ascend direct,
switch gases mid-ascent
at their MODs]
D -- no --> F[Ascend to first stop
switching gases at MODs
passed en route]
F --> G[Stop loop: depth → 0
in stopIncrement steps]
G --> H[At each depth: check ceiling
at destination GF]
H -- ceiling ≤ next --> I[Record stop, ascend]
H -- ceiling > next --> J[Haldane for
timeIncrement, recheck]
J -- exceeds cap --> K[throw DecoCapExceededError]
I --> G
```
## Gas-switch points
```javascript
// js/decoModel.js:922-952
const gasSwitchPoints = [];
if (gases && gases.length > 1) {
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 - 1) * 10;
if (!Number.isFinite(mod)) continue;
const switchDepth = Math.max(0, Math.floor(mod / stopIncrement) * stopIncrement);
gasSwitchPoints.push({ ...gas, switchDepth });
}
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}$.
```javascript
// js/decoModel.js:1044-1074
const ascentSwitchDepths = [...new Set(gasSwitchPoints.map(g => g.switchDepth))]
.filter(d => d < depth && d >= firstStopDepth)
.sort((a, b) => b - a); // deepest first
let currentAscentDepth = depth;
let currentTissues = { ...tissues };
for (const switchDepth of ascentSwitchDepths) {
if (currentAscentDepth > switchDepth) {
const segmentTime = (currentAscentDepth - switchDepth) / ASCENT_SPEED;
currentTissues = simulateDepthChange(currentTissues, currentAscentDepth, switchDepth, segmentTime, currentN2);
totalAscentTime += segmentTime;
currentAscentDepth = switchDepth;
if (switchToBestGas(switchDepth) && gasSwitchTime > 0) {
currentTissues = simulateDepthTime(currentTissues, switchDepth, gasSwitchTime, currentN2);
stops.push({ depth: switchDepth, time: gasSwitchTime, gas: currentGasName });
}
}
}
if (currentAscentDepth > firstStopDepth) {
const finalSegmentTime = (currentAscentDepth - firstStopDepth) / ASCENT_SPEED;
currentTissues = simulateDepthChange(currentTissues, currentAscentDepth, firstStopDepth, finalSegmentTime, currentN2);
totalAscentTime += finalSegmentTime;
}
```
## The stop loop
```javascript
// js/decoModel.js:1081-1132
const MIN_STOP_TIME = continuousDeco ? 2 : 0;
let pendingStopTime = 0;
while (depth > 0) {
if (switchToBestGas(depth) && gasSwitchTime > 0) {
tissues = simulateDepthTime(tissues, depth, gasSwitchTime, currentN2);
pendingStopTime += gasSwitchTime;
}
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) {
if (pendingStopTime > 0) {
if (continuousDeco && pendingStopTime < MIN_STOP_TIME) {
const extra = MIN_STOP_TIME - pendingStopTime;
tissues = simulateDepthTime(tissues, depth, extra, currentN2);
pendingStopTime = MIN_STOP_TIME;
}
stops.push({
depth: Math.round(depth * 10) / 10,
time: Math.round(pendingStopTime * 10) / 10,
gas: currentGasName
});
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?".
- 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.
- Stops are only recorded if `pendingStopTime > 0` — a stop that the diver merely passes through without waiting does not appear in the list. This is why the first recorded stop can be shallower than `anchorDepth`.
## Safety cap
```javascript
// js/decoModel.js:42
export const DECO_STOP_MAX_MINUTES = 300;
```
If a single stop exceeds 300 min, `DecoCapExceededError` is thrown (`js/decoModel.js:48-61`). 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 grow as the controlling compartment shifts toward slower tissues (TC5 → TC7 → TC9 …). The numbers here are indicative — exact values depend on descent profile and gas-switch timing.
## Cross-references
- [Algo-03-First-Stop-Ramped-GF](Algo-03-First-Stop-Ramped-GF.md) — the `pAnchor` and first-stop inputs.
- [Algo-05-Multi-Gas-Switching](Algo-05-Multi-Gas-Switching.md) — how `gasSwitchPoints` and `switchToBestGas` pick gases.
- [Model-05-Gradient-Factors](Model-05-Gradient-Factors.md) — the `interpolateGF` ramp the loop calls on every iteration.
- [References](References.md#41-decotengu-primary-reference-implementation) — stop-termination follows decotengu's convention; the 3900-scenario cross-check passes 100 % within ±5 min, mean diff 0.6 min.