UNPKG

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
"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;