dive_deco/common/
deco.rs

1use alloc::vec;
2use alloc::vec::Vec;
3use core::{cmp::Ordering, fmt};
4#[cfg(feature = "serde")]
5use serde::{Deserialize, Serialize};
6
7use crate::{DecoModel, Depth, DepthType, Gas, Time};
8
9use super::{ceil, DecoModelConfig, DiveState, MbarPressure, Sim};
10
11// @todo move to model config
12const DEFAULT_CEILING_WINDOW: DepthType = 3.;
13const DEFAULT_MAX_END_DEPTH: DepthType = 30.;
14
15#[derive(Copy, Clone, Debug, PartialEq)]
16#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
17enum DecoAction {
18    AscentToCeil,
19    AscentToGasSwitchDepth,
20    SwitchGas,
21    Stop,
22}
23
24#[derive(Copy, Clone, Debug, PartialEq)]
25#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
26pub enum DecoStageType {
27    Ascent,
28    DecoStop,
29    GasSwitch,
30}
31
32#[derive(Copy, Clone, Debug, PartialEq)]
33#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
34pub struct DecoStage {
35    pub stage_type: DecoStageType,
36    pub start_depth: Depth,
37    pub end_depth: Depth,
38    pub duration: Time,
39    pub gas: Gas,
40}
41
42#[derive(Clone, Debug, Default)]
43#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
44pub struct Deco {
45    deco_stages: Vec<DecoStage>,
46    tts: Time,
47    sim: bool,
48}
49
50#[derive(Debug, PartialEq, Default, Clone)]
51#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
52pub struct DecoRuntime {
53    // runtime
54    pub deco_stages: Vec<DecoStage>,
55    // current TTS in minutes
56    pub tts: Time,
57    // TTS @+5 (TTS in 5 min given current depth and gas mix)
58    pub tts_at_5: Time,
59    // TTS Δ+5 (absolute change in TTS after 5 mins given current depth and gas mix)
60    pub tts_delta_at_5: Time,
61}
62
63#[derive(Debug)]
64struct MissedDecoStopViolation;
65
66#[derive(Debug, PartialEq, Clone)]
67#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
68pub enum DecoCalculationError {
69    EmptyGasList,
70    CurrentGasNotInList,
71}
72
73impl fmt::Display for DecoCalculationError {
74    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
75        match *self {
76            DecoCalculationError::EmptyGasList => {
77                write!(f, "At least one available gas mix required")
78            }
79            DecoCalculationError::CurrentGasNotInList => write!(
80                f,
81                "Available gas mixes must include current gas mix used by deco model"
82            ),
83        }
84    }
85}
86
87impl Sim for Deco {
88    fn fork(&self) -> Self {
89        Self {
90            sim: true,
91            ..self.clone()
92        }
93    }
94    fn is_sim(&self) -> bool {
95        self.sim
96    }
97}
98
99impl Deco {
100    pub fn new_sim() -> Self {
101        let deco = Self::default();
102        deco.fork()
103    }
104
105    pub fn calc<T: DecoModel + Clone + Sim>(
106        &mut self,
107        deco_model: T,
108        mut gas_mixes: Vec<Gas>,
109    ) -> Result<DecoRuntime, DecoCalculationError> {
110        // validate gas mixes
111        Self::validate_gas_mixes(&deco_model, &gas_mixes)?;
112        // sort deco gasses by o2 content
113        gas_mixes.sort_by(|a, b| {
114            let x = a.gas_pressures_compound(1.);
115            let y = b.gas_pressures_compound(1.);
116            x.o2.partial_cmp(&y.o2).unwrap()
117        });
118
119        // run model simulation until no deco stages
120        let mut sim_model: T = deco_model.clone();
121        let ascent_rate = sim_model.config().deco_ascent_rate();
122        loop {
123            let DiveState {
124                depth: pre_stage_depth,
125                time: pre_stage_time,
126                gas: pre_stage_gas,
127                ..
128            } = sim_model.dive_state();
129            let ceiling = sim_model.ceiling();
130
131            // handle missed deco stop
132            // if missed deco stop, override sim model to depth at the expected stop and rerun the calculation
133            let next_deco_action = self.next_deco_action(&sim_model, gas_mixes.clone());
134            if let Err(e) = next_deco_action {
135                return match e {
136                    MissedDecoStopViolation => {
137                        sim_model.record(
138                            self.deco_stop_depth(ceiling),
139                            Time::zero(),
140                            &pre_stage_gas,
141                        );
142                        self.calc(sim_model, gas_mixes)
143                    }
144                };
145            }
146
147            // handle deco actions
148            let mut deco_stages: Vec<DecoStage> = vec![];
149            let (deco_action, next_switch_gas) = next_deco_action.unwrap();
150            match deco_action {
151                // deco obligation cleared
152                None => {
153                    break;
154                }
155
156                // handle mandatory deco stage
157                Some(deco_action) => {
158                    match deco_action {
159                        // ascent to min depth (deco stop or surface)
160                        DecoAction::AscentToCeil => {
161                            sim_model.record_travel_with_rate(
162                                self.deco_stop_depth(ceiling),
163                                ascent_rate,
164                                &pre_stage_gas,
165                            );
166                            let current_sim_state = sim_model.dive_state();
167                            let current_sim_time = current_sim_state.time;
168                            deco_stages.push(DecoStage {
169                                stage_type: DecoStageType::Ascent,
170                                start_depth: pre_stage_depth,
171                                end_depth: current_sim_state.depth,
172                                duration: current_sim_time - pre_stage_time,
173                                gas: current_sim_state.gas,
174                            })
175                        }
176
177                        // ascent to min depth with gas switch on next deco gas maximum operating depth
178                        DecoAction::AscentToGasSwitchDepth => {
179                            // @todo unwrap and handler err
180                            if let Some(next_switch_gas) = next_switch_gas {
181                                // travel to MOD
182                                let switch_gas_mod = next_switch_gas.max_operating_depth(1.6);
183                                sim_model.record_travel_with_rate(
184                                    switch_gas_mod,
185                                    ascent_rate,
186                                    &pre_stage_gas,
187                                );
188                                let DiveState {
189                                    depth: post_ascent_depth,
190                                    time: post_ascent_time,
191                                    ..
192                                } = sim_model.dive_state();
193                                deco_stages.push(DecoStage {
194                                    stage_type: DecoStageType::Ascent,
195                                    start_depth: pre_stage_depth,
196                                    end_depth: post_ascent_depth,
197                                    duration: post_ascent_time - pre_stage_time,
198                                    gas: pre_stage_gas,
199                                });
200
201                                // switch gas @todo configurable gas change duration
202                                sim_model.record(
203                                    sim_model.dive_state().depth,
204                                    Time::zero(),
205                                    &next_switch_gas,
206                                );
207                                // @todo configurable oxygen window stop
208                                let post_switch_state = sim_model.dive_state();
209                                deco_stages.push(DecoStage {
210                                    stage_type: DecoStageType::GasSwitch,
211                                    start_depth: post_ascent_depth,
212                                    end_depth: post_switch_state.depth,
213                                    duration: Time::zero(),
214                                    gas: next_switch_gas,
215                                });
216                            }
217                        }
218
219                        // switch gas without ascent
220                        DecoAction::SwitchGas => {
221                            let switch_gas = next_switch_gas.unwrap();
222                            // @todo configurable gas switch duration
223                            sim_model.record(pre_stage_depth, Time::zero(), &switch_gas);
224                            deco_stages.push(DecoStage {
225                                stage_type: DecoStageType::GasSwitch,
226                                start_depth: pre_stage_depth,
227                                end_depth: pre_stage_depth,
228                                duration: Time::zero(),
229                                gas: switch_gas,
230                            })
231                        }
232
233                        // decompression stop (a series of 1s segments, merged into one on cleared stop)
234                        DecoAction::Stop => {
235                            let stop_depth = self.deco_stop_depth(ceiling);
236
237                            sim_model.record(
238                                pre_stage_depth,
239                                Time::from_seconds(1.),
240                                &pre_stage_gas,
241                            );
242                            let sim_state = sim_model.dive_state();
243                            // @todo dedupe here on deco instead of of add deco
244                            deco_stages.push(DecoStage {
245                                stage_type: DecoStageType::DecoStop,
246                                start_depth: stop_depth,
247                                end_depth: stop_depth,
248                                duration: sim_state.time - pre_stage_time,
249                                gas: sim_state.gas,
250                            })
251                        }
252                    }
253                }
254            }
255            // register deco stages
256            deco_stages
257                .into_iter()
258                .for_each(|deco_stage| self.register_deco_stage(deco_stage));
259        }
260
261        let tts = self.tts;
262        let mut tts_at_5 = Time::zero();
263        let mut tts_delta_at_5 = Time::zero();
264        if !self.is_sim() {
265            let mut nested_sim_deco = Deco::new_sim();
266            let mut nested_sim_model = deco_model.clone();
267            let DiveState {
268                depth: sim_depth,
269                gas: sim_gas,
270                ..
271            } = nested_sim_model.dive_state();
272            nested_sim_model.record(sim_depth, Time::from_minutes(5.), &sim_gas);
273            let nested_deco = nested_sim_deco.calc(nested_sim_model, gas_mixes.clone())?;
274            tts_at_5 = nested_deco.tts;
275            tts_delta_at_5 = tts_at_5 - tts;
276        }
277
278        Ok(DecoRuntime {
279            deco_stages: self.deco_stages.clone(),
280            tts,
281            tts_at_5,
282            tts_delta_at_5,
283        })
284    }
285
286    fn next_deco_action(
287        &self,
288        sim_model: &impl DecoModel,
289        gas_mixes: Vec<Gas>,
290    ) -> Result<(Option<DecoAction>, Option<Gas>), MissedDecoStopViolation> {
291        let DiveState {
292            depth: current_depth,
293            gas: current_gas,
294            ..
295        } = sim_model.dive_state();
296        let surface_pressure = sim_model.config().surface_pressure();
297
298        // end deco simulation - surface
299        if current_depth <= Depth::zero() {
300            return Ok((None, None));
301        }
302
303        let ceiling = sim_model.ceiling();
304
305        match ceiling.partial_cmp(&Depth::zero()) {
306            Some(Ordering::Equal | Ordering::Less) => Ok((Some(DecoAction::AscentToCeil), None)),
307            Some(Ordering::Greater) => {
308                // check if deco violation
309                if current_depth < self.deco_stop_depth(ceiling) {
310                    return Err(MissedDecoStopViolation);
311                }
312
313                let next_switch_gas =
314                    self.next_switch_gas(current_depth, &current_gas, gas_mixes, surface_pressure);
315                // check if within mod @todo min operational depth
316                if let Some(switch_gas) = next_switch_gas {
317                    //switch gas without ascent if within mod of next deco gas
318                    let gas_mod = switch_gas.max_operating_depth(1.6);
319                    let gas_end = switch_gas.equivalent_narcotic_depth(current_depth);
320                    if (switch_gas != current_gas)
321                        && (current_depth <= gas_mod)
322                        && (gas_end <= Depth::from_meters(DEFAULT_MAX_END_DEPTH))
323                    {
324                        return Ok((Some(DecoAction::SwitchGas), Some(switch_gas)));
325                    }
326                }
327
328                // check if already at deco stop depth
329                let stop_depth = self.deco_stop_depth(ceiling);
330                if current_depth == stop_depth {
331                    Ok((Some(DecoAction::Stop), None))
332                } else {
333                    // ascent to next gas switch depth if next gas' MOD below ceiling
334                    if let Some(next_switch_gas) = next_switch_gas {
335                        if next_switch_gas.max_operating_depth(1.6) >= ceiling {
336                            return Ok((
337                                Some(DecoAction::AscentToGasSwitchDepth),
338                                Some(next_switch_gas),
339                            ));
340                        }
341                    }
342                    Ok((Some(DecoAction::AscentToCeil), None))
343                }
344            }
345            None => panic!("Ceiling and depth uncomparable"),
346        }
347    }
348
349    /// check next deco gas in deco (the one with the lowest MOD while more oxygen-rich than current)
350    fn next_switch_gas(
351        &self,
352        current_depth: Depth,
353        current_gas: &Gas,
354        gas_mixes: Vec<Gas>,
355        surface_pressure: MbarPressure,
356    ) -> Option<Gas> {
357        let current_gas_partial_pressures =
358            current_gas.partial_pressures(current_depth, surface_pressure);
359        // all potential deco gases that are more oxygen-rich than current (inc. trimix / heliox)
360        let switch_gasses = gas_mixes
361            .into_iter()
362            .filter(|gas| {
363                let partial_pressures = gas.partial_pressures(current_depth, surface_pressure);
364                partial_pressures.o2 > current_gas_partial_pressures.o2
365            })
366            .collect::<Vec<Gas>>();
367
368        // mix with the lowest MOD (by absolute o2 content)
369        switch_gasses.first().copied()
370    }
371
372    fn register_deco_stage(&mut self, stage: DecoStage) {
373        // dedupe iterative deco stops and merge into one
374        let mut push_new = true;
375        let last_stage = self.deco_stages.last_mut();
376        if let Some(last_stage) = last_stage {
377            if last_stage.stage_type == stage.stage_type {
378                last_stage.duration += stage.duration;
379                last_stage.end_depth = stage.end_depth;
380                push_new = false;
381            }
382        }
383        if push_new {
384            self.deco_stages.push(stage);
385        }
386
387        // increment TTS by deco stage duration
388        self.tts += stage.duration;
389    }
390
391    // round ceiling up to the bottom of deco window
392    fn deco_stop_depth(&self, ceiling: Depth) -> Depth {
393        let depth = DEFAULT_CEILING_WINDOW * ceil(ceiling.as_meters() / DEFAULT_CEILING_WINDOW);
394        Depth::from_meters(depth)
395    }
396
397    fn validate_gas_mixes<T: DecoModel>(
398        deco_model: &T,
399        gas_mixes: &[Gas],
400    ) -> Result<(), DecoCalculationError> {
401        if gas_mixes.is_empty() {
402            return Err(DecoCalculationError::EmptyGasList);
403        }
404        let current_gas = deco_model.dive_state().gas;
405        let current_gas_in_available = gas_mixes.iter().find(|gas_mix| **gas_mix == current_gas);
406        if current_gas_in_available.is_none() {
407            return Err(DecoCalculationError::CurrentGasNotInList);
408        }
409        Ok(())
410    }
411}
412
413#[cfg(test)]
414mod tests {
415    use super::*;
416    use crate::{BuhlmannConfig, BuhlmannModel};
417
418    #[test]
419    fn test_ceiling_rounding() {
420        let test_cases: Vec<(DepthType, DepthType)> = vec![
421            (0., 0.),
422            (2., 3.),
423            (2.999, 3.),
424            (3., 3.),
425            (3.00001, 6.),
426            (12., 12.),
427        ];
428        let deco = Deco::default();
429        for case in test_cases.into_iter() {
430            let (input_depth, expected_depth) = case;
431            let res = deco.deco_stop_depth(Depth::from_meters(input_depth));
432            assert_eq!(res, Depth::from_meters(expected_depth));
433        }
434    }
435
436    #[test]
437    fn test_next_switch_gas() {
438        let air = Gas::air();
439        let ean_50 = Gas::new(0.5, 0.);
440        let oxygen = Gas::new(1., 0.);
441        let trimix = Gas::new(0.5, 0.2);
442
443        // potential switch if in deco!
444        // [ (current_depth, current_gas, gas_mixes, expected_result) ]
445        // @todo depth constructor in test cases
446        let test_cases: Vec<(DepthType, Gas, Vec<Gas>, Option<Gas>)> = vec![
447            // single gas air
448            (10., air, vec![air], None),
449            // air + ean50 within MOD
450            (10., air, vec![air, ean_50], Some(ean_50)),
451            // air + ean50 over MOD
452            (30., air, vec![air, ean_50], Some(ean_50)),
453            // air + ean50 + oxygen, ean50 withing MOD, oxygen out
454            (20., air, vec![air, ean_50, oxygen], Some(ean_50)),
455            // air + ean50 + oxy, deco on ean50, oxygen within MOD
456            (5.5, ean_50, vec![air, ean_50, oxygen], Some(oxygen)),
457            // air + heliox within o2 MOD, not considered deco gas
458            (30., air, vec![air, trimix], Some(trimix)),
459        ];
460
461        let deco = Deco::default();
462        for case in test_cases.into_iter() {
463            let (current_depth, current_gas, available_gas_mixes, expected_switch_gas) = case;
464            let res = deco.next_switch_gas(
465                Depth::from_meters(current_depth),
466                &current_gas,
467                available_gas_mixes,
468                1000,
469            );
470            assert_eq!(res, expected_switch_gas);
471        }
472    }
473
474    #[test]
475    fn should_err_on_empty_gas_mixes() {
476        let mut deco = Deco::default();
477        let deco_model = BuhlmannModel::default();
478        let deco_res = deco.calc(deco_model, vec![]);
479        assert_eq!(deco_res, Err(DecoCalculationError::EmptyGasList));
480    }
481
482    #[test]
483    fn should_err_on_gas_mixes_without_current_mix() {
484        let mut deco = Deco::default();
485        let mut deco_model = BuhlmannModel::default();
486        let air = Gas::air();
487        let ean50 = Gas::new(0.50, 0.);
488        let tmx2135 = Gas::new(0.21, 0.35);
489        deco_model.record_travel_with_rate(Depth::from_meters(40.), 10., &air);
490        let deco_res = deco.calc(deco_model, vec![ean50, tmx2135]);
491        assert_eq!(deco_res, Err(DecoCalculationError::CurrentGasNotInList));
492    }
493
494    #[test]
495    fn no_duplicated_stops_within_deco_padding() {
496        let mut deco = Deco::default();
497        let mut deco_model =
498            BuhlmannModel::new(BuhlmannConfig::default().with_gradient_factors(30, 70));
499        let air = Gas::air();
500        let ean50 = Gas::new(0.50, 0.);
501        deco_model.record_travel_with_rate(Depth::from_meters(40.), 10., &air);
502        deco_model.record(Depth::from_meters(40.), Time::from_minutes(27.), &air);
503        deco_model.record_travel_with_rate(Depth::from_meters(16.), 10., &air);
504        deco_model.record(
505            Depth::from_meters(16.),
506            Time::from_minutes(1.),
507            &Gas::new(0.50, 0.),
508        );
509
510        let mut deco_stop_depths: Vec<Depth> = vec![];
511        let deco_res = deco.calc(deco_model, vec![air, ean50]).unwrap();
512        for deco_stage in deco_res.deco_stages {
513            if deco_stage.stage_type == DecoStageType::DecoStop {
514                let deco_stop_depth = deco_stage.start_depth;
515                assert_eq!(
516                    deco_stop_depths.contains(&deco_stop_depth),
517                    false,
518                    "{}m deco stop should not repeat",
519                    deco_stop_depth
520                );
521                deco_stop_depths.push(deco_stop_depth);
522            }
523        }
524    }
525}