dive-deco
Version:
A TypeScript implementation of decompression calculation algorithms for scuba diving, featuring Bühlmann ZH-L16C algorithm with gradient factors, gas management, and oxygen toxicity tracking.
440 lines (439 loc) • 18.3 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.BuhlmannModel = void 0;
const decoModel_1 = require("./decoModel");
const buhlmannConfig_1 = require("./buhlmannConfig");
const compartment_1 = require("./compartment");
const zhlValues_1 = require("./zhlValues");
const types_1 = require("./types");
const depth_1 = require("./depth");
const time_1 = require("./time");
const gas_1 = require("./gas");
const oxTox_1 = require("./oxTox");
const NDL_CUT_OFF_MINS = 99;
class BuhlmannModel extends decoModel_1.DecoModel {
configuration;
compartments = [];
state;
sim;
constructor(config) {
super();
this.configuration = config || buhlmannConfig_1.BuhlmannConfig.default();
// Validate configuration
const validationError = this.configuration.validate();
if (validationError) {
throw new Error(`Config error [${validationError.field}]: ${validationError.reason}`);
}
this.state = {
depth: depth_1.Depth.zero(),
time: time_1.Time.zero(),
gas: gas_1.Gas.air(),
oxTox: oxTox_1.OxTox.default(),
};
this.sim = false;
this.createCompartments();
}
static default() {
return new BuhlmannModel();
}
config() {
return this.configuration;
}
diveState() {
return {
depth: this.state.depth,
time: this.state.time,
gas: this.state.gas,
oxTox: this.state.oxTox,
};
}
record(depth, time, gas) {
this.validateDepth(depth);
this.state.depth = depth;
this.state.gas = gas;
this.state.time = this.state.time.add(time);
const record = { depth, time, gas };
this.recalculate(record);
}
recordTravel(targetDepth, time, gas) {
this.validateDepth(targetDepth);
this.state.gas = gas;
let currentDepth = this.state.depth;
const distance = targetDepth.asMeters() - currentDepth.asMeters();
const travelTime = time;
const distRate = distance / travelTime.asSeconds();
let i = 0;
while (i < travelTime.asSeconds()) {
this.state.time = this.state.time.add(time_1.Time.fromSeconds(1));
currentDepth = depth_1.Depth.fromMeters(currentDepth.asMeters() + distRate);
const record = {
depth: currentDepth,
time: time_1.Time.fromSeconds(1),
gas,
};
this.recalculate(record);
i++;
}
this.state.depth = targetDepth;
}
recordTravelWithRate(targetDepth, rate, gas) {
const distance = Math.abs(targetDepth.asMeters() - this.state.depth.asMeters());
const travelTimeMinutes = distance / rate;
const travelTime = time_1.Time.fromMinutes(travelTimeMinutes);
this.recordTravel(targetDepth, travelTime, gas);
}
ndl() {
let ndl = time_1.Time.fromMinutes(NDL_CUT_OFF_MINS);
if (this.inDeco()) {
return time_1.Time.zero();
}
// Create a simulation model based on current model's state
const simModel = this.fork();
// Iterate simulation model over 1min records until NDL cut-off or in deco
const interval = time_1.Time.fromMinutes(1);
for (let i = 0; i < NDL_CUT_OFF_MINS; i++) {
simModel.record(this.state.depth, interval, this.state.gas);
if (simModel.inDeco()) {
ndl = interval.multiply(i);
break;
}
}
return ndl;
}
// Fork method for simulation
fork() {
const cloned = new BuhlmannModel(this.configuration);
cloned.state = {
depth: this.state.depth,
time: this.state.time,
gas: this.state.gas,
gfLowDepth: this.state.gfLowDepth,
oxTox: this.state.oxTox.clone(),
};
cloned.compartments = this.compartments.map(c => c.clone());
cloned.sim = true;
return cloned;
}
ceiling() {
const config = this.config();
let ceilingType = config.ceilingType();
// Simulations always use actual ceiling
if (this.sim) {
ceilingType = types_1.CeilingType.Actual;
}
const leadingComp = this.leadingComp();
let ceiling;
switch (ceilingType) {
case types_1.CeilingType.Actual:
ceiling = leadingComp.ceiling();
break;
case types_1.CeilingType.Adaptive:
// Adaptive ceiling calculation: simulate ascending to ceiling iteratively
const simModel = this.fork();
const simGas = simModel.diveState().gas;
let calculatedCeiling = simModel.ceiling(); // This recursively calls with Actual ceiling
let iterations = 0;
while (true) {
const simDepth = simModel.diveState().depth;
// Break if at surface or at/below the ceiling
if (simDepth.asMeters() <= 0 || simDepth.asMeters() <= calculatedCeiling.asMeters()) {
break;
}
// Ascend to the current ceiling at configured ascent rate
simModel.recordTravelWithRate(calculatedCeiling, config.decoAscentRate(), simGas);
// Recalculate ceiling after ascent
calculatedCeiling = simModel.ceiling();
iterations++;
if (iterations > 50) { // Increase iteration limit for better convergence
break;
}
}
ceiling = calculatedCeiling;
break;
default:
throw new Error(`Unsupported ceiling type: ${ceilingType}`);
}
if (config.roundCeiling()) {
ceiling = depth_1.Depth.fromMeters(Math.ceil(ceiling.asMeters()));
}
return ceiling;
}
deco(gasMixes) {
// Create a simulation copy of the model
const simModel = this.clone();
simModel.sim = true;
if (gasMixes.length === 0) {
throw new Error('Empty gas list');
}
if (!gasMixes.find(g => g.equals(simModel.state.gas))) {
throw new Error('Current gas not in list');
}
const stages = [];
while (true) {
const preStageDepth = simModel.state.depth;
const preStageTime = simModel.state.time;
const preStageGas = simModel.state.gas;
const ceiling = simModel.ceiling();
const [decoAction, nextSwitchGas] = this.nextDecoAction(simModel, gasMixes);
if (decoAction === null) {
break; // Decompression finished
}
switch (decoAction) {
case types_1.DecoStageType.Ascent: {
const stopDepth = this.decoStopDepth(ceiling);
simModel.recordTravelWithRate(stopDepth, this.configuration.decoAscentRate(), preStageGas);
const postStageState = simModel.diveState();
stages.push({
stageType: types_1.DecoStageType.Ascent,
startDepth: preStageDepth,
endDepth: postStageState.depth,
duration: postStageState.time.subtract(preStageTime),
gas: postStageState.gas,
});
break;
}
case types_1.DecoStageType.DecoStop: {
const stopDepth = this.decoStopDepth(ceiling);
simModel.record(preStageDepth, time_1.Time.fromSeconds(1), preStageGas);
const postStageState = simModel.diveState();
this.registerDecoStage(stages, {
stageType: types_1.DecoStageType.DecoStop,
startDepth: stopDepth,
endDepth: stopDepth,
duration: postStageState.time.subtract(preStageTime),
gas: postStageState.gas,
});
break;
}
case types_1.DecoStageType.GasSwitch: {
if (nextSwitchGas) {
const switchMod = nextSwitchGas.maxOperatingDepth(1.6);
if (preStageDepth.greaterThan(switchMod)) {
simModel.recordTravelWithRate(switchMod, this.configuration.decoAscentRate(), preStageGas);
const postAscentState = simModel.diveState();
stages.push({
stageType: types_1.DecoStageType.Ascent,
startDepth: preStageDepth,
endDepth: postAscentState.depth,
duration: postAscentState.time.subtract(preStageTime),
gas: preStageGas,
});
}
simModel.record(simModel.diveState().depth, time_1.Time.zero(), nextSwitchGas);
const postSwitchState = simModel.diveState();
this.registerDecoStage(stages, {
stageType: types_1.DecoStageType.GasSwitch,
startDepth: postSwitchState.depth,
endDepth: postSwitchState.depth,
duration: time_1.Time.zero(),
gas: nextSwitchGas,
});
}
break;
}
}
}
const tts = stages.reduce((total, stage) => total.add(stage.duration), time_1.Time.zero());
return {
decoStages: stages,
tts,
ttsSurface: tts, // Simplified for now
sim: true,
};
}
nextDecoAction(simModel, gasMixes) {
const currentDepth = simModel.state.depth;
const currentGas = simModel.state.gas;
if (currentDepth.asMeters() <= 0) {
return [null, null];
}
const ceiling = simModel.ceiling();
if (ceiling.asMeters() <= 0) {
return [types_1.DecoStageType.Ascent, null];
}
if (currentDepth.lessThan(this.decoStopDepth(ceiling))) {
// Missed stop, force ascent to stop depth
return [types_1.DecoStageType.Ascent, null];
}
const nextGas = this.nextSwitchGas(currentDepth, currentGas, gasMixes);
if (nextGas && !nextGas.equals(currentGas)) {
const mod = nextGas.maxOperatingDepth(1.6);
if (currentDepth.asMeters() <= mod.asMeters()) {
return [types_1.DecoStageType.GasSwitch, nextGas];
}
}
if (currentDepth.equals(this.decoStopDepth(ceiling))) {
return [types_1.DecoStageType.DecoStop, null];
}
if (nextGas) {
const mod = nextGas.maxOperatingDepth(1.6);
if (mod.greaterThanOrEqual(ceiling)) {
return [types_1.DecoStageType.GasSwitch, nextGas];
}
}
return [types_1.DecoStageType.Ascent, null];
}
nextSwitchGas(currentDepth, currentGas, gasMixes) {
const currentPPO2 = currentGas.partialPressures(currentDepth, this.configuration.surfacePressure()).o2;
const candidates = gasMixes.filter(gas => {
const ppO2 = gas.partialPressures(currentDepth, this.configuration.surfacePressure()).o2;
return ppO2 > currentPPO2;
});
if (candidates.length === 0) {
return null;
}
candidates.sort((a, b) => a.o2 - b.o2);
return candidates[0];
}
decoStopDepth(ceiling) {
const window = 3; // 3m window
const depth = Math.ceil(ceiling.asMeters() / window) * window;
return depth_1.Depth.fromMeters(depth);
}
registerDecoStage(stages, stage) {
const lastStage = stages.length > 0 ? stages[stages.length - 1] : null;
if (lastStage &&
lastStage.stageType === stage.stageType &&
lastStage.endDepth.equals(stage.startDepth) &&
lastStage.gas.equals(stage.gas)) {
lastStage.duration = lastStage.duration.add(stage.duration);
lastStage.endDepth = stage.endDepth;
}
else {
stages.push(stage);
}
}
cns() {
return this.state.oxTox.cns;
}
otu() {
return this.state.oxTox.otu;
}
// Get supersaturation information - maximum across all compartments
supersaturation() {
let accGf99 = 0;
let accGfSurf = 0;
for (const compartment of this.compartments) {
const supersaturation = compartment.supersaturation(this.configuration.surfacePressure(), this.state.depth);
if (supersaturation.gf99 > accGf99) {
accGf99 = supersaturation.gf99;
}
if (supersaturation.gfSurf > accGfSurf) {
accGfSurf = supersaturation.gfSurf;
}
}
return {
gf99: accGf99,
gfSurf: accGfSurf,
};
}
// Leading compartment - the one with highest min tolerable ambient pressure
leadingComp() {
let leadingComp = this.compartments[0];
for (const compartment of this.compartments.slice(1)) {
if (compartment.minTolerableAmbPressure > leadingComp.minTolerableAmbPressure) {
leadingComp = compartment;
}
}
return leadingComp;
}
// Get supersaturation information for all compartments
supersaturationAll() {
return this.compartments.map(compartment => compartment.supersaturation(this.configuration.surfacePressure(), this.state.depth));
}
// Get tissue pressures for all compartments
tissurePressures() {
return this.compartments.map(compartment => compartment.tissurePressures);
}
createCompartments() {
this.compartments = zhlValues_1.ZHL_16C_N2_16A_HE_VALUES.map((params, index) => {
return new compartment_1.Compartment(index + 1, params, this.configuration);
});
}
recalculate(record) {
this.recalculateCompartments(record);
if (!this.sim) {
this.recalculateOxTox(record);
}
}
recalculateCompartments(record) {
const [gfLow, gfHigh] = this.configuration.gradientFactors();
// First recalculate all compartments with GF high
for (const compartment of this.compartments) {
compartment.recalculate(record, gfHigh, this.configuration.surfacePressure());
}
// If GF slope is enabled, recalculate with appropriate GF
if (gfHigh !== gfLow) {
const maxGf = this.calcMaxSlopedGF(record.depth);
// For simplicity, we'll recalculate the leading compartment with the sloped GF
const leadingComp = this.leadingComp();
leadingComp.recalculate({ ...record, time: time_1.Time.zero() }, maxGf, this.configuration.surfacePressure());
}
}
recalculateOxTox(record) {
const partialPressures = record.gas.inspiredPartialPressures(record.depth, this.configuration.surfacePressure());
this.state.oxTox.addExposure(partialPressures.o2, record.time);
}
recalculateLeadingCompartmentWithGF(depth, gas, maxGF) {
// This method is now empty as the logic is integrated into the main ceiling calculation.
}
calcMaxSlopedGF(depth) {
const [gfLow, gfHigh] = this.configuration.gradientFactors();
const inDeco = this.ceiling().greaterThan(depth_1.Depth.zero());
if (!inDeco) {
return gfHigh;
}
if (!this.state.gfLowDepth) {
// Direct calculation for gf_low_depth
const surfacePressureBar = this.configuration.surfacePressure() / 1000.0;
const gfLowFraction = gfLow / 100.0;
let maxCalculatedDepthM = 0.0;
for (const comp of this.compartments) {
const totalIp = comp.totalIp;
const [, aWeighted, bWeighted] = comp.weightedZhlParams(comp.heIp, comp.n2Ip);
// General case: P_amb = (P_ip - G*a) / (1 - G + G/b)
const maxAmbP = (totalIp - gfLowFraction * aWeighted) /
(1.0 - gfLowFraction + gfLowFraction / bWeighted);
const maxDepth = Math.max(0.0, 10.0 * (maxAmbP - surfacePressureBar));
maxCalculatedDepthM = Math.max(maxCalculatedDepthM, maxDepth);
}
const calculatedGfLowDepth = depth_1.Depth.fromMeters(maxCalculatedDepthM);
this.state.gfLowDepth = calculatedGfLowDepth;
}
if (depth.greaterThan(this.state.gfLowDepth)) {
return gfLow;
}
return this.gfSlopePoint(this.state.gfLowDepth, depth);
}
gfSlopePoint(gfLowDepth, depth) {
const [gfLow, gfHigh] = this.configuration.gradientFactors();
const slopePoint = gfHigh - (((gfHigh - gfLow) / gfLowDepth.asMeters()) * depth.asMeters());
return slopePoint;
}
// Debug method to expose current GF
getCurrentGF() {
return this.calcMaxSlopedGF(this.state.depth);
}
validateDepth(depth) {
if (depth.lessThan(depth_1.Depth.zero())) {
throw new Error('Depth cannot be negative');
}
if (depth.asMeters() > 200) {
throw new Error('Depth exceeds maximum supported depth (200m)');
}
}
clone() {
const cloned = new BuhlmannModel(this.configuration.clone());
cloned.state = {
depth: this.state.depth,
time: this.state.time,
gas: this.state.gas,
gfLowDepth: this.state.gfLowDepth,
oxTox: this.state.oxTox.clone(),
};
cloned.compartments = this.compartments.map(c => c.clone());
cloned.sim = true;
return cloned;
}
}
exports.BuhlmannModel = BuhlmannModel;