A training to acquire strong basis in Python to use it efficiently
Pierre Augier (LEGI), Cyrille Bonamy (LEGI), Eric Maldonado (Irstea), Franck Thollard (ISTERRE), Christophe Picard (LJK), Loïc Huder (ISTerre)
Python is also an object-oriented language. Object-oriented programming is very useful and used in many libraries so it is very useful to understand how the simple object-oriented mechanisms work in Python.
For some problems, Object-oriented programming is a very efficient paradigm. Many libraries use it so it is worth understanding what is object oriented programming (POO) and when it is useful.
An object is an entity that has a state and a behavior. Objects are the basic elements of object-oriented system.
Classes are "families" of objects. A class is a pattern that describes how objects will be built.
Example: the weather stations
Let us suppose we have a set of weather stations that do measurements of wind speed and temperature. Suppose now one wants to compute some statistics on these data. A basic representation of a station will be an array of arrays: wind values and temperature values.
paris = [[10, 0, 20, 30, 20, 0], [1, 5, 1, -1, -1, 3]]
# get wind when temperature is maximal
idx_max_temp = paris[1].index(max(paris[1]))
print(f"max temp is of {paris[1][idx_max_temp]}°C at index {idx_max_temp} ")
print(f"wind speed at max temp = {paris[0][idx_max_temp]} km/h")
max temp is of 5°C at index 1 wind speed at max temp = 0 km/h
Comments on this solution
Many problems:
A possible solution: create a box
We can use a dictionnary:
paris = {"wind": [10, 0, 20, 30, 20, 0], "temperature": [1, 5, 1, -1, -1, 3]}
# get wind when temperature is minimal
paris_temp = paris["temperature"]
idx_max_temp = paris_temp.index(max(paris_temp))
print(f"max temp is {paris_temp[idx_max_temp]}°C at index {idx_max_temp}")
print(f"wind speed at max temp = {paris['wind'][idx_max_temp]} km/h")
max temp is 5°C at index 1 wind speed at max temp = 0 km/h
Comments
Pro
Con
Improvement
Add functions
paris = {"wind": [10, 0, 20, 30, 20, 0], "temperature": [1, 5, 1, -1, -1, 3]}
def max_temp(station):
""" returns the maximum temperature available in the station"""
return max(station["temperature"])
def arg_max_temp(station):
""" returns the index of maximum temperature available in the station"""
max_temperature = max_temp(station)
return station["temperature"].index(max_temperature)
idx_max_temp = arg_max_temp(paris)
print(f"max temp is {max_temp(paris)}°C at index {arg_max_temp(paris)}")
print(f"wind speed at max temp = {paris['wind'][idx_max_temp]} km/h")
max temp is 5°C at index 1 wind speed at max temp = 0 km/h
import pytest
import ipytest
ipytest.autoconfig()
%%ipytest
def test_max_temp():
paris = {
"wind": [10, 0, 20, 30, 20, 0],
"temperature": [1, 5, 1, -1, -1, 3],
}
assert 5 == max_temp(paris)
def test_arg_max_temp():
paris = {
"wind": [10, 0, 20, 30, 20, 0],
"temperature": [1, 5, 1, -1, -1, 3],
}
assert 1 == arg_max_temp(paris)
.. [100%] 2 passed in 0.01s
Comments
Improvement
Define a function that builds the station (delegate the generation of the station dictionnary to a function)
def build_station(wind, temp):
""" Build a station given wind and temp
:param wind: (list) floats of winds
:param temp: (list) float of temperatures
"""
if len(wind) != len(temp):
raise ValueError("wind and temperature should have the same size")
return {"wind": list(wind), "temperature": list(temp)}
def max_temp(station):
""" returns the maximum temperature available in the station"""
return max(station["temperature"])
def arg_max_temp(station):
""" returns the index of maximum temperature available in the station"""
max_temperature = max_temp(station)
return station["temperature"].index(max_temperature)
paris = build_station([10, 0, 20, 30, 20, 0], [1, 5, 1, -1, -1, 3])
idx_max_temp = arg_max_temp(paris)
print(f"max temp is {max_temp(paris)}°C at index {arg_max_temp(paris)}")
print(f"wind speed at max temp = {paris['wind'][idx_max_temp]} km/h")
max temp is 5°C at index 1 wind speed at max temp = 0 km/h
%%ipytest
# testing
def test_build_station_with_iterable():
""" Tests that the station can be generated from iterables """
station = build_station(range(10), range(10))
assert station["wind"][0] == 0
assert station["wind"][-1] == 9
assert station["temperature"][0] == 0
assert station["temperature"][-1] == 9
def test_wrong_build():
""" Tests that the station generation throws an error
if wind and temperature do not have the same size"""
try:
bad = build_station(range(10), range(4))
except ValueError:
assert True
def test_max_temp():
""" test max_temp computes conrrectly"""
paris = build_station([10, 0, 20, 30, 20, 0], [1, 5, 1, -1, -1, 3])
assert 5 == max_temp(paris)
def test_arg_max_temp():
""" test arg_max_temp computs correctly"""
paris = build_station([10, 0, 20, 30, 20, 0], [1, 5, 1, -1, -1, 3])
assert 1 == arg_max_temp(paris)
.... [100%] 4 passed in 0.01s
Comments
build_station
is used, the returned dictionary is well structured.build_station
, only max_temp
and arg_max_temp
have to be changed accordinglyA class defines a template used for building object.
In our example, the class (named WeatherStation
) defines the specifications of what is a weather station (i.e, a weather station should contain an array for wind speeds, named "wind", and an array for temperatures, named "temp").
paris
should now be an object that answers to these specifications. Is is called an instance of the class WeatherStation
.
When defining the class, we need to define how to build it (special "function" __init__
).
class WeatherStation(object):
""" A weather station that holds wind and temperature
:param wind: any ordered iterable
:param temperature: any ordered iterable
wind and temperature must have the same length.
"""
def __init__(self, wind, temperature):
self.wind = list(wind)
self.temp = list(temperature)
if len(self.wind) != len(self.temp):
raise ValueError(
"wind and temperature should have the same size"
)
def max_temp(self):
""" returns the maximum temperature recorded in the station"""
return max(self.temp)
def arg_max_temp(self):
""" returns the index of (one of the) maximum temperature recorded in the station"""
return self.temp.index(self.max_temp())
paris = WeatherStation([10, 0, 20, 30, 20, 0], [1, 5, 1, -1, -1, 3])
# OR paris = WeatherStation(wind=[10, 0, 20, 30, 20, 0], temperature=[1, 5, 1, -1, -1, 3])
idx_max_temp = paris.arg_max_temp()
print(f"max temp is {paris.max_temp()}°C at index {paris.arg_max_temp()}")
print(f"wind speed at max temp = {paris.wind[idx_max_temp]} km/h")
max temp is 5°C at index 1 wind speed at max temp = 0 km/h
Comments
The max_temp
and the arg_max_temp
are now part of the class WeatherStation
. Functions attached to classes are named methods.
Similary, wind
and temp
lists are also now part this class. Variables attached to classes are named members or attributes.
An object (here paris
) thus contains both attributes (holding data for example) and methods to access and/or process the data.
Testing
%%ipytest
def test_building_with_good_input_arrays():
""" test that things goes smoothly if the input are correct"""
paris = WeatherStation([10, 0, 20, 30, 20, 0], [1, 5, 1, -1, -1, 3])
assert 0 == paris.wind[1]
assert 5 == paris.temp[1]
def test_building_with_input_iterables():
""" test that things goes smoothly if the input are correct"""
r_station = WeatherStation(range(10), range(10))
assert 4 == r_station.wind[4]
assert 5 == r_station.temp[5]
def test_building_with_bad_arrays():
""" test that an exception is raised with incorrect inputs"""
try:
bad_station = WeatherStation([10, 0, 20, 30, 20, 0], [1, 5, 1])
except ValueError:
assert True
def test_max_temp():
""" test test_max_temp function"""
#paris = WeatherStation([10, 0, 20, 30, 20, 0], [1, 5, 1, -1, -1, 3])
assert 5 == paris.max_temp()
def test_arg_max_temp():
""" test arg_max_temp function"""
paris = WeatherStation([10, 0, 20, 30, 20, 0], [1, 5, 1, -1, -1, 3])
assert 1 == paris.arg_max_temp()
..... [100%] 5 passed in 0.01s
perceived_temp
) that takes as input a temperature and wind and return the perceived temperature, i.e. taking into account the wind chill effect.max_temp
and arg_max_temp
so that they take an additional optional boolean parameter (e.g. perceived default to False). If perceived
is False, the methods have the same behaviour as before. If perceived is True, the temperatures to process are the perceived temperatures.perceived_temperatures
) that returns an array containing all the perceived temperatures.perceived_temperatures
so that all tests are passing.class WeatherStation(object):
""" A weather station that holds wind and temperature """
def __init__(self, wind, temperature):
""" initialize the weather station.
Precondition: wind and temperature must have the same length
:param wind: any ordered iterable
:param temperature: any ordered iterable"""
self.wind = list(wind)
self.temp = list(temperature)
if len(self.wind) != len(self.temp):
raise ValueError(
"wind and temperature should have the same size"
)
def perceived_temp(self, index):
""" computes the perceived temp according to
https://en.wikipedia.org/wiki/Wind_chill
i.e. The standard Wind Chill formula for Environment Canada is:
apparent = 13.12 + 0.6215*air_temp - 11.37*wind_speed^0.16 + 0.3965*air_temp*wind_speed^0.16
:param index: the index for which the computation must be made
:return: the perceived temperature"""
air_temp = self.temp[index]
wind_speed = self.wind[index]
# Perceived temperature does not have a sense without wind...
if wind_speed == 0:
apparent_temp = air_temp
else:
apparent_temp = 13.12 + 0.6215*air_temp \
- 11.37*wind_speed**0.16 \
+ 0.3965*air_temp*wind_speed**0.16
# Let's round to avoid trailing decimals...
return round(apparent_temp, 2)
def perceived_temperatures(self):
""" Returns an array of percieved temp computed from the temperatures and wind speed data """
apparent_temps = []
for index in range(len(self.wind)):
# Reusing the method perceived_temp defined above
apparent_temperature = self.perceived_temp(index)
apparent_temps.append(apparent_temperature)
return apparent_temps
def max_temp(self, perceived=False):
""" returns the maximum temperature record in the station"""
if perceived:
apparent_temp = self.perceived_temperatures()
return max(apparent_temp)
else:
return max(self.temp)
def arg_max_temp(self, perceived=False):
""" returns the index of (one of the) maximum temperature record in the station"""
if perceived:
temp_array_to_search = self.perceived_temperatures()
else:
temp_array_to_search = self.temp
return temp_array_to_search.index(self.max_temp(perceived))
%%ipytest
def test_building_with_good_input_arrays():
""" test things goes smoothly if input are correct"""
paris = WeatherStation([10, 50, 20, 30, 20, 0],[1, 5, 1, -1, -1, 3])
assert 50 == paris.wind[1]
assert 5 == paris.temp[1]
def test_building_with_input_iterables():
""" test things goes smoothly if input are correct"""
r_station = WeatherStation(range(10), range(10))
assert 4 == r_station.wind[4]
assert 5 == r_station.temp[5]
def test_building_with_bad_arrays():
""" test exception is raised with bad entry"""
try:
bad_station = WeatherStation([10, 50, 20, 30, 20, 0], [1, 5, 1])
except ValueError:
assert True
def test_perceived_with_null_wind():
""" test that the perceived temp is the same without wind"""
paris = WeatherStation([10, 50, 20, 30, 20, 0],[1, 5, 1, -1, -1, 3])
index = 5
assert paris.wind[index] == 0
assert paris.temp[index] == paris.perceived_temp(index)
def test_perceived():
""" test that the perceived temp is lower than the regular temp with wind """
index = 2
paris = WeatherStation([10, 50, 20, 30, 20, 0],[1, 5, 1, -1, -1, 3])
assert paris.wind[index] > 0
assert paris.perceived_temp(index) < paris.temp[index]
def test_perceived_temperatures():
""" test that the perceived temperatures generation works"""
paris = WeatherStation([10, 50, 20, 30, 20, 0],[1, 5, 1, -1, -1, 3])
apparent_temps = paris.perceived_temperatures()
assert isinstance(apparent_temps, list)
assert len(apparent_temps) == len(paris.temp)
for i in range(0, len(apparent_temps)):
assert apparent_temps[i] <= paris.temp[i]
def test_max_temp_no_perceived():
""" test max temp function """
paris = WeatherStation([10, 50, 20, 30, 20, 0],[1, 5, 1, -1, -1, 3])
assert 5 == paris.max_temp()
def test_arg_max_temp_no_perceived():
""" test arg_max_temp function"""
paris = WeatherStation([10, 50, 20, 30, 20, 0],[1, 5, 1, -1, -1, 3])
assert 1 == paris.arg_max_temp()
def test_max_temp_perceived():
""" test max temp function"""
paris = WeatherStation([10, 50, 20, 30, 20, 0],[1, 5, 1, -1, -1, 3])
assert 3 == paris.max_temp(perceived=True)
def test_arg_max_temp_no_perceived():
""" test arg_max_temp function"""
paris = WeatherStation([10, 50, 20, 30, 20, 0],[1, 5, 1, -1, -1, 3])
assert 5 == paris.arg_max_temp(perceived=True)
......... [100%] 9 passed in 0.02s
Comments :
isinstance
allows to test the type of an object (in this case, we test if apparent_temps
is a list)if perceived:
rather than if perceived == True:
. It is equivalent but clearer and shorter !What if we now have a weather station that also measure humidity ?
Do we need to rewrite everything ?
What if we rewrite everything and we find a bug ?
Here comes inheritance
Inheritance is used when we need to define a new class (say B) which behavior is the same as another class (say A) + some specialisation. We say that class B inherits from class A, and that A is the parent (or mother) of class B.
In our previous example, we want to define the class WeatherStationWithHumidity that behave almost like the WeatherStation clas but have a member that holds the humidity and for which the computation of the perceived temperation is different.
class WeatherStationWithHumidity(WeatherStation):
""" A weather station that holds wind and temperature and humidity"""
def __init__(self, wind, temperature, humidities):
""" initialize the weather station.
Precondition: wind and temperature must have the same length
:param wind: any ordered iterable
:param temperature: any ordered iterable
:param humidity: any ordered iterable containing % of humidity """
super().__init__(wind, temperature)
self.humidities = humidities
if len(self.wind) != len(self.humidities):
raise ValueError("humidity and wind should have the same size")
for humidity in humidities:
if humidity < 0 or humidity > 100:
raise ValueError(f"humidity should be in the range [0, 100], got {humidity}")
def perceived_temp(self, index):
""" percieved temperature is the same as the percieved temperature but when
there is wind, but the humidity makes the perception wramer.
:param index: the index for which the computation must be made
:return: the perceived temperature"""
current_humidity = self.humidities[index]
percieved_temp_no_humidity = super().perceived_temp(index)
return percieved_temp_no_humidity * (1+ (current_humidity/100))
return round(percieved_temp_no_humidity, 2)
%%ipytest
def test_building_wether_station_with_hwith_bad_arrays():
""" test exception is raised with bad entry"""
with pytest.raises(ValueError) as exc_info:
bad_station = WeatherStationWithHumidity([10, 50, 20, 30, 20, 0],[1, 5, 1, -1], [0, 10, 20, 15, 50, 5])
assert "wind and temperature should have the same size" == str(exc_info.value)
with pytest.raises(ValueError) as exc_info:
bad_station = WeatherStationWithHumidity([10, 50, 20, 30, 20, 0],[1, 5, 1, -1, -1, 3], [0, 50, 5])
assert "humidity and wind should have the same size" == str(exc_info.value)
with pytest.raises(ValueError) as exc_info:
bad_station = WeatherStationWithHumidity([10, 50, 20, 30, 20, 0],[1, 5, 1, -1, -1, 3], [0, 10, 200, 15, 50, 5])
assert "humidity should be in the range [0, 100], got 200" == str(exc_info.value)
with pytest.raises(ValueError) as exc_info:
bad_station = WeatherStationWithHumidity([10, 50, 20, 30, 20, 0],[1, 5, 1, -1, -1, 3], [0, 10, 20, -10, 50, 5])
assert "humidity should be in the range [0, 100], got -10" == str(exc_info.value)
def test_perceived_whith_null_humidity():
""" test that the perceived temp with humidty is the same when no humidity"""
index = 0
paris_with_h = WeatherStationWithHumidity([10, 50, 20, 30, 20, 0],[1, 5, 1, -1, -1, 3], [0, 10, 20, 15, 50, 5])
paris_no_h = WeatherStation([10, 50, 20, 30, 20, 0],[1, 5, 1, -1, -1, 3])
target = paris_no_h.perceived_temp(index)
assert paris_with_h.perceived_temp(index) == target
def test_perceived_with_humidity():
index = 4
epsilon = 0.0001
paris_with_h = WeatherStationWithHumidity([10, 50, 20, 30, 20, 0],[1, 5, 1, -1, -1, 3], [0, 10, 20, 15, 50, 5])
paris_no_h = WeatherStation([10, 50, 20, 30, 20, 0],[1, 5, 1, -1, -1, 3])
target = paris_no_h.perceived_temp(index) * 1.5
assert target - epsilon < paris_with_h.perceived_temp(index) < target + epsilon
... [100%] 3 passed in 0.01s