dive_deco/buhlmann/
buhlmann_model.rs

1use crate::buhlmann::buhlmann_config::BuhlmannConfig;
2use crate::buhlmann::compartment::{Compartment, Supersaturation};
3use crate::buhlmann::zhl_values::{ZHLParams, ZHL_16C_N2_16A_HE_VALUES};
4use crate::common::{abs, ceil};
5use crate::common::{
6    AscentRatePerMinute, ConfigValidationErr, Deco, DecoModel, DecoModelConfig, Depth, DiveState,
7    Gas, GradientFactor, OxTox, RecordData,
8};
9use crate::{CeilingType, DecoCalculationError, DecoRuntime, GradientFactors, Sim, Time};
10use alloc::vec;
11use alloc::vec::Vec;
12use core::cmp::Ordering;
13
14#[cfg(feature = "serde")]
15use serde::{Deserialize, Serialize};
16
17const NDL_CUT_OFF_MINS: u8 = 99;
18
19#[derive(Clone, Debug)]
20#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
21pub struct BuhlmannModel {
22    config: BuhlmannConfig,
23    compartments: Vec<Compartment>,
24    state: BuhlmannState,
25    sim: bool,
26}
27pub type BuehlmannModel = BuhlmannModel;
28
29#[derive(Clone, Copy, Debug, PartialEq)]
30#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
31pub struct BuhlmannState {
32    depth: Depth,
33    time: Time,
34    gas: Gas,
35    gf_low_depth: Option<Depth>,
36    ox_tox: OxTox,
37}
38
39impl Default for BuhlmannState {
40    fn default() -> Self {
41        Self {
42            depth: Depth::zero(),
43            time: Time::zero(),
44            gas: Gas::air(),
45            gf_low_depth: None,
46            ox_tox: OxTox::default(),
47        }
48    }
49}
50
51impl DecoModel for BuhlmannModel {
52    type ConfigType = BuhlmannConfig;
53
54    // initialize with the default config
55    fn default() -> Self {
56        Self::new(BuhlmannConfig::default())
57    }
58
59    /// initialize new Buhlmann (ZH-L16C) model with gradient factors
60    fn new(config: BuhlmannConfig) -> Self {
61        // validate config
62        if let Err(e) = config.validate() {
63            panic!("Config error [{}]: {}", e.field, e.reason);
64        }
65        // air as a default init gas
66        let initial_model_state = BuhlmannState::default();
67        let mut model = Self {
68            config,
69            compartments: vec![],
70            state: initial_model_state,
71            sim: false,
72        };
73        model.create_compartments(ZHL_16C_N2_16A_HE_VALUES, config);
74
75        model
76    }
77
78    fn config(&self) -> BuhlmannConfig {
79        self.config
80    }
81
82    fn dive_state(&self) -> DiveState {
83        let BuhlmannState {
84            depth,
85            time,
86            gas,
87            ox_tox,
88            ..
89        } = self.state;
90        DiveState {
91            depth,
92            time,
93            gas,
94            ox_tox,
95        }
96    }
97
98    /// record data: depth (meters), time (seconds), gas
99    fn record(&mut self, depth: Depth, time: Time, gas: &Gas) {
100        self.validate_depth(depth);
101        self.state.depth = depth;
102        self.state.gas = *gas;
103        self.state.time += time;
104        let record = RecordData { depth, time, gas };
105        self.recalculate(record);
106    }
107
108    /// model travel between depths in 1s intervals
109    // @todo: Schreiner equation instead of Haldane to avoid imprecise intervals
110    fn record_travel(&mut self, target_depth: Depth, time: Time, gas: &Gas) {
111        self.validate_depth(target_depth);
112        self.state.gas = *gas;
113        let mut current_depth = self.state.depth;
114        let distance = target_depth - current_depth;
115        let travel_time = time;
116        let dist_rate = distance.as_meters() / travel_time.as_seconds();
117        let mut i = 0;
118        while i < travel_time.as_seconds() as i32 {
119            self.state.time += Time::from_seconds(1.);
120            current_depth += Depth::from_meters(dist_rate);
121            let record = RecordData {
122                depth: current_depth,
123                time: Time::from_seconds(1.),
124                gas,
125            };
126            self.recalculate(record);
127            i += 1;
128        }
129
130        // align with target depth with lost precision @todo: round / bignumber?
131        self.state.depth = target_depth;
132    }
133
134    fn record_travel_with_rate(
135        &mut self,
136        target_depth: Depth,
137        // @todo ascent rate units
138        rate: AscentRatePerMinute,
139        gas: &Gas,
140    ) {
141        self.validate_depth(target_depth);
142
143        let travel_distance = abs((target_depth - self.state.depth).as_meters());
144
145        self.record_travel(
146            target_depth,
147            Time::from_seconds(travel_distance / rate * 60.),
148            gas,
149        );
150    }
151
152    fn ndl(&self) -> Time {
153        // Early return if already in deco
154        if self.in_deco() {
155            return Time::zero();
156        }
157        // Binary search for NDL between 0 and NDL_CUT_OFF_MINS using minute intervals
158        let mut low: u8 = 0;
159        let mut high: u8 = NDL_CUT_OFF_MINS;
160        // Binary search until we narrow down to adjacent minutes
161        while high - low > 1 {
162            let mid = (low + high) / 2;
163            // Check if staying for 'mid' minutes keeps us within NDL
164            if self.check_ndl_for(Time::from_minutes(mid)) {
165                // Still within NDL at mid-point, so NDL is at least this high
166                low = mid;
167            } else {
168                // In deco at mid-point, so NDL is lower
169                high = mid;
170            }
171        }
172        // Check if we can stay for the full cut-off time
173        if self.check_ndl_for(Time::from_minutes(high)) {
174            Time::from_minutes(high)
175        }
176        // At this point, low is safe and high is in deco (or high == low + 1)
177        // Verify that 'low' minutes keeps us within NDL
178        else if self.check_ndl_for(Time::from_minutes(low)) {
179            Time::from_minutes(low)
180        } else {
181            // Edge case: even 'low' puts us in deco
182            Time::from_minutes(0)
183        }
184    }
185
186    fn ceiling(&self) -> Depth {
187        let BuhlmannConfig {
188            deco_ascent_rate,
189            mut ceiling_type,
190            ..
191        } = self.config();
192        if self.sim {
193            ceiling_type = CeilingType::Actual;
194        }
195
196        let leading_comp: &Compartment = self.leading_comp();
197        let mut ceiling = match ceiling_type {
198            CeilingType::Actual => leading_comp.ceiling(),
199            CeilingType::Adaptive => {
200                let mut sim_model = self.fork();
201                let sim_gas = sim_model.dive_state().gas;
202                let mut calculated_ceiling = sim_model.ceiling();
203                loop {
204                    let sim_depth = sim_model.dive_state().depth;
205                    let sim_depth_cmp = sim_depth.partial_cmp(&Depth::zero());
206                    let sim_depth_at_surface = match sim_depth_cmp {
207                        Some(Ordering::Equal | Ordering::Less) => true,
208                        Some(Ordering::Greater) => false,
209                        None => panic!("Simulation depth incomparable to surface"),
210                    };
211                    if sim_depth_at_surface || sim_depth <= calculated_ceiling {
212                        break;
213                    }
214                    sim_model.record_travel_with_rate(
215                        calculated_ceiling,
216                        deco_ascent_rate,
217                        &sim_gas,
218                    );
219                    calculated_ceiling = sim_model.ceiling();
220                }
221                calculated_ceiling
222            }
223        };
224
225        if self.config().round_ceiling() {
226            ceiling = Depth::from_meters(ceil(ceiling.as_meters()));
227        }
228
229        ceiling
230    }
231
232    fn deco(&self, gas_mixes: Vec<Gas>) -> Result<DecoRuntime, DecoCalculationError> {
233        let mut deco = Deco::default();
234        deco.calc(self.fork(), gas_mixes)
235    }
236}
237
238impl Sim for BuhlmannModel {
239    fn fork(&self) -> Self {
240        Self {
241            sim: true,
242            ..self.clone()
243        }
244    }
245    fn is_sim(&self) -> bool {
246        self.sim
247    }
248}
249
250impl BuhlmannModel {
251    /// set of current gradient factors (GF now, GF surface)
252    pub fn supersaturation(&self) -> Supersaturation {
253        let mut acc_gf_99 = 0.;
254        let mut acc_gf_surf = 0.;
255        for comp in self.compartments.iter() {
256            let Supersaturation { gf_99, gf_surf } =
257                comp.supersaturation(self.config.surface_pressure, self.state.depth);
258            if gf_99 > acc_gf_99 {
259                acc_gf_99 = gf_99;
260            }
261            if gf_surf > acc_gf_surf {
262                acc_gf_surf = gf_surf;
263            }
264        }
265
266        Supersaturation {
267            gf_99: acc_gf_99,
268            gf_surf: acc_gf_surf,
269        }
270    }
271
272    pub fn tissues(&self) -> Vec<Compartment> {
273        self.compartments.clone()
274    }
275
276    pub fn update_config(&mut self, new_config: BuhlmannConfig) -> Result<(), ConfigValidationErr> {
277        new_config.validate()?;
278        self.config = new_config;
279        Ok(())
280    }
281
282    fn check_ndl_for(&self, time: Time) -> bool {
283        let mut sim_model = self.fork();
284        sim_model.record(self.state.depth, time, &self.state.gas);
285        !sim_model.in_deco()
286    }
287
288    fn leading_comp(&self) -> &Compartment {
289        let mut leading_comp: &Compartment = &self.compartments[0];
290        for compartment in &self.compartments[1..] {
291            if compartment.min_tolerable_amb_pressure > leading_comp.min_tolerable_amb_pressure {
292                leading_comp = compartment;
293            }
294        }
295
296        leading_comp
297    }
298
299    fn leading_comp_mut(&mut self) -> &mut Compartment {
300        let comps = &mut self.compartments;
301        let mut leading_comp_index = 0;
302        for (i, compartment) in comps.iter().enumerate().skip(1) {
303            if compartment.min_tolerable_amb_pressure
304                > comps[leading_comp_index].min_tolerable_amb_pressure
305            {
306                leading_comp_index = i;
307            }
308        }
309
310        &mut comps[leading_comp_index]
311    }
312
313    fn create_compartments(&mut self, zhl_values: [ZHLParams; 16], config: BuhlmannConfig) {
314        let mut compartments: Vec<Compartment> = vec![];
315        for (i, comp_values) in zhl_values.into_iter().enumerate() {
316            let compartment = Compartment::new(i as u8 + 1, comp_values, config);
317            compartments.push(compartment);
318        }
319        self.compartments = compartments;
320    }
321
322    fn recalculate(&mut self, record: RecordData) {
323        self.recalculate_compartments(&record);
324        if !self.is_sim() {
325            self.recalculate_ox_tox(&record);
326        }
327    }
328
329    fn recalculate_compartments(&mut self, record: &RecordData) {
330        let (gf_low, gf_high) = self.config.gf;
331        for compartment in self.compartments.iter_mut() {
332            compartment.recalculate(record, gf_high, self.config.surface_pressure);
333        }
334
335        // recalc
336        if gf_high != gf_low {
337            let max_gf = self.calc_max_sloped_gf(self.config.gf, record.depth);
338
339            let should_recalc_all_tissues =
340                !self.is_sim() && self.config.recalc_all_tissues_m_values;
341            match should_recalc_all_tissues {
342                true => self.recalculate_all_tissues_with_gf(record, max_gf),
343                false => self.recalculate_leading_compartment_with_gf(record, max_gf),
344            }
345        }
346    }
347
348    fn recalculate_all_tissues_with_gf(&mut self, record: &RecordData, max_gf: GradientFactor) {
349        let recalc_record = RecordData {
350            depth: record.depth,
351            time: Time::zero(),
352            gas: record.gas,
353        };
354        for compartment in self.compartments.iter_mut() {
355            compartment.recalculate(&recalc_record, max_gf, self.config.surface_pressure);
356        }
357    }
358
359    fn recalculate_leading_compartment_with_gf(
360        &mut self,
361        record: &RecordData,
362        max_gf: GradientFactor,
363    ) {
364        let surface_pressure = self.config.surface_pressure;
365        let leading = self.leading_comp_mut();
366
367        // recalculate leading tissue with max gf
368        let leading_tissue_recalc_record = RecordData {
369            depth: record.depth,
370            time: Time::zero(),
371            gas: record.gas,
372        };
373        leading.recalculate(&leading_tissue_recalc_record, max_gf, surface_pressure);
374    }
375
376    fn recalculate_ox_tox(&mut self, record: &RecordData) {
377        self.state
378            .ox_tox
379            .recalculate(record, self.config().surface_pressure);
380    }
381
382    /// Calculate the maximum gradient factor (GF) for a given depth and gradient factors.
383    /// This is the maximum supersaturation on a slope between GF_low and GF_high for a given depth.
384    /// Side effect: updates self.state.gf_low_depth
385    fn calc_max_sloped_gf(&mut self, gf: GradientFactors, depth: Depth) -> GradientFactor {
386        let (gf_low, gf_high) = gf;
387        let in_deco = self.ceiling() > Depth::zero();
388        if !in_deco {
389            return gf_high;
390        }
391
392        let gf_low_depth = match self.state.gf_low_depth {
393            Some(gf_low_depth) => gf_low_depth,
394            None => {
395                // Direct calculation for gf_low_depth
396                let surface_pressure_bar = self.config.surface_pressure as f64 / 1000.0;
397                let gf_low_fraction = gf.0 as f64 / 100.0; // gf.0 is gf_low
398
399                let mut max_calculated_depth_m = 0.0f64;
400
401                for comp in self.compartments.iter() {
402                    let total_ip = comp.total_ip;
403                    let (_, a_weighted, b_weighted) =
404                        comp.weighted_zhl_params(comp.he_ip, comp.n2_ip);
405
406                    // General case: P_amb = (P_ip - G*a) / (1 - G + G/b)
407                    let max_amb_p = (total_ip - gf_low_fraction * a_weighted)
408                        / (1.0 - gf_low_fraction + gf_low_fraction / b_weighted);
409
410                    let max_depth = (10.0 * (max_amb_p - surface_pressure_bar)).max(0.0);
411                    max_calculated_depth_m = max_calculated_depth_m.max(max_depth);
412                }
413
414                let calculated_gf_low_depth = Depth::from_meters(max_calculated_depth_m);
415                self.state.gf_low_depth = Some(calculated_gf_low_depth);
416                calculated_gf_low_depth
417            }
418        };
419
420        if depth > gf_low_depth {
421            return gf_low;
422        }
423
424        self.gf_slope_point(gf, gf_low_depth, depth)
425    }
426
427    fn gf_slope_point(
428        &self,
429        gf: GradientFactors,
430        gf_low_depth: Depth,
431        depth: Depth,
432    ) -> GradientFactor {
433        let (gf_low, gf_high) = gf;
434        let slope_point: f64 = gf_high as f64
435            - (((gf_high - gf_low) as f64) / gf_low_depth.as_meters()) * depth.as_meters();
436
437        slope_point as u8
438    }
439
440    fn validate_depth(&self, depth: Depth) {
441        if depth < Depth::zero() {
442            panic!("Invalid depth [{depth}]");
443        }
444    }
445}
446
447#[cfg(test)]
448mod tests {
449    use super::*;
450    use alloc::string::String;
451
452    #[test]
453    fn test_state() {
454        let mut model = BuhlmannModel::new(BuhlmannConfig::default());
455        let air = Gas::new(0.21, 0.);
456        let nx32 = Gas::new(0.32, 0.);
457        model.record(Depth::from_meters(10.), Time::from_minutes(10.), &air);
458        model.record(Depth::from_meters(15.), Time::from_minutes(15.), &nx32);
459        assert_eq!(model.state.depth.as_meters(), 15.);
460        assert_eq!(model.state.time, Time::from_minutes(25.));
461        assert_eq!(model.state.gas, nx32);
462        assert_eq!(model.state.gf_low_depth, None);
463        assert_ne!(model.state.ox_tox, OxTox::default());
464    }
465
466    #[test]
467    fn test_max_gf_within_ndl() {
468        let gf = (50, 100);
469        let mut model = BuhlmannModel::new(BuhlmannConfig::new().with_gradient_factors(gf.0, gf.1));
470        let air = Gas::air();
471        let record = RecordData {
472            depth: Depth::from_meters(0.),
473            time: Time::zero(),
474            gas: &air,
475        };
476        model.record(record.depth, record.time, record.gas);
477        assert_eq!(model.calc_max_sloped_gf(gf, record.depth), 100);
478    }
479
480    #[test]
481    fn test_max_gf_below_first_stop() {
482        let gf = (50, 100);
483
484        let mut model = BuhlmannModel::new(BuhlmannConfig::new().with_gradient_factors(gf.0, gf.1));
485        let air = Gas::air();
486        let record = RecordData {
487            depth: Depth::from_meters(40.),
488            time: Time::from_minutes(12.),
489            gas: &air,
490        };
491        model.record(record.depth, record.time, record.gas);
492        assert_eq!(model.calc_max_sloped_gf(gf, record.depth), 50);
493    }
494
495    #[test]
496    fn test_max_gf_during_deco() {
497        let gf = (30, 70);
498        let mut model = BuhlmannModel::new(BuhlmannConfig::new().with_gradient_factors(gf.0, gf.1));
499        let air = Gas::air();
500
501        model.record(Depth::from_meters(40.), Time::from_minutes(30.), &air);
502        model.record(Depth::from_meters(21.), Time::from_minutes(5.), &air);
503        model.record(Depth::from_meters(14.), Time::zero(), &air);
504        assert_eq!(model.calc_max_sloped_gf(gf, Depth::from_meters(14.)), 40);
505    }
506
507    #[test]
508    fn test_gf_slope_point() {
509        let gf = (30, 85);
510        let model = BuhlmannModel::new(BuhlmannConfig::new().with_gradient_factors(gf.0, gf.1));
511        let slope_point =
512            model.gf_slope_point(gf, Depth::from_meters(33.528), Depth::from_meters(30.48));
513        assert_eq!(slope_point, 35);
514    }
515
516    #[test]
517    fn test_initial_supersaturation() {
518        fn extract_supersaturations(model: BuhlmannModel) -> Vec<Supersaturation> {
519            model
520                .compartments
521                .clone()
522                .into_iter()
523                .map(|comp| comp.supersaturation(model.config().surface_pressure, Depth::zero()))
524                .collect::<Vec<Supersaturation>>()
525        }
526
527        let model_initial = BuhlmannModel::default();
528
529        let mut model_with_surface_interval = BuhlmannModel::default();
530        model_with_surface_interval.record(Depth::zero(), Time::from_seconds(999999.), &Gas::air());
531
532        let initial_gfs = extract_supersaturations(model_initial);
533        let surface_interval_gfs = extract_supersaturations(model_with_surface_interval);
534
535        assert_eq!(initial_gfs, surface_interval_gfs);
536    }
537
538    #[test]
539    fn test_updating_config() {
540        let mut model = BuhlmannModel::default();
541        let initial_config = model.config();
542        let new_config = BuhlmannConfig::new()
543            .with_gradient_factors(30, 70)
544            .with_round_ceiling(true)
545            .with_ceiling_type(CeilingType::Adaptive)
546            .with_round_ceiling(true);
547        assert_ne!(initial_config, new_config, "given configs aren't identical");
548
549        model.update_config(new_config).unwrap();
550        let updated_config = model.config();
551        assert_eq!(updated_config, new_config, "new config saved");
552
553        let invalid_config = new_config.with_gradient_factors(0, 150);
554        let update_res = model.update_config(invalid_config);
555        assert_eq!(
556            update_res,
557            Err(ConfigValidationErr {
558                field: String::from("gf"),
559                reason: String::from("GF values have to be in 1-100 range"),
560            }),
561            "invalid config update results in Err"
562        );
563    }
564
565    #[test]
566    fn test_ndl_0_if_in_deco() {
567        let mut model = BuhlmannModel::new(
568            BuhlmannConfig::default()
569                .with_gradient_factors(30, 70)
570                .with_ceiling_type(CeilingType::Actual),
571        );
572        let air = Gas::air();
573        model.record(Depth::from_meters(40.), Time::from_minutes(6.), &air);
574        model.record(Depth::from_meters(9.), Time::zero(), &air);
575        let ndl = model.ndl();
576        assert_eq!(ndl, Time::zero());
577    }
578}