1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20 """
21 Decompression dive planning.
22 """
23
24 from collections import namedtuple
25 import enum
26 import itertools
27 import math
28 import operator
29 import re
30 import logging
31
32 from kenozooid.data import gas
33 from kenozooid.calc import mod, pp_o2
34
35 logger = logging.getLogger(__name__)
36
37
38 RE_GAS = re.compile("""
39 ^(?P<name>
40 (?P<type> O2 | AIR | EAN | TX)
41 ((?<=TX|AN)(?P<o2>[0-9]{2}))?
42 ((?<=TX..)/(?P<he>[0-9]{2}))?
43 )
44 (@(?P<depth>[0-9]+))?
45 (\|(?P<tank>([2-9]x[1-9]{1,2})))?
46 $
47 """, re.VERBOSE)
48
49
51 """
52 Dive planner exception.
53 """
54
55
56
58 """
59 List of gas mixes.
60
61 :var travel_gas: List of travel gas mixes.
62 :var bottom_gas: Bottom gas mix.
63 :var deco_gas: List of decompression gas mixes.
64 """
66 """
67 Create list of gas mixes.
68
69 :param gas: Bottom gas mix.
70 """
71 self.bottom_gas = gas
72 self.travel_gas = []
73 self.deco_gas = []
74
75
76
78 """
79 Dive plan information.
80
81 Dive plan information contains list of dive profiles, gas volume
82 information and dive decompression parameters.
83
84 :var profiles: List of dive profiles.
85 :var min_gas_vol: Minimal volume of gas mixes required for the plan.
86 :var last_stop_6m: True if last stop is at 6m.
87 :var gf_low: Gradient factor low value (decompression model specific).
88 :var gf_high: Gradient factor high value (decompression model specific).
89 :var descent_rate: Descent rate.
90 :var rmv: Respiratory Minute Volume.
91 :var ext_profile: Tuple depth and time to add for an extended dive
92 profile.
93 """
95 self.profiles = []
96 self.min_gas_vol = {}
97 self.last_stop_6m = False
98 self.gf_low = 30
99 self.gf_high = 85
100
101 self.descent_rate = 20
102 self.rmv = 20
103 self.ext_profile = 5, 3
104
105 self.gas_mix_ppo2 = 1.4
106 self.deco_gas_mix_ppo2 = 1.6
107
108
109
111 """
112 Dive profile information.
113
114 :var type: Dive profile type.
115 :var gas_list: Gas list for the dive profile.
116 :var depth: Maximum dive depth.
117 :var time: Dive bottom time.
118 :var descent_time: Time required to descent to dive bottom depth.
119 :var deco_time: Total decompression time.
120 :var dive_time: Total dive time.
121 :var pp_o2: The O2 partial pressure of bottom gas mix at maximum dive
122 depth.
123 :var mod: Maximum operating depth for bottom gas mix and maximum dive
124 depth.
125 :var slate: Dive slate.
126 :var gas_vol: Dictionary of gas mix and gas volume required for the
127 dive.
128 :var gas_info: Gas mix requirements information.
129 """
130 - def __init__(self, type, gas_list, depth, time):
131 self.type = type
132 self.gas_list = gas_list
133 self.depth = depth
134 self.time = time
135 self.descent_time = None
136 self.deco_time = None
137 self.dive_time = None
138 self.pp_o2 = None
139 self.mod = None
140 self.slate = []
141 self.gas_vol = {}
142 self.gas_info = []
143
144
145
147 """
148 Dive profile type.
149
150 The dive profile types are
151
152 PLANNED
153 Dive profile planned by a diver.
154 LOST_GAS
155 Dive profile as planned dive but for lost decompression gas.
156 EXTENDED
157 Extended dive profile compared to planned dive profile.
158 EXTENDED_LOST_GAS
159 Combination of `EXTENDED` and `LOST_GAS` dive profiles.
160 """
161 PLANNED = 'planned'
162 LOST_GAS = 'lost gas'
163 EXTENDED = 'extended'
164 EXTENDED_LOST_GAS = 'extended + lost gas'
165
166
167
169 """
170 Plan decompression dive.
171
172 The dive plan information is calculated and stored in the dive plan
173 object.
174
175 Any dive plan configuration should be set in the dive plan object
176 before calling this function.
177
178 :param plan: Dive plan object to be filled with dive plan information.
179 :param gas_list: Gas mix configuration list.
180 :param depth: Maximum dive depth.
181 :param time: Dive bottom time.
182 """
183 ext_depth = depth + plan.ext_profile[0]
184 ext_time = time + plan.ext_profile[1]
185
186 gas_list = gas_mix_depth_update(
187 gas_list, plan.gas_mix_ppo2, plan.deco_gas_mix_ppo2
188 )
189
190 lost_gas_list = GasList(gas_list.bottom_gas)
191 lost_gas_list.travel_gas.extend(gas_list.travel_gas)
192
193 pt = ProfileType
194 plan.profiles = [
195 DiveProfile(pt.PLANNED, gas_list, depth, time),
196 DiveProfile(pt.LOST_GAS, lost_gas_list, depth, time),
197 DiveProfile(pt.EXTENDED, gas_list, ext_depth, ext_time),
198 DiveProfile(pt.EXTENDED_LOST_GAS, lost_gas_list, ext_depth, ext_time),
199 ]
200
201 for p in plan.profiles:
202 stops = deco_stops(plan, p)
203 if not stops:
204 raise DivePlanError('NDL dive, no plan calculated')
205
206 legs = dive_legs(p, stops, plan.descent_rate)
207 if p.type == ProfileType.PLANNED:
208 plan.min_gas_vol = min_gas_volume(p.gas_list, legs, rmv=plan.rmv)
209
210 p.deco_time = sum_deco_time(legs)
211 p.dive_time = sum_dive_time(legs)
212 p.pp_o2 = pp_o2(p.depth, p.gas_list.bottom_gas.o2)
213 p.mod = mod(p.gas_list.bottom_gas.o2, plan.gas_mix_ppo2)
214 p.slate = dive_slate(p, stops, legs, plan.descent_rate)
215
216 p.descent_time = depth_to_time(0, p.depth, plan.descent_rate)
217 p.gas_vol = gas_volume(p.gas_list, legs, rmv=plan.rmv)
218
219
220
221
222
223 assert plan.min_gas_vol
224
225
227 """
228 Calculate decompression stops for a dive profile.
229
230 The dive plan information is used to configure decompression engine.
231
232 :param plan: Dive plan information.
233 :param profile: Dive profile information.
234 """
235 import decotengu
236 engine = decotengu.create()
237 engine.last_stop_6m = plan.last_stop_6m
238 engine.model.gf_low = plan.gf_low / 100
239 engine.model.gf_high = plan.gf_high / 100
240
241 gas_list = profile.gas_list
242
243
244 for m in gas_list.travel_gas:
245 engine.add_gas(m.depth, m.o2, m.he, travel=True)
246 logger.debug('added travel gas {}'.format(m))
247
248 m = gas_list.bottom_gas
249 engine.add_gas(m.depth, m.o2, m.he)
250 logger.debug('added bottom gas {}'.format(m))
251
252 for m in gas_list.deco_gas:
253 engine.add_gas(m.depth, m.o2, m.he)
254 logger.debug('added deco gas {}'.format(m))
255
256 list(engine.calculate(profile.depth, profile.time))
257
258 return engine.deco_table
259
260
262 """
263 Calculate dive legs information.
264
265 The dive legs information is used for other calculations, i.e. dive gas
266 consumption, dive slate.
267
268 Dive profile is split into legs using
269
270 - gas mix switch depths
271 - dive maximum depth and bottom time
272 - descent rate
273 - list of decompression stops
274
275 The ascent rate is assumed to be 10m/min.
276
277 Each dive leg consists of the following information
278
279 - start depth
280 - end depth
281 - time
282 - gas mix used during a dive leg
283 - deco zone indicator (true or false)
284 """
285 gas_list = profile.gas_list
286 max_depth = profile.depth
287 time = profile.time
288
289 legs = []
290
291
292 if gas_list.travel_gas:
293
294 mixes = gas_list.travel_gas + [gas_list.bottom_gas]
295 depths = [m.depth for m in mixes[1:]]
296 times = (
297 depth_to_time(d, m.depth, descent_rate)
298 for m, d in zip(mixes, depths)
299 )
300 legs.extend(
301 (m.depth, d, t, m, False)
302 for m, d, t in zip(mixes, depths, times)
303 )
304
305
306
307
308 m = gas_list.bottom_gas
309 if max_depth > m.depth:
310 t = depth_to_time(m.depth, max_depth, descent_rate)
311 legs.append((m.depth, max_depth, t, m, False))
312
313 assert abs(sum(l[2] for l in legs) - max_depth / descent_rate) < 0.00001
314
315
316 t = time - max_depth / descent_rate
317 legs.append((max_depth, max_depth, t, gas_list.bottom_gas, False))
318
319 first_stop = stops[0]
320
321
322 mixes = [m for m in gas_list.deco_gas if m.depth > first_stop.depth]
323 depths = [max_depth] + [m.depth for m in mixes] + [first_stop.depth]
324 rd = zip(depths[:-1], depths[1:])
325 mixes.insert(0, gas_list.bottom_gas)
326 legs.extend(
327 (d1, d2, (d1 - d2) / 10, m, False)
328 for (d1, d2), m in zip(rd, mixes)
329 )
330
331
332 depths = [s.depth for s in stops[1:]] + [0]
333 mixes = {
334 (m.depth // 3) * 3: m for m in gas_list.deco_gas
335 if m.depth <= first_stop.depth
336 }
337 cm = legs[-1][3]
338 for s, d in zip(stops, depths):
339 cm = mixes.get(s.depth, cm)
340 legs.append((s.depth, s.depth, s.time, cm, True))
341 t = (s.depth - d) / 10
342 legs.append((s.depth, d, t, cm, True))
343
344 return legs
345
346
348 """
349 Determine the overhead part of a decompression dive.
350
351 The overhead part of a dive is the descent, bottom and ascent parts of
352 a dive up to first decompression stop or first decompression gas mix
353 switch.
354
355 The overhead part of a dive is used to calculate gas mix consumption
356 using rule of thirds.
357
358 :param gas_list: Gas list information.
359 :param legs: List of dive legs.
360
361 ..seealso:: :py:func:`dive_legs`
362 """
363 mix = gas_list.deco_gas[0] if gas_list.deco_gas else None
364 nr = range(len(legs))
365 k = next(k for k in nr if legs[k][3] == mix or legs[k][-1])
366 return legs[:k]
367
368
369 -def dive_slate(profile, stops, legs, descent_rate):
370 """
371 Calculate dive slate for a dive profile.
372
373 The dive decompression stops is a collection of items implementing the
374 following interface
375
376 depth
377 Depth of dive stop [m].
378 time
379 Time of dive stop [min].
380
381 Dive slate is list of items consisting of
382
383 - dive depth
384 - decompression stop information, null if no decompression
385 - run time in minutes
386 - gas mix on gas switch, null otherwise
387
388 :param profile: Dive profile information.
389 :param stops: Dive decompression stops.
390 :param legs: Dive legs.
391 :param descent_rate: Dive descent rate.
392 """
393 slate = []
394
395 depth = profile.depth
396 time = profile.time
397 gas_list = profile.gas_list
398 rt = 0
399
400
401 k = len(gas_list.travel_gas)
402 if k:
403 for i in range(k + 1):
404 leg = legs[i]
405
406 d = leg[0]
407 m = leg[3]
408 slate.append((d, None, round(rt), m))
409 rt += leg[2]
410
411 legs = legs[k:]
412
413
414 rt += legs[0][2] + legs[1][2]
415 d = legs[1][0]
416 m = None if gas_list.travel_gas else legs[1][3]
417 slate.append((d, None, round(rt), m))
418
419
420 no_deco = [l for l in legs if not l[4]]
421 no_deco = no_deco[2:]
422 for i in range(1, len(no_deco)):
423 prev = no_deco[i - 1]
424 leg = no_deco[i]
425
426 d = leg[0]
427 rt += prev[2]
428 m = leg[3]
429 slate.append((d, None, round(rt), m))
430
431
432 deco = [l for l in legs if l[4]]
433 if no_deco:
434 deco.insert(0, no_deco[-1])
435 for i in range(1, len(deco), 2):
436 prev = deco[i - 1]
437 leg = deco[i]
438
439 d = leg[1]
440 dt = leg[2]
441 rt += dt + prev[2]
442 m = None if prev[3] == leg[3] else leg[3]
443 slate.append((d, dt, round(rt), m))
444
445
446 leg = deco[-1]
447 d = leg[1]
448 rt += leg[2]
449 slate.append((d, None, round(rt), None))
450
451 return slate
452
453
455 """
456 Calculate time required to descent or ascent from start to end depth.
457
458 :param start: Starting depth.
459 :param end: Ending depth.
460 :param rate: Ascent or descent rate.
461 """
462 return abs(start - end) / rate
463
464
466 """
467 Calculate total decompression time using dive legs.
468
469 :param legs: List of dive legs.
470
471 ..seealso:: :py:func:`dive_legs`
472 """
473 return sum(l[2] for l in legs if l[-1])
474
475
477 """
478 Calculate total dive time using dive legs.
479
480 :param legs: List of dive legs.
481
482 ..seealso:: :py:func:`dive_legs`
483 """
484 return sum(l[2] for l in legs)
485
486
488 """
489 Analyze gas volume requirements using gas mix volume calculations.
490
491 The list of messages is returned, which confirm required gas mix volume
492 or warn about gas logistics problems.
493
494 :param gas_vol: Gas volume requirements per gas mix.
495 :param min_gas_vol: Minimal gas mixes volume for the plan.
496
497 .. seealso::
498
499 :py:func:`min_gas_volume`
500 :py:func:`gas_volume`
501
502 """
503 info = []
504 for mix, vol in gas_vol.items():
505 assert mix in min_gas_vol
506
507 fmt = '{}Gas mix {} volume {}.'
508 if vol <= min_gas_vol[mix]:
509 msg = fmt.format('', mix.name, 'OK')
510 else:
511 msg = fmt.format('WARN: ', mix.name, 'NOT OK')
512 info.append(msg)
513
514
515 return info
516
517
519 """
520 Calculate dive gas mix volume information.
521
522 Gas mix volume is calculated for each gas mix on the gas list. The
523 volume information is returned as dictionary `gas mix name -> usage`,
524 where gas usage is volume of gas in liters.
525
526 The key of the gas mix volume dictionary is gas mix name to merge all
527 travel and decompression gas mixes information regardless their depth
528 switch.
529
530 FIXME: apply separate RMV for decompression gas
531
532 :param gas_list: Gas list information.
533 :param legs: List of dive legs.
534 :param rmv: Respiratory minute volume (RMV) [min/l].
535
536 ..seealso:: :py:func:`dive_legs`
537 """
538 mixes = gas_list.travel_gas + [gas_list.bottom_gas] + gas_list.deco_gas
539 gas_vol = {m.name: 0 for m in mixes}
540
541 items = (
542 (leg[3].name, ((leg[0] + leg[1]) / 2 / 10 + 1) * leg[2] * rmv)
543 for leg in legs
544 )
545 key = operator.itemgetter(0)
546 items = sorted(items, key=key)
547 items = itertools.groupby(items, key)
548 gas_vol.update({m: sum(v[1] for v in vols) for m, vols in items})
549 return gas_vol
550
551
553 """
554 Calculate minimal volume of gas mixes required for a dive using rule of
555 thirds.
556
557 The volume information is returned as dictionary `gas mix -> usage`,
558 where gas usage is volume of gas in liters.
559
560 :param gas_list: Gas list information.
561 :param legs: List of dive legs.
562 """
563
564 gas_vol = gas_volume(gas_list, legs, rmv=rmv)
565
566
567
568 oh_legs = dive_legs_overhead(gas_list, legs)
569 cons = gas_volume(gas_list, oh_legs, rmv=rmv)
570 gas_vol[gas_list.bottom_gas] = cons[gas_list.bottom_gas.name]
571
572
573 for mix in gas_vol:
574 gas_vol[mix] *= 1.5
575 return gas_vol
576
577
579 """
580 Update gas mix list, so every gas mix has depth specified.
581
582 The following rules are used
583
584 - gas mixes with non-null depth are _not_ changed
585 - first travel gas mix switch depth is 0m
586 - if no travel gas mixes specified, then bottom gas mix switch depth is
587 set to 0m
588 - travel and bottom gas mixes are updated with MOD calculated using
589 ppO2 and O2 value of previous gas mix on the list
590 - decompression gas mixes are updated with MOD calculated using
591 decompression ppO2 and O2 value of changed gas mix
592
593 :param gas_list: Gas mix list to modify.
594 :param ppo2: ppO2 value used to calculate MOD of travel and bottom gas
595 mixes.
596 :param deco_ppo2: ppO2 value used to calculate MOD of decompression gas
597 mixes.
598 """
599 def change_gas_mix(gas_mix, o2, ppo2, condition):
600 if gas_mix.depth is None:
601 v = mod(o2, ppo2) if condition else 0
602 return gas_mix._replace(depth=math.floor(v))
603 else:
604 return gas_mix
605
606 m = gas_list.travel_gas[-1] if gas_list.travel_gas else gas_list.bottom_gas
607 bottom_gas = change_gas_mix(
608 gas_list.bottom_gas, m.o2, ppo2, gas_list.travel_gas
609 )
610 new_list = GasList(bottom_gas)
611
612 if gas_list.travel_gas:
613
614 m = gas_list.travel_gas[0]
615 m = m._replace(depth=0) if m.depth is None else m
616 assert m.depth is not None
617 new_list.travel_gas = [m]
618
619 o2_list = [m.o2 for m in gas_list.travel_gas]
620 new_list.travel_gas.extend(
621 change_gas_mix(m, o2, ppo2, True) for m, o2 in
622 zip(gas_list.travel_gas[1:], o2_list[:-1])
623 )
624
625 new_list.deco_gas = [
626 change_gas_mix(m, m.o2, deco_ppo2, True) for m in gas_list.deco_gas
627 ]
628
629 return new_list
630
631
632 -def plan_to_text(plan):
633 """
634 Convert decompression dive plan to text.
635 """
636 txt = []
637
638
639 txt.append('')
640 t = 'Dive Profile Summary'
641 txt.append(t)
642 txt.append('-' * len(t))
643
644 titles = (
645 'Depth [m]', 'Bottom Time [min]', 'Descent Time [min]',
646 'Total Decompression Time [min]', 'Total Dive Time [min]',
647 'O2 Pressure of Bottom Gas Mix', 'MOD for Bottom Gas Mix'
648 )
649 attrs = 'depth', 'time', 'descent_time', 'deco_time', 'dive_time', \
650 'pp_o2', 'mod'
651 fmts = '{:>6d}', '{:>6d}', '{:>6.1f}', '{:>6.0f}', '{:>6.0f}', \
652 '{:>6.2f}', '{:>6.0f}'
653 assert len(titles) == len(fmts) == len(attrs)
654
655
656 th = '=' * 30 + ' ' + ' '.join(['=' * 6, ] * 4)
657 txt.append(th)
658 txt.append(' {:32}'.format('Name') + 'P LG E E+LG')
659 txt.append(th)
660 for title, attr, fmt in zip(titles, attrs, fmts):
661 t = '{:30s} '.format(title) + ' '.join([fmt] * 4)
662 values = [getattr(p, attr) for p in plan.profiles]
663 txt.append(t.format(*values))
664 txt.append(th)
665 txt.append('')
666
667 txt.append('')
668 t = 'Gas Logistics'
669 txt.append(t)
670 txt.append('-' * len(t))
671
672
673 th = '=' * 30 + ' ' + ' '.join(['=' * 6, ] * 4)
674 txt.append(th)
675 txt.append('{:33s}'.format('Gas Mix') + 'P LG E E+LG')
676 txt.append(th)
677 gas_list = plan.profiles[0].gas_list
678
679 gas_list = gas_list.travel_gas + [gas_list.bottom_gas] + gas_list.deco_gas
680 gas_mix_names = sorted(set(m.name for m in gas_list))
681 for m in gas_mix_names:
682 n = 'Gas Mix {} [liter]'.format(m)
683 vol = [p.gas_vol.get(m, 0) for p in plan.profiles]
684
685 vol[0] = plan.min_gas_vol[m]
686 na = ' xx '
687 s = ('{:6.0f}'.format(v) if v > 0 else na for v in vol)
688 t = '{:30s}'.format(n) + ' ' + ' '.join(s)
689 txt.append(t)
690
691 txt.append(th)
692 txt.append('')
693
694
695
696
697
698
699
700
701
702
703
704 t = 'Dive Slates'
705 txt.append(t)
706 txt.append('-' * len(t))
707 for p in plan.profiles:
708 txt.append('Profile *{}*::'.format(p.type.value))
709 txt.append('')
710 slate = p.slate
711 t = ' {:>3} {:>3} {:>4} {:7}'.format('D', 'DT', 'RT', 'GAS')
712 txt.append(t)
713 txt.append(' ' + '-' * (len(t) - 1))
714 for item in slate:
715 st = int(item[1]) if item[1] else ''
716
717 m = item[3]
718 star = '*' if m else ' '
719 m = m.name if m else ''
720
721 t = ' {}{:>3} {:>3} {:>4} {}'.format(
722 star, int(item[0]), st, int(item[2]), m
723 )
724 txt.append(t)
725 txt.append('')
726
727
728 t = 'Parameters'
729 txt.append(t)
730 txt.append('-' * len(t))
731
732 titles = (
733 'RMV [l/min]', 'Last stop at 6m', 'GF Low', 'GF High',
734
735 )
736 attrs = ('rmv', 'last_stop_6m', 'gf_low', 'gf_high')
737 fmts = ('{:>6}', ' {}', '{:>6}%', '{:>6}%')
738 assert len(titles) == len(fmts) == len(attrs)
739
740 th = '=' * 30 + ' ' + '=' * 7
741 txt.append(th)
742 txt.append('Parameter' + ' ' * 23 + 'Value')
743 txt.append(th)
744 for title, attr, fmt in zip(titles, attrs, fmts):
745 t = '{:30s} '.format(title) + fmt
746 txt.append(t.format(getattr(plan, attr)))
747 txt.append(th)
748 txt.append('')
749
750
751 return '\n'.join(txt)
752
753
755 """
756 Parse gas mix.
757
758 :param t: Gas mix string.
759 :param travel: True if travel gas mix.
760 """
761 t = t.upper()
762 v = RE_GAS.search(t)
763 m = None
764
765 if v:
766 n = v.group('name')
767
768 p = v.group('o2')
769 if p is None:
770 if n == 'AIR':
771 o2 = 21
772 elif n == 'O2':
773 o2 = 100
774 else:
775 return None
776 else:
777 o2 = int(p)
778
779 p = v.group('he')
780 he = 0 if p is None else int(p)
781
782 p = v.group('depth')
783 depth = None if p is None else int(p)
784
785 m = gas(o2, he, depth=depth)
786
787 return m
788
789
791 """
792 Parse gas mix list.
793
794 :param *args: List of gas mix strings.
795 """
796 travel_gas = [parse_gas(a[1:], True) for a in args if a[0] == '+']
797 deco_gas = [parse_gas(a) for a in args if a[0] != '+']
798 bottom_gas = deco_gas[0]
799 del deco_gas[0]
800
801 gl = GasList(bottom_gas)
802 gl.travel_gas.extend(travel_gas)
803 gl.deco_gas.extend(deco_gas)
804 return gl
805
806
807
808