#!/usr/bin/env python
# -*- coding: utf-8 -*-
#
# Copyright 2011-2012 Thomas Chiroux
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.
# If not, see <http://www.gnu.org/licenses/gpl.html>
#
# This module is part of dipplanner, a Dive planning Tool written in python
"""
Contains a Tank Class
.. note:: in MVPlan, this class was the 'Gas' class
"""
__authors__ = [
# alphabetical order by last name
'Thomas Chiroux', ]
import logging
import math
import re
# local imports
from dipplanner import settings
from dipplanner.dipp_exception import DipplannerException
from dipplanner.tools import pressure_to_depth, depth_to_pressure
[docs]class InvalidGas(DipplannerException):
"""Exception raised when the gas informations provided for the Tank
are invalid
"""
[docs] def __init__(self, description):
"""constructor : call the upper constructor and set the logger
*Keyword Arguments:*
:description: (str) -- text describing the error
*Return:*
<nothing>
*Raise:*
<nothing>
"""
DipplannerException.__init__(self, description)
self.logger = logging.getLogger(
"dipplanner.DipplannerException.InvalidGas")
self.logger.error(
"Raising an exception: InvalidGas ! (%s)" % description)
[docs]class InvalidTank(DipplannerException):
"""Exception raised when the tank infos provided are invalid
"""
[docs] def __init__(self, description):
"""constructor : call the upper constructor and set the logger
*Keyword Arguments:*
:description: (str) -- text describing the error
*Return:*
<nothing>
*Raise:*
<nothing>
"""
DipplannerException.__init__(self, description)
self.logger = logging.getLogger(
"dipplanner.DipplannerException.InvalidTank")
self.logger.error(
"Raising an exception: InvalidTank ! (%s)" % description)
[docs]class InvalidMod(DipplannerException):
"""Exception raised when the given MOD is incompatible with the gas
provided for the tank
"""
[docs] def __init__(self, description):
"""constructor : call the upper constructor and set the logger
*Keyword Arguments:*
:description: (str) -- text describing the error
*Return:*
<nothing>
*Raise:*
<nothing>
"""
DipplannerException.__init__(self, description)
self.logger = logging.getLogger(
"dipplanner.DipplannerException.InvalidMod")
self.logger.error(
"Raising an exception: InvalidMod ! (%s)" % description)
[docs]class EmptyTank(DipplannerException):
"""Exception raised when trying to consume more gas in tank than the
remaining gas
"""
[docs] def __init__(self, description):
"""constructor : call the upper constructor and set the logger
*Keyword Arguments:*
:description: (str) -- text describing the error
*Return:*
<nothing>
*Raise:*
<nothing>
"""
DipplannerException.__init__(self, description)
self.logger = logging.getLogger(
"dipplanner.DipplannerException.EmptyTank")
self.logger.error(
"Raising an exception: EmptyTank ! (%s)" % description)
[docs]class Tank(object):
"""This class implements a representation of dive tanks wich
contains breathing Gas
We provide proportion of N2, O2, He, calculates MOD and volumes during the
dives
We can also (optionally) provide the type of tanks :
- volume
- pressure
- remaining gas warning rule
.. note:: About imperial unit conversion
In 'imperial' countries (North America), it's common to describe
a tank with the volume of air stored in the cylinder at its working
pressure (if you where to release it at the surface),
instead of (internal volume * pressure).
This make difficult if not impossible to switch between the two units
without any approximation or implementation choices.
eg: 80-cubic-foot aluminium cylinder (AL80)
TODO: continuer le texte explicatif
*Attributes:*
* f_o2 (float) -- fraction of oxygen in the gas in % (>= 0.0 & <= 1.0)
* f_he (float) -- fraction of helium in the gas in % (>= 0.0 & <= 1.0)
* f_n2 (float) -- fraction of nitrogen in the gas in %
(>= 0.0 & <= 1.0)
* max_ppo2 (float) -- maximum tolerated ppo2 for this tank
* tank_vol (float) -- volume of tank in liter
* tank_pressure (float) -- pressure of tank in bar
* mod (float) -- maximum operating depth of the tank
* in_use (boolean) -- is the tank used for the dive of not
* total_gas (float) -- total gas volume of the tank in liter
* used_gas (float) -- used gas in liter
* remaining_gas (float) -- remaining gas in liter
* min_gas (float) -- minimum remaining gas in liter
"""
[docs] def __init__(self, f_o2=0.21, f_he=0.0,
max_ppo2=settings.DEFAULT_MAX_PPO2,
mod=None, tank_vol=12.0, tank_pressure=200,
tank_rule="30b"):
"""Constructor for Tank class
If nothing is provided, create a default 'Air' with 12l/200b tank
and max_ppo2 to 1.6 (used to calculate mod)
if mod not provided, mod is calculed based on max tolerable ppo2
*Keyword arguments:*
:f_o2: (float) -- Fraction of O2 in the gaz in %
value between 0.0 and 1.0
:f_he: (float) -- Fraction of He in the gaz in %
value between 0.0 and 1.0
:max_ppo2: (float) -- sets the maximum ppo2 you want for this tank
(default: settings.DEFAULT_MAX_PPO2)
:mod: (float) -- Specify the mod you want.
* if not provided, calculates the mod based on max_ppo2
* if provided and not compatible
with max_ppo2: raise InvalidMod
:tank_vol: (float) -- Volume of the tank in liter
:tank_pressure: (float) -- Pressure of the tank, in bar
:tank_rule: (float) -- rule for warning in the tank consumption
must be either :
* xxxb (ex: 50b means 50 bar minimum at
the end of the dive)
* 1/x
* ex : 1/3 for rule of thirds:
* 1/3 for way in,
* 1/3 for way out,
* 1/3 remains at the end of the dive)
* ex2: 1/6 rule:
* 1/6 way IN,
* 1/6 wau OUT,
* 2/3 remains
*Returns:*
<nothing>
*Raise:*
* InvalidGas -- see validate()
* InvalidMod -- if mod > max mod based on
max_ppo2 and see validate()
* InvalidTank -- see validate()
"""
#initiate class logger
self.logger = logging.getLogger("dipplanner.tank.Tank")
self.logger.debug("creating an instance of Tank: O2:%f, He:%f, "
"max_ppo2:%f, mod:%s, tank_vol:%f, "
"tank_pressure:%d" % (f_o2, f_he, max_ppo2,
mod, tank_vol,
tank_pressure))
self.f_o2 = float(f_o2)
self.f_he = float(f_he)
self.f_n2 = 1.0 - (self.f_o2 + self.f_he)
self.max_ppo2 = float(max_ppo2)
self.tank_vol = float(tank_vol)
self.tank_pressure = float(tank_pressure)
if mod is not None:
if mod > self._calculate_mod(self.max_ppo2):
raise InvalidMod(
"The mod exceed maximum MOD based on given max ppo2")
self.mod = float(mod)
else:
self.mod = self._calculate_mod(self.max_ppo2)
self.in_use = True
self._validate()
self.used_gas = 0.0
if self.tank_vol and self.tank_pressure:
self.total_gas = self.calculate_real_volume()
else:
self.total_gas = 0.0
self.remaining_gas = self.total_gas
# calculate minimum remaining gas
min_re = re.search("([0-9]+)b", tank_rule)
if min_re is not None:
self.min_gas = self.calculate_real_volume(self.tank_vol,
int(min_re.group(1)))
else:
min_re = re.search("1/([0-9])", tank_rule)
if min_re is not None:
self.min_gas = self.total_gas * \
(float(1) - 2 * (1 / float(min_re.group(1))))
else:
self.min_gas = 0
self.logger.debug("minimum gas authorised: %s" % self.min_gas)
[docs] def __deepcopy__(self, memo):
"""deepcopy method will be called by copy.deepcopy
Used for "cloning" the object into another new object.
*Keyword Arguments:*
:memo: -- not used here
*Returns:*
Tank -- Tank object copy of itself
*Raise:*
<nothing>
"""
newobj = Tank()
newobj.f_o2 = self.f_o2
newobj.f_he = self.f_he
newobj.f_n2 = self.f_n2
newobj.max_ppo2 = self.max_ppo2
newobj.tank_vol = self.tank_vol
newobj.tank_pressure = self.tank_pressure
newobj.mod = self.mod
newobj.in_use = self.in_use
newobj.used_gas = self.used_gas
newobj.total_gas = self.total_gas
newobj.remaining_gas = self.remaining_gas
newobj.min_gas = self.min_gas
return newobj
[docs] def calculate_real_volume(self, tank_vol=None, tank_pressure=None,
f_o2=None, f_he=None, temp=15):
"""
Calculate the real gas volume of the tank (in liter) based
on Van der waals equation:
(P+n2.a/V2).(V-n.b)=n.R.T
*Keyword arguments:*
:tank_vol: (float) -- Volume of the tank in liter
optional : if not provided, use self.tank_vol
:tank_pressure: (float) -- Pressure of the tank in bar
optional : if not provided, use self.tank_pressure
:f_o2: (float) -- fraction of O2 in the gas
optional : if not provided, use self.f_o2
:f_he: (float) -- fraction of He in the gas
optional : if not provided, use self.f_he
*Returns:*
float -- total gas volume of the tank in liter
*Raise:*
<nothing>
"""
# handle parameters
if tank_vol is None:
tank_vol = self.tank_vol
if tank_pressure is None:
tank_pressure = self.tank_pressure
if f_o2 is None:
f_o2 = self.f_o2
if f_he is None:
f_he = self.f_he
f_n2 = 1.0 - (f_o2 + f_he)
# Constants used in calculations
a_o2 = 1.382
b_o2 = 0.03186
a_n2 = 1.37
b_n2 = 0.0387
a_he = 0.0346
b_he = 0.0238
#vm_o2 = 31.9988 # not used
#vm_n2 = 28.01348 # not used
#vm_he = 4.0020602 # not used
R = 0.0831451
T = 273.15 + temp # default temp at 15°C
# at first, calculate a and b values for this gas
a_gas = math.sqrt(a_o2 * a_o2) * f_o2 * f_o2 +\
math.sqrt(a_o2 * a_he) * f_o2 * f_he +\
math.sqrt(a_o2 * a_n2) * f_o2 * f_n2 +\
math.sqrt(a_he * a_o2) * f_he * f_o2 +\
math.sqrt(a_he * a_he) * f_he * f_he +\
math.sqrt(a_he * a_n2) * f_he * f_n2 +\
math.sqrt(a_n2 * a_o2) * f_n2 * f_o2 +\
math.sqrt(a_n2 * a_he) * f_n2 * f_he +\
math.sqrt(a_n2 * a_n2) * f_n2 * f_n2
#print "a: %s" % a_gas
b_gas = math.sqrt(b_o2 * b_o2) * f_o2 * f_o2 +\
math.sqrt(b_o2 * b_he) * f_o2 * f_he +\
math.sqrt(b_o2 * b_n2) * f_o2 * f_n2 +\
math.sqrt(b_he * b_o2) * f_he * f_o2 +\
math.sqrt(b_he * b_he) * f_he * f_he +\
math.sqrt(b_he * b_n2) * f_he * f_n2 +\
math.sqrt(b_n2 * b_o2) * f_n2 * f_o2 +\
math.sqrt(b_n2 * b_he) * f_n2 * f_he +\
math.sqrt(b_n2 * b_n2) * f_n2 * f_n2
#print "b: %s" % b_gas
# now approximate n (quantities of molecules of gas in the tank in mol)
# using perfect gas law : PV = nRT : n = PV/RT
approx_n = (float(tank_pressure) * float(tank_vol)) / (R * T)
# recalculate pressure on the tank whith approx_n
# P=n.R.T/(V-n.b)-n2.a/V2)
tank_pressure_mid = (approx_n * R * T) / (tank_vol - approx_n * b_gas)\
- (approx_n * approx_n * a_gas)\
/ (tank_vol * tank_vol)
# now try to approx tank_pressure with new_tank_pressure by
# variating approx_n
# start with *2 or /2 value (which is enormous !)
if tank_pressure_mid < tank_pressure:
n_left = approx_n
n_right = approx_n * 2
else:
n_left = approx_n / 2
n_right = approx_n
n_mid = (n_left + n_right) / 2
while round(tank_pressure_mid, 2) != round(tank_pressure, 2):
n_mid = (n_left + n_right) / 2
# new pressure calculated using:
# P = nRT/(V - nb) - n2a/V2
tank_pressure_mid = (n_mid * R * T) / (tank_vol - n_mid * b_gas) -\
(n_mid * n_mid * a_gas) / (tank_vol * tank_vol)
if tank_pressure_mid > tank_pressure:
# keep left
n_right = n_mid
else:
n_left = n_mid
#print "n_mid:%s" % n_mid
# recalculate volume using van der waals again
# V = nR3T3/(PR2T2+aP2) + nb
total_gas_volume = n_mid * pow(R, 3) * pow(T, 3) / \
(settings.AMBIANT_PRESSURE_SURFACE * pow(R, 2) * pow(T, 2) +
a_gas * pow(settings.AMBIANT_PRESSURE_SURFACE, 2)) + \
n_mid * b_gas
self.logger.debug("real total gas volume : %02fl instead of %02fl" %
(total_gas_volume, tank_vol * tank_pressure))
return total_gas_volume
def __repr__(self):
"""Returns a string representing the actual tank
*Keyword arguments:*
<none>
*Returns:*
str -- representation of the tank in the form:
"Air - 12.0l-100.0% (2423.10/2423.10l)"
*Raise:*
<nothing>
"""
return "%s - %s" % (self.name(), self.get_tank_info())
[docs] def __str__(self):
"""Return a human readable name of the tank
*Keyword arguments:*
<none>
*Returns:*
str -- name of the tank in the form:
"Air"
"Nitrox 80"
...
*Raise:*
<nothing>
"""
return "%s" % self.name()
[docs] def __unicode__(self):
"""Return a human readable name of the tank in unicode
*Keyword arguments:*
<none>
*Returns:*
str -- name of the tank in the form:
"Air"
"Nitrox 80"
...
*Raise:*
<nothing>
"""
return u"%s" % self.name()
[docs] def __cmp__(self, othertank):
"""Compare a tank to another tank, based on MOD
*Keyword arguments:*
othertank (Tank) -- another tank object
*Returns:*
integer -- result of cmp()
*Raise:*
<nothing>
"""
return cmp(self.mod, othertank.mod)
[docs] def _calculate_mod(self, max_ppo2):
"""calculate and returns mod for a given ppo2 based on this tank info
result in meter
*Keyword arguments:*
:max_ppo2: -- maximum ppo2 accepted (float).
Any value accepted, but should be > 0.0
*Returns:*
integer -- Maximum Operating Depth in meter
*Raise:*
<nothing>
"""
return max(int(10 * (float(max_ppo2) / self.f_o2) - 10), 0)
[docs] def _validate(self):
"""Test the validity of the tank informations inside this object
if validity check fails raise an Exception 'InvalidTank'
*Keyword arguments:*
<nothing>
*Returns:*
<nothing>
*Raise:*
* InvalidGas -- When proportions of gas exceed
100% for example (or negatives values)
* InvalidMod -- if mod > max mod based on max_ppo2
or ABSOLUTE_MAX_MOD.
ABSOLUTE_MAX_MOD is a global settings which
can not be exceeded.
* InvalidTank -- when pressure or tank size exceed maximum
values or are incorrect (like negatives) values
"""
if self.f_o2 + self.f_he > 1:
raise InvalidGas("Proportion of O2+He is more than 100%")
if self.f_o2 < 0 or self.f_he < 0 or self.f_n2 < 0:
raise InvalidGas("Proportion of gas should not be < 0")
if self.mod <= 0:
raise InvalidMod("MOD should be >= 0")
if (self.mod > self._calculate_mod(self.max_ppo2) or
self.mod > self._calculate_mod(settings.ABSOLUTE_MAX_PPO2)):
raise InvalidMod("MOD exceed maximum tolerable MOD")
if self.tank_pressure > settings.ABSOLUTE_MAX_TANK_PRESSURE:
raise InvalidTank(
"Tank pressure exceed maximum tolerable pressure")
if self.tank_pressure <= 0:
raise InvalidTank("Tank pressure should be greated than zero")
if self.tank_vol > settings.ABSOLUTE_MAX_TANK_SIZE:
raise InvalidTank("Tank size exceed maximum tolerable tank size")
if self.tank_vol <= 0:
raise InvalidTank("Tank size should be greater than zero")
[docs] def name(self):
"""returns a Human readable name for the gaz and tanks
Differnt possibilities:
Air, Nitrox, Oxygen, Trimix, Heliox
*Keyword arguments:*
<none>
*Returns:*
str -- name of the tank in the form:
"Air"
"Nitrox"
...
*Raise:*
<nothing>
"""
name = 'Air'
composition = ''
if self.f_he == 0:
composition = '%s' % int(self.f_o2 * 100)
if self.f_o2 == 0.21:
name = 'Air'
elif self.f_o2 == 1:
name = 'Oxygen'
else:
name = 'Nitrox ' + composition
else:
composition = '%s/%s' % (int(self.f_o2 * 100),
int(self.f_he * 100))
if self.f_he + self.f_o2 == 1:
name = 'Heliox ' + composition
else:
name = 'Trimix ' + composition
return name
[docs] def get_tank_info(self):
"""returns tank infos : size, remaining vol
example of tank info:
15l-90% (2800/3000l)
*Keyword arguments:*
<none>
*Returns:*
str -- infos of the tank in the form:
"12.0l-100.0% (2423.10/2423.10l)"
...
*Raise:*
<nothing>
"""
if self.total_gas > 0:
return "%sl-%s%% (%02.02f/%02.02fl)" % (
self.tank_vol,
round(100 * self.remaining_gas / self.total_gas, 1),
self.remaining_gas,
self.total_gas)
else:
return "(no tank info, used:%sl)" % self.used_gas
[docs] def get_mod(self, max_ppo2=None):
"""return mod (maximum operating depth) in meter
if no argument provided, return the mod based on the current tank (and
configured max_ppo2)
if max_ppo2 is provided, returns the (new) mod based on the given ppo2
*Keyword arguments:*
:max_ppo2: (float) -- ppo2 for mod calculation
*Returns:*
float -- mod in meter
*Raise:*
<nothing>
"""
if not max_ppo2:
return self.mod
else:
return self._calculate_mod(max_ppo2)
[docs] def get_min_od(self, min_ppo2=settings.ABSOLUTE_MIN_PPO2):
"""return in meter the minimum operating depth for the gas in the tank
return 0 if diving from/to surface is ok with this gaz
*Keyword arguments:*
:min_ppo2: (float) -- minimum tolerated ppo2
*Returns:*
float -- minimum operating depthin meter
*Raise:*
<nothing>
"""
return self._calculate_mod(min_ppo2)
[docs] def get_mod_for_given_end(self, end):
"""calculate a mod based on given end and based on gaz inside the tank
.. note::
end calculation is based on narcotic index for all gases.
By default, dipplanner considers that oxygen is narcotic
(same narcotic index than nitrogen)
All narcotic indexes can by changed in the config file,
in the [advanced] section
*Keyword arguments:*
:end: (int) -- equivalent narcotic depth in meter
*Returns:*
int -- mod: depth in meter based on given end
*Raise:*
<nothing>
"""
# calculate the reference narcotic effect of air
# Air consists of: Nitrogen N2: 78.08%,
# Oxygen O2: 20.95%,
# Argon Ar: 0.934%
#OC
reference_narcotic = settings.AMBIANT_PRESSURE_SURFACE * \
(settings.N2_NARCOTIC_VALUE * 0.7808 +
settings.O2_NARCOTIC_VALUE * 0.2095 +
settings.AR_NARCOTIC_VALUE * 0.00934)
#OC mode
narcotic_tank = (self.f_n2 * settings.N2_NARCOTIC_VALUE +
self.f_o2 * settings.O2_NARCOTIC_VALUE +
self.f_he * settings.HE_NARCOTIC_VALUE)
p_absolute = (depth_to_pressure(end) +
settings.AMBIANT_PRESSURE_SURFACE) * \
reference_narcotic / narcotic_tank
mod = pressure_to_depth(p_absolute - settings.AMBIANT_PRESSURE_SURFACE)
return mod
[docs] def get_end_for_given_depth(self, depth):
"""calculate end (equivalent narcotic depth)
based on given depth and based on gaz inside the tank
.. note::
end calculation is based on narcotic index for all gases.
By default, dipplanner considers that oxygen is narcotic
(same narcotic index than nitrogen)
All narcotic indexes can by changed in the config file,
in the [advanced] section
*Keyword arguments:*
depth -- int -- in meter
*Returns:*
end -- int -- equivalent narcotic depth in meter
*Raise:*
<nothing>
"""
p_absolute = depth_to_pressure(depth) + \
settings.AMBIANT_PRESSURE_SURFACE
# calculate the reference narcotic effect of air
# Air consists of: Nitrogen N2: 78.08%,
# Oxygen O2: 20.95%,
# Argon Ar: 0.934%
reference_narcotic = settings.AMBIANT_PRESSURE_SURFACE * \
(settings.N2_NARCOTIC_VALUE * 0.7808 +
settings.O2_NARCOTIC_VALUE * 0.2095 +
settings.AR_NARCOTIC_VALUE * 0.00934)
#OC mode
narcotic_index = p_absolute * (self.f_n2 * settings.N2_NARCOTIC_VALUE +
self.f_o2 * settings.O2_NARCOTIC_VALUE +
self.f_he * settings.HE_NARCOTIC_VALUE)
end = pressure_to_depth(narcotic_index / reference_narcotic -
settings.AMBIANT_PRESSURE_SURFACE)
if end < 0:
end = 0
return end
[docs] def consume_gas(self, gas_consumed):
"""Consume gas inside this tank
*Keyword arguments:*
:gas_consumed: (float) -- gas consumed in liter
*Returns:*
float -- remaining gas in liter
*Raise:*
<nothing>
"""
#if self.remaining_gas - gas_consumed < 0:
#raise EmptyTank("There is not enought gas in this tank")
#else:
self.used_gas += gas_consumed
self.remaining_gas -= gas_consumed
return self.remaining_gas
[docs] def refill(self):
"""Refill the tank
*Keyword arguments:*
<none>
*Returns:*
float -- remaining gas in liter
*Raise:*
<nothing>
"""
self.used_gas = 0
self.remaining_gas = self.total_gas
return self.remaining_gas
[docs] def check_rule(self):
"""Checks the rule agains the remaining gas in the tank
*Keyword arguments:*
:gas_consumed: (float) -- gas consumed in liter
*Returns:*
bool -- True is rule OK
False if rule Not OK
*Raise:*
<nothing>
"""
if self.remaining_gas < self.min_gas:
return False
else:
return True