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 fn default() -> Self {
56 Self::new(BuhlmannConfig::default())
57 }
58
59 fn new(config: BuhlmannConfig) -> Self {
61 if let Err(e) = config.validate() {
63 panic!("Config error [{}]: {}", e.field, e.reason);
64 }
65 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 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 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 self.state.depth = target_depth;
132 }
133
134 fn record_travel_with_rate(
135 &mut self,
136 target_depth: Depth,
137 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 if self.in_deco() {
155 return Time::zero();
156 }
157 let mut low: u8 = 0;
159 let mut high: u8 = NDL_CUT_OFF_MINS;
160 while high - low > 1 {
162 let mid = (low + high) / 2;
163 if self.check_ndl_for(Time::from_minutes(mid)) {
165 low = mid;
167 } else {
168 high = mid;
170 }
171 }
172 if self.check_ndl_for(Time::from_minutes(high)) {
174 Time::from_minutes(high)
175 }
176 else if self.check_ndl_for(Time::from_minutes(low)) {
179 Time::from_minutes(low)
180 } else {
181 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 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 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 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 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 let surface_pressure_bar = self.config.surface_pressure as f64 / 1000.0;
397 let gf_low_fraction = gf.0 as f64 / 100.0; 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 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}