Algo 04 Deco Stop Loop - matej-hron/decojs GitHub Wiki
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.
// 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.
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.
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
// 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.
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
// 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;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.
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 waittimeIncrementminutes 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.
// 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.
40 m air + EAN50 deco, 25 min bottom,
- 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.
-
Algo-03-First-Stop-Ramped-GF — the
pAnchorand first-stop inputs. -
Algo-05-Multi-Gas-Switching — how
gasSwitchPointsandswitchToBestGaspick gases. -
Model-05-Gradient-Factors — the
interpolateGFramp the loop calls on every iteration. - References — stop-termination follows decotengu's convention; the 3900-scenario cross-check passes 100 % within ±5 min, mean diff 0.6 min.