Spatially continuous data play a significant role in planning, risk assessment and decision making in environmental management (Li et al. 2011). However, these data are not always available and often difficult or expensive to acquire. The acquisition of environmental data such as groundwater temperature, hydraulic head, substance concentration of soil are usually collected by point sampling. Then, geoscientists often require spatial interpolation methods to get spatially continuous data over a region of interest: here comes the Geostatistics.

Geostatistics is a branch of statistics focusing on spatial or spatiotemporal datasets. Developed originally to predict probability distributions of ore grades for mining operations, it is currently applied in diverse disciplines including petroleum geology, hydrogeology, hydrology, meteorology, oceanography, geochemistry, geometallurgy, geography, forestry, environmental control, landscape ecology, soil science, and agriculture (wikipedia definition).

The principle of geostatistic is well resumed on the documentation webpage:

The basic idea of geostatistics is to describe and estimate spatial correlations in a set of point data. The typical application is geostatistics is an interpolation. Therefore, although using point data, a basic concept is to understand these point data as a sample of a (spatially) continuous variable that can be described as a random field, or to be more precise, a Gaussian random field in many cases. The most fundamental assumption in geostatistics is that any two values xi and xi+h are more similar, the smaller h is, which is a separating distance on the random field. In other words: close observation points will show higher covariances than distant points. In case this most fundamental conceptual assumption does not hold for a specific variable, geostatistics will not be the correct tool to analyze and interpolate this variable.

The aim of this notebook is to build a piezometric map using the Python Scikit-GStat library and to compare the results with other standard interpolation techniques. After some setups, we will download a dataset of points giving hydraulic head of a groundwater body located in the area of Lyon (France) and we will clean this dataset to eliminate all points outside of our groundwater body of interest.

In a second part, we will apply two standard interpolation methods (i.e. linear and cubic) given by the *griddata* function (from *scipy.interpolate*) to map the hydraulic head across our area of interest. The limits of both interpolation methods will be discussed.

Finally, we will explore the ordinary kriging method given by the Scikit-GStat library:

- We will first build a semi-variogramm exploring different parameters to better understand the relationship between measurements variability and distance between measurements.
- Secondly, we will build an ordinary kriging model to interpolate the hydraulic head across our area of interest.
- We will finally see how to plot the error estimation across our area of interest.

Please note the reference of this library and the full documentation:

*Mirko Mälicke, Helge David Schneider, Sebastian Müller, & Egil Möller. (2021, April 20). mmaelicke/scikit-gstat: A scipy flavoured geostatistical variogram analysis toolbox (Version v0.5.0). Zenodo. http://doi.org/10.5281/zenodo.4704356*,- the full documentation of this Python library can be downloaded here.

Finally, please note that the name kriging refers to its inventor Dave Krige who published the method in 1951:

*Krige, D. G. (1951). A statistical approach to some basic mine valuation problems on the Witwatersrand. Journal of the Southern African Institute of Mining and Metallurgy, 52(6), 119-139.*

Two options are proposed to install scikit-gstat. Please select the one you prefer.

Run the following command in you terminal:

View the code on Gist.

To run option 2, please copy/past following instructions into your powershell/terminal:

View the code on Gist.

We also need to install and import other libraries as follow:

View the code on Gist.

Once installation is finished, your notebook should start as follow:

View the code on Gist.

View the code on Gist.

View the code on Gist.

The hydraulic head dataset we are working with is available on the github repository of this notebook. Please note that this dataset is derived from a dataset provided by the french geological survey. Please visit a previous notebook to see how to explore french hydrogeological data.

View the code on Gist.

To download information at the scale of a department, we need some a geographical dataset with administrative units of France. We can use the openstreetmap shapefile of departments of France. We download this shapefile and put it in a local directory as follow:

View the code on Gist.

We can display the location of our points on a static map using `matplotlib`

:

View the code on Gist.

Now, let’s display the location of our points on an interactive map using `folium`

. We also add geological tile layers of France using the `folium.WmsTileLayer`

method (please note that we work with longitudes and latitudes with `folium`

):

View the code on Gist.

Another good possibility to map the locations is the `plotly`

plotting library. One advantage is, that recent versions of `scikit-gstat`

can make use of that library to return interactive plots. You can easily switch between plotly and matplotlib as a plotting backend.

View the code on Gist.

Before any interpolation, we need to clean our dataset. Particularly, we need to exclude all points which are not located in our groundwater body of interest. In this area there are several groundwater bodies and we make the choice to work on the shallow alluvial groundwater body. It includes groundwater flowing through fuvio-glacial corridors (on the eastern part of our available dataset), and groundwater flowing through the modern alluvial deposit.

To do so, we need to make a spatial join between our dataset and the shape of our groundwater body. The shape of the relevant groundwater body can be downloaded as follow (please note that provided file is an incomplete shape of the real groundwater body):

View the code on Gist.

We then make the spatial join and display the location of both files:

View the code on Gist.

We can also display some histograms using the `hist`

method and boxplots using the `boxplot`

method to have a closer look on how our values (i.e. hydraulic head `hh`

and ground elevation `z`

) are distributed:

View the code on Gist.

The hydraulic head boxplot put in evidence a very low outlier (around 130 m). This point can easily be identified in yellow on the previous map, in the south of our area of interest. Considering the very low hydraulic gradient of the groundwater body we are studying (about 0.2%) and other measurements around, this value is clearly an error and should be removed from our analysis (or maybe not an error but a hydraulic head measurement inside a pumping well). Then, let’s remove points where hydraulic head is lower than 145 m and display our cleaned dataset (please note that we could have gone further to clean this dataset):

View the code on Gist.

`griddata`

First, we need to build a meshgrid over the area of interest. A mesh-grid is a grid of coordinates, in which each value simply holds the indices of the position in the grid. In our case, the meshgrid is two dimensional, therefore the two grid-arrays (for x and y) will hold all coordinates present in the grid. We need to create this grid, as the coordinates are the input data for the `griddata`

function. To do so, we need to specify the resolution of the grid we want (the number of values between our x-min and our x-max and similarly in the y-direction) and build the grid using the `numpy.mgrid`

method:

View the code on Gist.

`griddata`

function from the `scipy`

library:View the code on Gist.

We can now plot the results given by both techniques:

View the code on Gist.

The previous figure shows that linear and cubic interpolations techniques give very different results. It is explained by the fact that the weight given by neighboring points to estimate a value is of course not the same. According to the

`scipy.interpolate.griddata`

documentation:- the
`linear`

method tessellates the input point set to N-D simplices, and interpolate linearly on each simplex. - the
`cubic`

method returns the value determined from a cubic spline.

Of course, both techniques can gives pretty good results for dense and regularly spaced datasets, but these conditions barely happen when handling environmental measurements/sampling which are often scattered and non-uniformly distributed. Under such conditions, these techniques have several drawbacks:

- the weighted model is completely arbitrary and doest not account for spatial variability of measurements,
- we have no idea of the variance/error associated to the interpolation method,
- the cubic method trend to emphasis on diverging values (we can easily check that with the multiple circular zones on the figure)
- it is not possible to make extrapolations with these methods.
- both methods use splines and can therefore be highly influenced by outliers

`scikit-gstat`

The first step of our geostatistical analysis consists in analysis the variability of our mesurements using a variogram. We can now import to tools we are going to work with (if you need a quick recap on main geostatistic concepts, please visit this video on YouTube):

View the code on Gist.

We can now build our variogram using the `Variogram`

function. We need to specify some arguments:

- the coordinates zipped into a list of tuples,
- the hydraulic head values of our dataset associated to the coordinates we specified earlier,
- the
`normalize`

parameter which is`True`

or`False`

: used to normalize distances and semi-variances. - the
`model`

representing the theoretical variogram function to be used to describe the experimental variogram. It can takes the following possibilities:`spherical`

,`exponential`

,`gaussian`

,`cubic`

,`stable`

, and`matern`

, - the
`use_nugget`

parameter which is`True`

or`False`

: the nugget represents the intrinsec variability of measurements (occuring even if the distance between measurements is zero). In our case, wa can expect a nugget effect because our hydraulic head measurements are not synchronous and there is of course a seasonal fluctuation of hydraulic head. - the
`maxlag`

parameter representing the maximal distance used between points to calculate semi-variances. - the
`n_lags`

parameter representing the number of intervals in which we divide`maxlag`

to calculate intermediate semi-variances.

View the code on Gist.

This variogram gives us following informations:

- at the origin, we observe the nugget effect: this effet is about 2-4m wich is in line with natural hydrualic head fluctuation in our area of interest,
- of course when the distance increases, the semivariance increases too,

`scikit-gstat`

also has a powerful fitting parameterization. You can switch between a still growing set of fitting algorithms, and apply different weighing functions. These can be used to put more weight on shorter lag bins. This can be helpful in cases where outliers at larger distances than the effective range influence the sill. Remember, that a good fit of the theoretical variogram will have a higher impact on the Kriging quality, than on large lags. With `fit_sigma`

you can either pass weights, or the name of a weighing function. With `'linear'`

the weights of each lag bin will decrease linearly.

View the code on Gist.

You can see, that a linear decrease of weights completely under-estimates the sill. But the `fit_sigma='sqrt'`

produces a slightly better fit on the first 5 bins. We can of course play with all parameters to find the more appropriate model.

Now that we have a fitting model for our variogram, we can build our ordinary kriging model using the `OrdinaryKriging`

function of the `scikit-gstat`

library. We must specify some parameters such as:

- the variogram we want to use,
`min_points`

: the minimum number of points we want to take into account to make our calculation,`max_points`

: the maximal number of points we want to take into account to make our calculation,`mode`

: has to be one of`exact`

or`estimate`

. In`exact`

mode (default) the variogram matrix will be calculated from scratch in each iteration. This gives an exact solution, but it is also slower. In estimate mode, a set of semivariances is pre-calculated and the closest value will be used. This is significantly faster, but the estimation quality is dependent on the given precision.`precision`

(integer): Only needed if`mode="estimate"`

. This is the number of pre-calculated in-range semivariances. If chosen too low, the estimation will be off, if toohigh the performance gain is limited.

View the code on Gist.

Now we can display the result of our ordinary kriging estimation and its associated error:

View the code on Gist.

As we can see:

- we are able to make extrapolation with our ordinary kriging model,
- the error increase in areas with a lower density of measurements.

Now, considering that interpolation/extrapolation has no meaning outside the groundwater body of interest, we can make a mask to display the result on relevant locations:

View the code on Gist.

We finally display the result:

View the code on Gist.

Now we may want to export our ordinary kriging estimation as a shapefile. So, let’s convert resulting array into a geodataframe:

View the code on Gist.

We can finally save the the shapefile using the `to_file`

method:

View the code on Gist.

To export the kriging estimation as a geoTIFF please follow the procedure below:

View the code on Gist.

In this notebook, we applied an ordinary kriging method to determine the hydraulic head across an area of interest. The result is in great accordance with the regional understanding of groundwater flow systems. However, our dataset is based on non-synchronous measurements leading to a significative variability which does not allow us to describe draw conclusions regarding local flow systems behavior.

There are many other geostatistical methods derived from the variography study (e.g. simple kriging, ordinary kriging, universal kriging, co-kriging, etc.)

Please note that kriging algorithms can be combine with machine learning and/or deep learning algorithm to improve local estimations: see Li et al., (2011) and Li & Heap (2014).

This post is available as a notebook and can be run on Goolge Colab.

Please note that all my notebooks are available on my GitHub repository PythonForGeosciences.

In France, main geological/geoscientific data are managed by the French Geological Survey and several platforms provide a public and free access to many datasets such as:

- geological maps,
- boreholes, piezometers and wells locations, geological reports and cross sections (InfoTerre). In France, this database is known as the BSS (
*Base de donnée du Sous-Sol*) which includes more than 700 000 boreholes descriptions, - piezometric time series and physico-chemical analysis from the national monitoring network (ADES).

These data dedicated to the (hydro-)geological knowledge are used by engineers and scientists to prevent natural risks and disasters (e.g. landslides or settlements), protect groundwater ressources and secure water supply for example. Particularly, these data can be used in many fields such as hydrogeology, geothermal energy and geotechnical applications.

These datasets can be explored and download manually by visiting the main platform of the french geological survey InfoTerre, but when it comes to export a large amount of data, at a regional scale, this task becomes tedious and time consuming. To face this problem and improve the dissemination of geoscientific data to the public and to scientists, the French geological survey recently developed many geo-webservices in accordance with European open-data requirements. These geo-webservices can help to automate geological data manipulation, analysis and mapping.

Consequently, the aim of this article is to present some of these services. Particularly, we are going to:

- download and analyze boreholes information at the scale of the department of our choice: water supply wells and geothermal installations will be identified.
- map all this hydrogeological information on an interactive folium map and add the geological tiles of France.
- play with two Hubeau APIs to describe piezometric and physico-chemical characteristics of some groundwater wells: we will learn how to access and analyze hydraulic heads and groundwater quality fluctuations on the groundwater wells we want.

Before starting we need to install/import some libraries:

View the code on Gist.

To download information at the scale of a department, we need some a geographical dataset with administrative units of France. We can use the openstreetmap shapefile of departments of France. We download this shapefile and put it in a local directory as follow:

View the code on Gist.

code_insee | nom | nuts3 | wikipedia | surf_km2 | geometry | |

0 | 974 | La Réunion | FR940 | fr:La Réunion | 2505 | MULTIPOLYGON (((55.21643 -21.03904, 55.21652 -… |

1 | 11 | Aude | FR811 | fr:Aude (département) | 6343 | POLYGON ((1.68872 43.27368, 1.69001 43.27423, … |

2 | 43 | Haute-Loire | FR723 | fr:Haute-Loire | 5003 | POLYGON ((3.08206 45.28988, 3.08209 45.29031, … |

3 | 13 | Bouches-du-Rhône | FR823 | fr:Bouches-du-Rhône | 5247 | MULTIPOLYGON (((4.23014 43.46047, 4.23025 43.4… |

4 | 47 | Lot-et-Garonne | FR614 | fr:Lot-et-Garonne | 5385 | POLYGON ((-0.14058 44.22648, -0.12931 44.23218… |

All department numbers are indicated in the column *code_insee*. We should be careful about this number because the *Rhône* department is identified as 69D to make the difference with the metropolitan area of Lyon which is identified as 69M. Here, we do not want to distinguish them so we create a new column associating the same number *69*.

View the code on Gist.

To see the result, we can select some departments of interest and preview their shape. In the following, we will work with this selection of departments numbers to get some (hydro-)geological information. I decided to work with the department of the Ain (01), the Rhône (69) and of the Isère (38).

View the code on Gist.

We also download a dataset of main cities of France (more than 100’000 inhabitants) and we keep the cities inside our depatment of interest following a similar procedure.

View the code on Gist.

city | lat | lng | country | iso2 | admin_name | capital | population | population_proper | geometry | index_right |

8 | Lyon | 45.76 | 4.84 | France | FR | Auvergne-Rhône-Alpes | admin | 516092 | 516092 | POINT (4.84000 45.76000) |

5 | Grenoble | 45.1715 | 5.7224 | France | FR | Auvergne-Rhône-Alpes | minor | 687985 | 158454 | POINT (5.72240 45.17150) |

165 | Bourg-en-Bresse | 46.2056 | 5.2289 | France | FR | Auvergne-Rhône-Alpes | minor | 41527 | 41527 | POINT (5.22890 46.20560) |

256 | Vienne | 45.5242 | 4.8781 | France | FR | Auvergne-Rhône-Alpes | minor | 29306 | 29306 | POINT (4.87810 45.52420) |

Now, we want to get some information about the geological units of these departments. In the following, we are going to define a function that take the department number as input and which give a dataframe of boreholes information as output. Then, we will be able to identify we groundwater wells or geothermal installations are located and determine the groundwater table depth.

View the code on Gist.

Now we can make a loop and concatenate the data of our departments of interest:

View the code on Gist.

ID_BSS | indice | designation | x_ref06 | y_ref06 | lex_projection_ref06 | … | code_bss | |

0 | BSS001PYDG | 06246X0004 | CPT | 810400 | 6575119 | Lambert-93 | … | 06246X0004/CPT |

1 | BSS001PYDH | 06246X0005 | CPT | 810316 | 6574955 | Lambert-93 | … | 06246X0005/CPT |

2 | BSS001PYDJ | 06246X0006 | CPT | 810209 | 6575025 | Lambert-93 | … | 06246X0006/CPT |

3 | BSS001PYER | 06248X0003 | S | 827507 | 6575474 | Lambert-93 | … | 06248X0003/S |

4 | BSS001PYES | 06248X0004 | HY | 828134 | 6576416 | Lambert-93 | … | 06248X0004/HY |

As you can see, there are 71 columns. In the following, we reduce our focus to few relevent attributs (but please add more if you want to):

*ID_BSS*,*indice*and*designation*which are national codes of boreholes. These codes are requested when making queries to different webservices (we’ll see that later),*point_eau*is a bolean indicating if there is some groundwater or not,*x_ref06*and*y_ref06*are indicating the location in a geographical projection given by*lex_projection_ref06*,*z_sol*indicates the ground elevation mesured and*z_bdalti*the ground elevation given by the IGN at the same location (can be useful to get both to identify some errors),*prof_investigation*indicates the total depth of the borehole,*prof_eau_sol*indicates the groundwater table depth if any exists,*lex_usage*refers to the usage of the borehole/water well,*procede_geothermique*,*categorie_geothermie*, and*usage_geothermie*are indicating if the point is actually a geothermal installation.

View the code on Gist.

Your content goes here. Edit or remove this text inline or in the module Content settings. You can also style every aspect of this content in the module Design settings and even apply custom CSS to this text in the module Advanced settings.

Because the projection is not the same for all departments of France (because of overseas departments), the epsg number of different projections must be known. They are listed in a *epsg* dataframe below and finally, a procedure is made to convert all location in longitudes/latitudes (epsg:4326). **The following step is required in case you want to work on overseas departments. In case you won’t, the following procedure still works to get longitudes/latitudes from Lambert 93 coordinates**.

View the code on Gist.

In practice, there are some errors in the reporting of points coordinates so to remove these errors, we make a spatial join of our geodataframe with the spatial extent of our department of interest: we only keep points inside our departments. Finally, we plot the location of boreholes.

View the code on Gist.

We can of course save our geodataframe as a shapefile, in a RESULTS folder as follow:

View the code on Gist.

Of course we can decide to distinguish the kind of borehole we want to keep. For example, let’s select (1) boreholes where a groundwater table depth is reported and (2) geothermal installations.

In the first case, we keep points where the attribute *point_eau* is *OUI* (meaning that there is some groundwater right here) AND where *prof_eau_sol* is not a NaN value (meaning that we should know the groundwater table depth). In the second case, we keep points where *categorie_geothermie* or *procede_geothermique* or *usage_geothermie* are not a NaN value.

View the code on Gist.

Now that we know where groundwater bodies are located, we might want to describe groundwater levels and maybe describe groundwater flow directions. To do so, we refine a location of interest by choosing the coordinate of a point, we also define a relevant buffer zone around this point to reduce our dataset to this area of interest. In our example, we select the city of Lyon, and a buffer zone of 10km.

**Please note that groundwater table elevation fluctuates over time. In the following, the calculated value represents the water table elevation observed when the borehole was drilled. Also, it should be noted that the calculated values are not synchronous which can alter the local representativity regarding the interpretation groundwater flow directions and flow systems.**

**Please note that we could instead import the polygon shapefile of our choice and select boreholes inside our polygon.**

View the code on Gist.

We describe data inside our geodataframe:

View the code on Gist.

z_sol | z_bdalti | prof_investigation | prof_eau_sol | |

count | 2080 | 1860 | 2008 | 2092 |

mean | 174.73377 | 181.92688 | 19.682102 | 8.022075 |

std | 86.678638 | 30.315466 | 13.985364 | 6.130222 |

min | -999 | 155 | 2.3 | 0.2 |

25% | 166.7975 | 167 | 12 | 4.2575 |

50% | 169.92 | 170 | 17.5 | 6.1 |

75% | 182 | 184 | 22.5 | 10 |

max | 370 | 379 | 250.25 | 85 |

The result shows some errors regarding the ground elevation (-999 value). Consequently, let’s remove data outside first and last percentiles:

View the code on Gist.

z_sol | z_bdalti | prof_investigation | prof_eau_sol | |

count | 2036 | 1826 | 1953 | 2036 |

mean | 178.90788 | 180.04272 | 19.775622 | 8.048355 |

std | 23.567719 | 24.353793 | 14.11073 | 6.157885 |

min | 157.044 | 155 | 2.3 | 0.2 |

25% | 166.9 | 167 | 12 | 4.3 |

50% | 169.945 | 170 | 17.5 | 6.1 |

75% | 182 | 183 | 23 | 10 |

max | 329 | 330 | 250.25 | 85 |

Now, we define the groundwater table elevation as the ground elevation minus the groundwater table depth. Because we have two different possibility to choose the ground elevation from the IGN *z_bdalti*. In addition, we can consider that in our area of interest we focus on the shallow aquifer where groundwater is flowing in the modern alluvial deposit. Consequently, let’s focus on an investigation depth ranging from 10m to 30m:

View the code on Gist.

Then, groundwater table elevation values can be displayed as follow:

View the code on Gist.

The information above can easily be projected on an interactive map using the *folium* library. Additionally, it is interesting to do so because it can help us to understand the relation with geological units.

In fact, since a few years, geological tiles are available across France and can be projected and visualized using *folium*. All WMF/WFS services provided by the BRGM are listed in a table at this page. It is indicated that the geological map can be visualized using the following URL *http://geoservices.brgm.fr/geologie*, and the following layer *GEOLOGIE*. We directly use these inputs to define our WMS tile layer (see below in the script).

Then we can build our map using *folium*:

View the code on Gist.

Of couse we can save this map in *html* and open it later with any navigator:

View the code on Gist.

Since a couple of years, several APIs dedicated to water management have been developed on a national platform called Hubeau. Some of these APIs are dedicated to hydrobiology and surface water monitoring, and others to quantitative and qualitative description of groundwater.

The principle is quite easy: you provide the API of your choice with (1) one or several borehole identifiers, (2) a period of interest, (3) some parameters of interest (piezometric level, temperature, etc.) and you get back a dictionary with all information.

First the procedure to describe the hydraulic head fluctuation at a given borehole will be described. Secondly, we will focus on how to find all recording stations around a area of interest, for a given period of time.

Let’s see how the groundwater piezometric API works. We first need to define one borehole identifier *code_bss*, and a period of interest:

View the code on Gist.

To query the API, we need to build an URL which depends on previous parameters. I give the function below providing you the appropriate URL:

View the code on Gist.

We can now call this function as follow:

View the code on Gist.

The code ‘200’ means that our query is successful (the code is valid and there are some data available). We now organize the result (using the *json* method) and print the content of the webpage using the following procedure:

View the code on Gist.

The resulting content is a dictionary organized according to the date of measurements. We now need to define a function to extract the information we want in this dictionary. The first key of this dictionary gives us a *count *of all measurements over our period of interest. All piezometric values are stored in a data sub-dictionary. Let’s see on the first element of the *data* dictionary:

View the code on Gist.

{‘code_bss’: ‘06987A0186/S’,

‘code_continuite’: ‘2’,

‘code_nature_mesure’: None,

‘code_producteur’: ‘196’,

‘date_mesure’: ‘2015-01-01’,

‘mode_obtention’: ‘Valeur mesurée’,

‘niveau_nappe_eau’: 163.09,

‘nom_continuite’: ‘Point lié au point précédent’,

‘nom_nature_mesure’: None,

‘nom_producteur’: ‘Service Géologique Régional Rhône-Alpes (196)’,

‘profondeur_nappe’: 5.41,

‘qualification’: ‘Correcte’,

‘statut’: ‘Donnée contrôlée niveau 2’,

‘timestamp_mesure’: 1420117200000,

‘urn_bss’: ‘http://services.ades.eaufrance.fr/pointeau/06987A0186/S‘}

We have here a new dictionary with the ID of our borehole, the date of the measurement (*date_mesure*), the associated timestamp (*timestamp_mesure*), the hydraulic head measured (*niveau_nappe_eau*) and other information.

We can make a function to convert this dictionary into a dataframe keeping only relevant features as follow:

View the code on Gist.

date | hydraulic_head |

01/01/2015 | 163.09 |

02/01/2015 | 163.08 |

03/01/2015 | 163.07 |

04/01/2015 | 163.06 |

05/01/2015 | 163.19 |

A procedure exists to find all stations with hydraulic head records around a location of interest. What you need is to define the longitude and latitude coordinates of a rectangle area. Let’s take the smallest rectangle including our previous circular buffer zone:

View the code on Gist.

To make our query, we only need the lower left and top right corners coordinates:

View the code on Gist.

We then need to make a new function to produce the URL and another one to organize the result in a pandas dataframe:

View the code on Gist.

We run this function to sew how many piezometric stations are working/recording at a given date: the 1st of january 2020.

View the code on Gist.

code_bss | date_debut_mesure | date_fin_mesure | nom_commune | x | y | bss_id |

06988C0281/F | 10/04/2009 | 17/08/2020 | Chassieu | 4.969186 | 45.742375 | BSS001TPVW |

07223X0130/P | 18/05/2005 | 03/03/2020 | Saint-Priest | 4.908662 | 45.701409 | BSS001URVP |

06987X0272/P | 14/09/2005 | 09/03/2020 | Vaulx-en-Velin | 4.937039 | 45.807487 | BSS001TPEW |

06987X0274/P | 14/09/2005 | 09/03/2020 | Vaulx-en-Velin | 4.910798 | 45.796308 | BSS001TPEY |

06988X0217/P | 23/08/2005 | 09/03/2020 | Décines-Charpieu | 4.952973 | 45.776655 | BSS001TQGK |

06987A0186/S | 16/04/1968 | 07/08/2020 | Villeurbanne | 4.864856 | 45.779492 | BSS001TMCR |

06987J0105/PZ | 16/05/2005 | 03/03/2020 | Villeurbanne | 4.899866 | 45.782911 | BSS001TNEM |

We can of course display the location of these active stations on an interactive folium map:

View the code on Gist.

Now, we can plot the piezometric levels of these stations over the similar period of interest that we defined earlier.

View the code on Gist.

Similarly, we can make some queries to observe the fluctuation of some physico-chemical parameters such as groundwater temperature, nitrates concentrations, pesticides… many other parameters are available.

Since 1992, the French producers of public water data have engaged a consistency process for their data in the framework of the French water information system. Consequently, all groundwater quality parameters are associated to a number in a referential SANDRE.

This referential can be download below. We can see that 1845 parameters are available.

View the code on Gist.

Nom_famille_sise | nom_parametre_sise | code_parametre_sandre | |

1 | COMPOSES ORGANOHALOGENES VOLATILS | Tétrachloroéthane-1,1,1,2 | 1270 |

2 | COMPOSES ORGANOHALOGENES VOLATILS | 1,1,1,2 Tétrachloropropane | 2704 |

3 | COMPOSES ORGANOHALOGENES VOLATILS | 1,1,1,3 Tétrachloropropane | 2705 |

4 | COMPOSES ORGANOHALOGENES VOLATILS | Trichloroéthane-1,1,1 | 1284 |

5 | COMPOSES ORGANOHALOGENES VOLATILS | Tétrachloroéthane-1,1,2,2 | 1271 |

… | … | … | … |

1841 | PHYTOPLANCTONS | % de colo de ulothricophyc. subst | 6431 |

1842 | PHYTOPLANCTONS | Colonies de zygophyc. substrat | 6432 |

1843 | PHYTOPLANCTONS | % de colo de zygophyc. substr | 6432 |

1844 | PARAMETRES MICROBIOLOGIQUES | Entérocoques / 100 mL (qPCR) | 6455 |

1845 | PHYTOPLANCTONS | Radiocystis sp (cellules) substrat | 7299 |

We could explore all accessible parameters, but in this tutorial, we focus on two of them:

- temperature with the code ‘1301’,
- nitrates concentration with the code ‘1340’.

It can be checked by printing the appropriate lines of the dataframe:

View the code on Gist.

Nom_famille_sise | nom_parametre_sise | code_parametre_sandre | |

1255 | PARAMETRES AZOTES ET PHOSPHORES | Nitrates (en NO3) | 1340 |

1645 | CONTEXTE ENVIRONNEMENTAL | Température de l’eau | 1301 |

The information about these parameters (units, etc.) are described in the following webpages (in french):

You can easly access and get the information about the parameter you want by using the following function depending on the parameter code:

View the code on Gist.

First, we find some quality monitoring stations around our location of interest by using the function defined earlier:

View the code on Gist.

bss_id | code_bss | date_debut_mesure | date_fin_mesure | longitude | latitude | altitude | nom_commune | … | |

0 | BSS001TLEA | 06986T0059/S7 | 05/02/2004 | 05/02/2004 | 4.840702 | 45.731292 | 168 | Lyon 7e Arrondissement | … |

1 | BSS001TPEA | 06987X0252/PZ4 | 14/10/2002 | 02/06/2004 | 4.875022 | 45.762547 | 168 | Villeurbanne | … |

2 | BSS001TPFA | 06987X0276/PZ2 | 31/10/2003 | 04/10/2004 | 4.869878 | 45.750762 | 170 | Lyon | … |

3 | BSS001URFA | 07222X0490/PN4 | 27/11/2000 | 12/12/2007 | 4.845675 | 45.692831 | 164 | Saint-Fons | … |

4 | BSS001URGA | 07222X0514/PZ1 | None | None | 4.845893 | 45.706085 | 164 | Saint-Fons | … |

We see that there are 471 stations. However, the dates are not necessarily matching our query (it is probably an issue that will be solved in a future version of the API). To refine our selection on our period of interest we convert all date features in datetimes format and compare with our datetimes of interest:

View the code on Gist.

We have finally 7 active stations and we can add them (red color) to our interactive map:

View the code on Gist.

Getting groundwater temperature and nitrates concentration fluctuations

We first need to define one borehole identifier *code_bss*, a period of interest and the code of parameters we want to explore (temperature and nitrates in our example):

View the code on Gist.

To query the API, we need to build an URL which depends on previous parameters. I give the function below providing you the appropriate URL depending on the period and the parameters we want:

View the code on Gist.

We can now call this function as follow:

View the code on Gist.

Now, we get and organize the content of this webpage into a pandas dataframe. Please note that a very large number of features are available. You can explore these features by visiting the API website or by removing the last command in the following cell.

Here we only keep following features:

- the date of the measurement,
- the code of the parameter measured,
- the name of the parameter measured (in french),
- the resulting value,
- the quantification limit,
- the detection limit,
- the analytical uncertainty

and we finally print our dataset:

View the code on Gist.

date | code_param | nom_param | value | limite_quantification | limite_detection | incertitude_analytique |

11/05/2017 | 1340 | Nitrates | 19 | 0.1 | None | None |

07/12/2017 | 1340 | Nitrates | 19 | 0.1 | None | None |

07/12/2017 | 1301 | Température de l’Eau | 16.7 | NaN | None | None |

05/07/2018 | 1340 | Nitrates | 17.5 | 0.1 | None | None |

05/07/2018 | 1301 | Température de l’Eau | 17 | NaN | None | None |

16/10/2018 | 1301 | Température de l’Eau | 17 | NaN | None | None |

16/10/2018 | 1340 | Nitrates | 17.9 | 0.1 | None | None |

21/05/2019 | 1340 | Nitrates | 20 | 0.25 | None | None |

21/05/2019 | 1301 | Température de l’Eau | 17 | NaN | None | None |

16/10/2019 | 1301 | Température de l’Eau | 17.2 | NaN | None | None |

16/10/2019 | 1340 | Nitrates | 18 | 0.25 | None | None |

This notebook intends to introduce some APIs and geo-webservices dedicated to the (hydro-)geological knowledge of France. This notebook has been reviewed and successfully returns several parameter descriptions around an area of interest. Please note that all input data have been carefully checked, and be aware that an alteration of input data might raise errors and/or inconsistencies. Particularly, some site-specific choices have been made to illustrate different possibilities and must not be generalized to other locations. Of course, readers are invited to play with all input parameters, and focus on other locations, but following advises and comments are given:

- the area of interest should be carefully determined and must be of a reasonable size to limit the execution time of different procedures. Particularly, launching a data extraction of the all France is misadvised,
- the closeness and depth of boreholes must be taken carefully when comparing some measurements: un-correlated fluctuations can be observed when sensors are note located into the same groundwater body or geological unit, even if boreholes are close.
- there are many more available API functionalities not discussed in this notebook. Please visit Hubeau and PIC’EAU to find out more.
- to work at reginal scales on several stations at a time, there are more efficient methods to extract and analyse data than ones presented in this notebook.

This post is available as a notebook and can be run on Goolge Colab.

Please note that all my notebooks are available on my GitHub repository PythonForGeosciences.

There is an increasing interest in utilizing shallow ground and groundwater as a source for geothermal heating and cooling. Either open- or closed-loop systems can be used for heat exchange with the subsurface to supply heat pumps of buildings. Open-loop systems (Fig. 1a) are either single or groups of wells which utilize groundwater directly as a heat carrier. Commonly, such groundwater heat pump (GWHP) systems are installations of doublet configurations with an extraction well for groundwater abstraction, and an injection well, where water is injected back into the same aquifer at the same rate, but at an altered temperature. Standard closed-loop systems (Fig. 1b) consist of vertical boreholes (BHEs) where plastic tubes are installed for circulating a heat carrier fluid.

Augmented geothermal utilization entails a higher density of installations and potential competition among adjacent systems. When neighbouring geothermal installations are regularly operated in a similar mode of seasonal heating and cooling, there is a risk that thermal interference mitigates the technological performance. Thus, for concerted management of dense installations, especially in cities, authorities and operators have to account for potential thermal interference. Proper management of these systems, however, is not only required in order to regulate the competition for the limited geothermal resource, but is also particularly relevant for sustainable thermal groundwater management that prevents heating or cooling of the subsurface towards environmentally critical levels.

The aim of this article is to present a methodology to define the appropriate distance that should be kept between existing and future installations of different power to protect existing installations and optimally manage the urban thermal use of shallow groundwater. The following section introduces how thermal capture probability can be used as a criterion to define protection perimeters around geothermal installations. Subsequently, analytical models are adapted to calculate thermal capture probabilities, as well as the maximal acceptable power that can be exploited by open or closed loop geothermal installations.

An example focusing on the long term thermal impact and probability of capture around a multiple BHE installation is given at the end of this article.

In the following, some libraries are needed:

import numpy as np import matplotlib as mpl import matplotlib.pyplot as plt from scipy.interpolate import griddata from scipy.special import erf,erfc,erfcinv import scipy.integrate as integrate %matplotlib inline

And some physical parameters are considered constant in the all notebook (you can of course change them if you want to see the influence on the results):

C_m = float(2.888*1e6) #volumetric heat capacity porous media (J /kg /K) C_w = float(4.185*1e6) #volumetric heat capacity water (J /kg /K) alpha_L = float(5) #longitudinal dispersivity (m) alpha_T = float(0.5) #transverse dispersivity (m) lambda_m = float(2.24) #thermal conductivity (W /m /K)

The problem we are studying is to define a thermal protection perimeter around a geothermal installation to avoid an unreasonable temperature alteration \Delta T_{max}, which would be caused by an external heat injection I (Fig. 2). The following definitions are given:
– The protection target area (PTA) of a geothermal installation is defined as the small core area that includes all heat production devices of the installation.
– The thermal protection perimeter (TPP) of an installation is defined as the surrounding area, where an external heat injection I generates a temperature alteration above \Delta T_{max} in the protection target area of the installation.
The interaction between the external heat injection and the PTA can be described by a transfer function, which is defined as the outlet response of the advective-dispersive system to a heat Dirac input at the location of the external heat injection. This transfer function represents a probability density function of travel time distribution between the external heat injection location and the PTA.

By expressing (1) the probability p(t) that a heat quantity introduced somewhere into the aquifer is captured at the PTA and (2) the heat flowing through the PTA, it is possible to determine a protection criterion linking :

– the probability of capture p(t),

– the maximal acceptable temperature variation \Delta T_{max} in the protection target area of the installation,

– Q, the water flowing through the PTA: the pumping rate for an open-syestem, the Darcy rate for a closed-system,

– the external heat power injected I

In fact, under the assumption of (1) a constant undisturbed background temperature, (2) steady-state hydraulic conditions (*This also means that any external heat injection by a neighbouring system is assumed to have a negligible temporal influence on the flow regime*) and (3) a constant external heat input, the protection criterion is given by:

where C_{W} is the volumetric heat capacity of water. **This criterion implies that if the external heat power injection I is located in an area where the heat probability of capture is low enough, the warming of water inside the PTA will be lower than \Delta T_{max}.**

Thermal capture probability refers to a given geothermal installation and is defined as the probability that the heat from any spatial point is transported to this installation. The mathematical technique to compute a probability field for a thermal quantity to reach a domain of interest assumes injection of a thermal pulse in the domain of interest, and it solves the heat transport equation considering a reverse flow. The probability field can then be calculated by integration of the heat signal moving in the backward direction (Fig 3). Because the thermal response of a pulse is the derivative function of the thermal response of a constant heat load, this problem can be equivalently solved by studying groundwater temperature disturbances in the backward direction caused by a constant thermal anomaly of, for instance, \Delta T = 1 K assigned at the location of the geothermal device. Consequently, any analytical and numerical models available for simulation of the thermal impact caused by a heat injection in groundwater in the forward direction of flow can be used to determine capture probabilities in the backward direction.

To evaluate the zone of thermal influence by an open-loop system, semi-analytical solutions can be used (see previous article [here]). For instance, the planar advective heat transport model described by Hähnlein et al., (2010) can be used.

The planar advective heat transport model accounts for the fact that injection may induce a high local hydraulic gradient around the injection well. In such a case, the geometry of this heat source cannot be considered only with a vertical line. Instead, the source is represented as an area in the yz-plane. Accordingly, in a 2D horizontal projection in the xy-plane, the heat source corresponds to a line perpendicular to the groundwater flow direction. Please note that this model is undefined upstream the injection (for x < 0). It is finally given by:

\Delta T(x,y,t) = \frac{\Delta T_{0}}{4} \text{erfc}\left( \frac{Rx - v_{a}t}{2 \sqrt{D_{x}Rt}}\right) \left( \text{erf}\left( \frac{y + Y/2}{2 \sqrt{D_{y}x/v_{a}}}\right) - \text{erf}\left( \frac{y - Y/2}{2 \sqrt{D_{y}x/v_{a}}}\right)\right)with \Delta T_{0} = \frac{F_{0}}{v_{a}nC_{w}Y}, F_{0} = \frac{q_{h}}{b}, q_{h} = \Delta T_{inj} C_{w} Q_{inj}, D_{x,y} = \frac{\lambda _{m}}{n C_{w}} + \alpha _{L,T} v_{a} and Y = \frac{Q_{inj}}{2b v_{a} n}

To calculate probability fields upstream of production wells of open-loop systems, this model is rearranged considering \Delta T_{inj} = 1 K and a reverse flow is assumed meaning that x becomes -x. After some simplifications, it gives:

p(x,y,t) = \frac{1}{4} \text{erfc}\left( \frac{-Rx - v_{a}t}{2 \sqrt{D_{x}Rt}}\right) \left( \text{erf}\left( \frac{y + Y/2}{2 \sqrt{-D_{y}x/v_{a}}}\right) - \text{erf}\left( \frac{y - Y/2}{2 \sqrt{-D_{y}x/v_{a}}}\right)\right)Introducing:

– \alpha : the angle (degree) between the west-east diection and the direction of groundwater flow,

– X_{0} and Y_{0}: the location of the injection well of the doublet,

– X_{1} and Y_{1}: the location of the extraction well of the doublet

Both models are defined as follows:

def PAHT(x, y, X0, Y0, alpha, t, Qinj, DTinj, b, va, n, C_m, C_w, alpha_L, alpha_T, lambda_m): Y = Qinj/(2*b*va*n) qh = DTinj*C_w*Qinj F0 = qh/b DT0 = F0/(va*n*C_w*Y) Dx = lambda_m/n/C_w + alpha_L*va Dy = lambda_m/n/C_w + alpha_T*va R = C_m/(n*C_w) alpha_rad = -alpha*np.pi/180 x1 = x - X0 y1 = y - Y0 x2 = np.cos(alpha_rad)*x1 - np.sin(alpha_rad)*y1 y2 = np.sin(alpha_rad)*x1 + np.cos(alpha_rad)*y1 #To avoid an error due to sqrt with negative values x2 = np.where(x2 >0, x2, np.inf) res = DT0/4*erfc((R*x2 - va*t)/(2*np.sqrt(Dx*R*t)))*\ (erf((y2 + Y/2)/(2*np.sqrt(Dy*x2/va))) - erf((y2 - Y/2)/(2*np.sqrt(Dy*x2/va)))) return res def p_open(x, y, X1, Y1, alpha, t, Qinj, b, va, n, C_m, C_w, alpha_L, alpha_T, lambda_m): proba = PAHT(x, y, X1, Y1, alpha + 180, t, Qinj, 1, b, va, n, C_m, C_w, alpha_L, alpha_T, lambda_m) return proba

With these models defined, we can visualise the thermal impact and the probability of capture around an installation. We first need to define a region of interest and create an associated grid:

#definition of a grid from Xmin to Xmax, and from Ymin to Ymax Xmin = -50 Xmax = 70 xgrid_len = 200 Ymin = -40 Ymax = 70 ygrid_len = 200 #We create a grid xi = np.linspace(Xmin, Xmax, xgrid_len) yi = np.linspace(Ymin, Ymax, ygrid_len) xi, yi = np.meshgrid(xi, yi)

Then, we define some hydrogeological parameters as example, and the technical caracteristics of the open-loop system we want to test:

alpha = float(20) # groundwater flow angle K = float(0.0015) #permeability (m/s) b = float(10) #aquifer thickness [m] grad_h = float(0.0015) #hydraulic gradient n = float(0.2) #effective porosity v0 = K*grad_h #darcy velocity va = v0/n #seepage velocity R = C_m/(n*C_w) #retardation factor #We define the location of the hot water injection X0, Y0 = 20, 20 DTinj = 10. #temperature difference between pumping and reinjection Qinj = 0.0002 #injection and pumping rate m3/s time = 120*24*3600 # operation time in seconds (365 days) #We define the location of the extraction well X1, Y1 = 0, 0

We finnaly calculate the thermal impact and the probability of capture after one year of operation and we plot the result:

from matplotlib.legend_handler import HandlerPatch import matplotlib.patches as mpatches # First we calculate the thermal impact using the PAHT model thermalImpact = PAHT(xi, yi, X0, Y0, alpha, time, Qinj, DTinj, b, va, n, C_m, C_w, alpha_L, alpha_T, lambda_m) # Secondly we calculate the probability of capture using the adapted model probabilityCapture = p_open(xi, yi, X1, Y1, alpha, time, Qinj, b, va, n, C_m, C_w, alpha_L, alpha_T, lambda_m) # Then we create the chart fig, ax = plt.subplots(figsize=(10,6)) ax.set_aspect('equal') # Thermal plume caused by the open-loop doublet cf1= ax.contourf(xi, yi, thermalImpact, [1,2,3,4,5,6,7,8,9,10], cmap='OrRd') fig.colorbar(cf1, label = 'Temperature disturbance (K)') # Probability of capture around the open-loop doublet cf2= ax.contourf(xi, yi, probabilityCapture, [0.01, 0.1,0.2,0.3,0.4,0.5,0.6,0.7,0.8,0.9,1], cmap='PRGn_r') fig.colorbar(cf2, label = 'Probability of capture') ax.grid(color='black', linestyle='-', linewidth=0.1) T = ax.scatter(X0, Y0, marker='v', s=100, color='lightgray', edgecolors='k') P = ax.scatter(X1, Y1, marker='^', s=100, color='lightgray', edgecolors='k') ax.set_xlim(Xmin, Xmax) ax.set_ylim(Ymin, Ymax) # We add an arrow to indicate the groundwater flow direction: arr = ax.arrow(Xmin*0.7, Ymax*0.7, 15*np.cos(alpha*np.pi/180), 15*np.sin(alpha*np.pi/180), head_width=3, head_length=5, fc='k', ec='k') # To add the flow direction in the Legend: def make_legend_arrow(legend, orig_handle, xdescent, ydescent, width, height, fontsize): p = mpatches.FancyArrow(0, 0.5*height, width, 0, length_includes_head=True, head_width=0.75*height ) return p plt.legend([arr, T, P], ['Flow direction', 'Injection well', 'Extraction well'], handler_map={mpatches.FancyArrow : HandlerPatch(patch_func=make_legend_arrow)}) plt.show()

Reformulating protection criterion equation, we can determine the maximal acceptable power by:

P_{max} < \frac{\Delta T_{max} \times Q \times C_{w}}{p(t)} .

Then, considering \Delta T_{max} = 2 K :

DTmax = 2 def maxPowerOpenSystem(x, y, X1, Y1, alpha, t, Qinj, DTmax, b, va, n, C_m, C_w, alpha_L, alpha_T, lambda_m): proba = p_open(x, y, X1, Y1, alpha, t, Qinj, b, va, n, C_m, C_w, alpha_L, alpha_T, lambda_m) #to avoid areas were the probability is 0 and an error, we consider a very low value instead: epsilon = 0.00000001 proba = np.where(proba &gt; 0, proba, epsilon) return DTmax*Qinj*C_w/(proba) maxPower_example = maxPowerOpenSystem(xi, yi, X1, Y1, alpha, time, Qinj, DTmax, b, va, n, C_m, C_w, alpha_L, alpha_T, lambda_m)/1000 #unit kW proba_range = [round(k/20 + 0.05,2) for k in range(20)] power_range = [DTmax*Qinj*C_w/(p*1000) for p in proba_range[::-1]] #unit kW Xmax = 5 Ymax = 10 fig, axs = plt.subplots(1, 2,figsize=(14,5)) ax = axs[0] ax.set_aspect('equal') # Probability of capture around the open-loop doublet cf1= ax.contourf(xi, yi, probabilityCapture, proba_range, cmap='PRGn_r') cb = fig.colorbar(cf1, ax = ax) cb.ax.set_ylabel('Probability of capture') ax.grid(color='black', linestyle='-', linewidth=0.1) abs_well = ax.scatter(X1, Y1, marker='^', s=100, color='lightgray', edgecolors='k') new_project = ax.scatter(-30, -20, marker = '+', s=100, color='black') ax.set_xlim(Xmin, 5) ax.set_ylim(Ymin, 10) arr = ax.arrow(Xmin*0.9, Ymax*0.4, 7*np.cos(alpha*np.pi/180), 7*np.sin(alpha*np.pi/180), head_width=3, head_length=5, fc='k', ec='k') ax.legend([arr, abs_well, new_project], ['Flow direction', 'Extraction well', 'New project'], handler_map={mpatches.FancyArrow : HandlerPatch(patch_func=make_legend_arrow)}, loc='lower right') ax = axs[1] ax.set_aspect('equal') # Maximal acceptable power around the open-loop doublet cf2= ax.contourf(xi, yi, maxPower_example, power_range, cmap='RdYlGn') cb = fig.colorbar(cf2, ax = ax) cb.ax.set_ylabel('Maximal acceptable power (kW)') ax.grid(color='black', linestyle='-', linewidth=0.1) abs_well = ax.scatter(X1, Y1, marker='^', s=100, color='lightgray', edgecolors='k') new_project = ax.scatter(-30, -20, marker = '+', s=100, color='black') ax.set_xlim(Xmin, 5) ax.set_ylim(Ymin, 10) arr = ax.arrow(Xmin*0.9, Ymax*0.4, 7*np.cos(alpha*np.pi/180), 7*np.sin(alpha*np.pi/180), head_width=3, head_length=5, fc='k', ec='k') ax.legend([arr, abs_well, new_project], ['Flow direction', &quot;Extraction well&quot;, &quot;New project&quot;], handler_map={mpatches.FancyArrow : HandlerPatch(patch_func=make_legend_arrow)}, loc='lower right') plt.show()

Here is an example of how to read these figures. Let’s consider a future geothermal installation project located in (X=−30 and Y=−20). These figures can be read as follow: 12% of the heat input coming from a new project located at the black cross, reach the abstraction well before 120 days (the simulation time considered in the current example). It also means that if an heat power of 14 kW is applied at the new project location, the extracted water at the abstraction well location will be reheated by 2 K (the threshold we considered in the current example) within 120 days. We can also directly calculate these values using previous functions:

# We define the location of the new project: X_test, Y_test = -30, -20 print('Probability of capture at the location of the new project:', round(float(p_open(X_test, Y_test, X1, Y1, alpha, time, Qinj, b, va, n, C_m, C_w, alpha_L, alpha_T, lambda_m)),2),) print('Max. acceptable power at the location of the new project: ', round(float(maxPowerOpenSystem(X_test, Y_test, X1, Y1, alpha, time, Qinj, DTmax, b, va, n, C_m, C_w, alpha_L, alpha_T, lambda_m)/1000)), 'kW')

Several analytical solutions are available to calculate the thermal response of an aquifer to operation of closed-loop geothermal systems. Here, the analytical model used to calculate capture probability around such systems is based on the moving infinite line source theory initially proposed by Carslaw and Jaeger. This semi-analytical model allows for the calculation the thermal response of a line source of infinite length along the vertical direction with a continuous heat flow rate q_{th} per unit length of the BHE in a uniform advective-dispersive flow system. According to Stauffer et al., the moving infinite line source model considering dispersion (MILD) reads:

\Delta T(x,y,t) = \frac{q_{th}}{4 \pi C_{m} \sqrt{ D_{t,L} D_{t,T}}} \text{exp}[\frac{u_{t} x}{2D_{t,L}}] \int_{\frac{x^{2}}{4D_{t,L} t} + \frac{y^{2}}{4D_{t,T} t}}^{\infty} \text{exp}\left(- \Psi -(\frac{x^{2}}{D_{t,L}} + \frac{y^{2}}{D_{t,T}}) \frac{u_{t}^{2}}{16 D_{t,L} \Psi}\right) \frac{\mathrm{d}\Psi}{\Psi}with u_{t} = \frac{C_{w}n}{C_{m}}v_{a} and D_{t,L/T} = \frac{\lambda _{m}}{C_{w}} + \alpha _{L/T} u_{t}

To calculate probability fields upstream of heat extracting closed-loop systems, the MILD is rearranged. For this, again reverse flow is described by replacing x with -x. In addition to that, q_{th} is expressed as the power per unit length needed to reheat by 1 K the virtual Darcy flow rate (per unit length) crossing the BHE location:

q_{th} = KidC_{w}

where K is the hydraulic conductivity, i is the hydraulic gradient, and d represents the borehole diameter. This yields the probability field around a borehole located at the origin (x=0, y=0):

p_{BHE} (x,y,t) = \frac{KidC_{w}}{4 \pi C_{m} \sqrt{ D_{t,L} D_{t,T}}} \text{exp}[\frac{- u_{t} x}{2D_{t,L}}] \int_{\frac{x^{2}}{4D_{t,L} t} + \frac{y^{2}}{4D_{t,T} t}}^{\infty} \text{exp}\left(- \Psi -(\frac{x^{2}}{D_{t,L}} + \frac{y^{2}}{D_{t,T}}) \frac{u_{t}^{2}}{16 D_{t,L} \Psi}\right) \frac{\mathrm{d}\Psi}{\Psi}Introducing:

– \alpha : the angle (degree) between the west-east diection and the direction of groundwater flow,

– X_{0} and Y_{0}: the location of the BHE,

the MILD model is defined as follow:

def MILSd(x, y, t, X0, Y0, P, va, n, b, C_m, C_w, alpha, alpha_L, alpha_T, lambda_m): QL=P/b #power by length of the borehole Dt = lambda_m/C_m vt = va*n*C_w/C_m Dx = Dt + alpha_L*vt Dy = Dt + alpha_T*vt alpha_rad = -alpha*np.pi/180 x1 = x - X0 y1 = y - Y0 x2 = np.cos(alpha_rad)*x1 - np.sin(alpha_rad)*y1 y2 = np.sin(alpha_rad)*x1 + np.cos(alpha_rad)*y1 e_x = 0.001 #to avoid error calculation at the origin if x2 == 0 and y2 == 0: x2 = e_x b_inf = (((x2**2)/4/Dx/t)+((y2**2)/4/Dy/t)) return QL*(1./4./np.pi/C_m/np.sqrt(Dx*Dy))*np.exp(vt*x2/2/Dx)*integrate.quad( lambda psi: 1/psi*np.exp(-psi-((x2**2/Dx)+(y2**2/Dy))*(vt**2)/16/Dx/psi),b_inf,np.Inf)[0] # to use the Model over a grid: def MILSd_grid(xi, yi, t, X0, Y0, P, va, n, b, C_m, C_w, alpha, alpha_L, alpha_T, lambda_m): DELTA_T = xi - xi #the result grid full of 0 for i in range(len(xi)): for j in range(len(yi)): DELTA_T[i,j] = MILSd(xi[i,j], yi[i,j], t, X0, Y0, P, va, n, b, C_m, C_w, alpha, alpha_L, alpha_T, lambda_m) return DELTA_T # We adapt the model to calculate probabilities: def proba_closed(xi, yi, t, X0, Y0, d, va, n, b, C_m, C_w, alpha, alpha_L, alpha_T, lambda_m): Qd = va*n*b*d P_unit = Qd*C_w*1. return MILSd_grid(xi, yi, t, X0, Y0, P_unit, va, n, b, C_m, C_w, alpha+180, alpha_L, alpha_T, lambda_m)

Now we choose some hydrogeological parameters to calculate the thermal impact and the probability of capture around a BHE. In addition, we consider that the BHE is located at the origin and has a power of 50 kW and a diamter of 1 m:

# We define the simulation time time = 120*24*3600 # We define some parameters associated to the BHE X0, Y0 = 0, 0 d = 1. #Diameter of BHE in m Pi = 50000 #power in W # We define some hydrogeological parameters: alpha = float(35) #groundwater flow angle K = float(0.001) #permeability (m/s) b = float(100) #aquifer thickness [m] grad_h = float(0.002) #hydraulic gradient n = float(0.1) #effective porosity v0 = K*grad_h #darcy velocity va = v0/n #seepage velocity R = C_m/(n*C_w) #retardation factor #definition of our grid from Xmin to Xmax, and from Ymin to Ymax Xmin = -20 Xmax = 40 xgrid_len = 100 Ymin = -20 Ymax = 40 ygrid_len = 100 #We create your grid of interest xi = np.linspace(Xmin, Xmax, xgrid_len) yi = np.linspace(Ymin, Ymax, ygrid_len) xi, yi = np.meshgrid(xi, yi) #We calculate the thermal impact closedDeltaT = MILSd_grid(xi,yi, time, X0, Y0, Pi,va, n, b, C_m, C_w, alpha, alpha_L, alpha_T, lambda_m) # We calculate the probability of capture over the grid: closedProba = proba_closed(xi, yi, time, X0, Y0, d, va, n, b, C_m, C_w, alpha, alpha_L, alpha_T, lambda_m) # We plot the result: fig, ax = plt.subplots(figsize=(10,6)) # Thermal plume caused by the closed-loop system cf1 = ax.contourf(xi, yi, closedDeltaT, [1,2,3,4,5,6,7,8,9,10], cmap='OrRd', zorder = 2) fig.colorbar(cf1, label = 'Temperature disturbance (K)') # Probability of capture around the closed-loop dsystem cf2 = ax.contourf(xi, yi, closedProba, [0.01, 0.1,0.2,0.3,0.4,0.5,0.6,0.7,0.8,0.9,1], cmap='PRGn_r', zorder = 1) fig.colorbar(cf2, label = 'Probability of capture') BHE = ax.scatter(X0, Y0, marker='o', s=100, color='lightgray', edgecolors='k', zorder=2) ax.set_aspect('equal') ax.grid(color='black', linestyle='-', linewidth=0.1) ax.set_xlim(Xmin, Xmax) ax.set_ylim(Ymin, Ymax) # We add an arrow to indicate the groundwater flow direction: arr = ax.arrow(Xmin*0.7, Ymax*0.7, 8*np.cos(alpha*np.pi/180), 8*np.sin(alpha*np.pi/180), head_width=3, head_length=5, fc='k', ec='k') # To add the flow direction in the Legend: plt.legend([arr, BHE], ['Flow direction', 'Borehole heat exchanger'], handler_map={mpatches.FancyArrow : HandlerPatch(patch_func=make_legend_arrow)}) plt.show()

Finally, the maximal acceptable heat power around the BHE is calulate as follows considering \Delta T_{max}=2K :

epsilon = 1e-9 #This is a residual value for areas where is probability of capture is close to 0 closedProba = np.where(closedProba &gt; 0,closedProba, epsilon) Qd = va*n*d*b #The Darcy flow around BHE with a diameter d DTmax = 2 #The acceptable temperature variation at the BHE location # Then we calculate the maximal acceptable power around the BHE maxPowerClosedExample = DTmax*Qd*C_w/closedProba proba_range_closed = [round(k/20 + 0.05,2) for k in range(20)] power_range_closed = [DTmax*Qd*C_w/(p*1000) for p in proba_range_closed[::-1]] #unit kW Xmax = 5 Ymax = 10 fig, axs = plt.subplots(1, 2,figsize=(13,5)) ax = axs[0] ax.set_aspect('equal') ax.set_xlim(Xmin, Xmax) ax.set_ylim(Ymin, Ymax) # Probability of capture around the BHE cf1= ax.contourf(xi, yi, closedProba, proba_range_closed, cmap='PRGn_r') cb = fig.colorbar(cf1, ax = ax) cb.ax.set_ylabel('Probability of capture') ax.grid(color='black', linestyle='-', linewidth=0.1) # Location of the BHE BHE = ax.scatter(X0, Y0, marker='o', s=50, color='lightgray', edgecolors='k', zorder=2) # Direction of the groundwater flow arr = ax.arrow(Xmin*0.9, Ymax*0.5, 5*np.cos(alpha*np.pi/180), 5*np.sin(alpha*np.pi/180), head_width=1, head_length=2, fc='k', ec='k') ax.legend([arr, BHE], ['Flow direction', 'BHE', handler_map={mpatches.FancyArrow : HandlerPatch(patch_func=make_legend_arrow)}) ax = axs[1] ax.set_aspect('equal') ax.set_xlim(Xmin, Xmax) ax.set_ylim(Ymin, Ymax) # Probability of capture around the closed-loop system cf2= ax.contourf(xi, yi, maxPowerClosedExample/1000, power_range_closed, cmap='RdYlGn') cb = fig.colorbar(cf2, ax = ax) cb.ax.set_ylabel('Maximal acceptable power (kW)') ax.grid(color='black', linestyle='-', linewidth=0.1) # Location of the BHE BHE = ax.scatter(X0, Y0, marker='o', s=50, color='lightgray', edgecolors='k', zorder=2) # Direction of the groundwater flow arr = ax.arrow(Xmin*0.9, Ymax*0.5, 5*np.cos(alpha*np.pi/180), 5*np.sin(alpha*np.pi/180), head_width=1, head_length=2, fc='k', ec='k') ax.legend([arr, BHE], ['Flow direction', 'BHE'], handler_map={mpatches.FancyArrow : HandlerPatch(patch_func=make_legend_arrow)}) plt.show()

In this last section, we illustrate how to apply this methodology to a multiple BHE installations. We use previous models to determine long term (30 years) thermal impact and probability of capture around the installation. After defining simulation time and aquifer properties, the installation properties are defined (location of BHEs, power and diameters). Then, we make a loop over BHEs to calculate the cumulative thermal impact and the total probability of capture:

########################################################################### # Simulation time ## ########################################################################### long_term_period = 30*365*24*3600 #30 years in seconds ########################################################################### # AQUIFER properties ## ########################################################################### b = 100. #aquifer thickness (m) n = 0.30 #aquifer porosity (-) va = 0.05/24./3600. #seepage velocity (m/s) alpha = 0 #angle between flow direction and x-axis ########################################################################### # CLOSED-SYSTEM properties ## ########################################################################### P0 = 5000 #individual boreholes power in W d = 1. #BHE diameter BHES = [(0, 0, P0, d), (0, 15, P0, d), (0, -15, P0, d), (0, 30, P0, d), (0, -30, P0, d)] # BHEs locations and Power (X, Y, Power, BHE diameter) ########################################################################### # Windows properties and Grid of interest ## ########################################################################### Xmax = 300 Xmin= -Xmax Ymax = (Xmax-Xmin)/2 Ymin = -Ymax #We create our grid of interest xgrid_len = 100 ygrid_len = 100 xi = np.linspace(Xmin, Xmax, xgrid_len) yi = np.linspace(Ymin, Ymax, ygrid_len) xi, yi = np.meshgrid(xi, yi) ########################################################################### # Thermal impact and probability of capture ## ########################################################################### multipleClosedDeltaT = xi - xi # we initialize the grid to zeros multipleClosedProba = xi - xi # we initialize the grid to zeros for bhe in BHES: x_bhe = bhe[0] y_bhe = bhe[1] P_bhe = bhe[2] d_bhe = bhe[3] multipleClosedDeltaT += MILSd_grid(xi,yi, long_term_period, x_bhe, y_bhe, P_bhe, va, n, b, C_m, C_w, alpha, alpha_L, alpha_T, lambda_m) multipleClosedProba += proba_closed(xi, yi, long_term_period, x_bhe, y_bhe, d_bhe, va, n, b, C_m, C_w, alpha, alpha_L, alpha_T, lambda_m)

After the calculation is made, we can plot the results:

# We plot the results: fig, ax = plt.subplots(figsize=(10,6)) ax.set_aspect('equal') # Thermal plume caused by the closed-loop system cf1= ax.contourf(xi, yi, multipleClosedDeltaT, [1,2,3,4,5,6,7,8,9,10], cmap='OrRd', zorder = 1) fig.colorbar(cf1, label = 'Temperature disturbance (K)') # Probability of capture around multiple closed-loop systems cf2= ax.contourf(xi, yi, multipleClosedProba, [1e-2,5e-2,1e-1,5e-1,1], cmap='PRGn_r', zorder = 0) fig.colorbar(cf2, label = 'Probability of capture') ax.grid(color='black', linestyle='-', linewidth=0.1) BHE = ax.scatter([bhe[0] for bhe in BHES], [bhe[1] for bhe in BHES], marker='o', s=50, color='lightgray', edgecolors='k', zorder=2) # We add an arrow to indicate the groundwater flow direction: arr = ax.arrow(Xmin*0.7, Ymax*0.7, 40*np.cos(alpha*np.pi/180), 20*np.sin(alpha*np.pi/180), head_width=13, head_length=15, fc='k', ec='k') # To add the flow direction in the Legend: plt.legend([arr, BHE], ['Flow direction', 'Borehole heat exchanger'], handler_map={mpatches.FancyArrow : HandlerPatch(patch_func=make_legend_arrow)}) plt.show()

Then, we can calculate the maximal acceptable power that can be exploited around the installation considering the probability of individual BHEs. Here we consider that the temperature alteration at each BHE should not exceed \Delta T_{max}. Then, we have to make a loop over BHEs and store individual probabilities of capture fields:

# We initialize some matrix to zeros multipleClosedProba = xi - xi #The probability of capture around the installation MaxPowerMultplieClosed = xi -xi #The maximal acceptable power around the installation MaxPowerMultplieClosed += 10000000000 # We consider a very large value for initializing the calculation # We consider a maximum temperature difference caused by a new installation to 2K: DTmax = 2. # We make a loop on BHES for bhe in BHES: x_bhe = bhe[0] y_bhe = bhe[1] P_bhe = bhe[2] d_bhe = bhe[3] # we calculate the Darcy flow around the bhe Qd_bhe = d_bhe*va*n*b # we calculate the probability of capture around the BHE bhe_Proba = proba_closed(xi, yi, long_term_period, x_bhe, y_bhe, d_bhe, va, n, b, C_m, C_w, alpha, alpha_L, alpha_T, lambda_m) # The cumulative result (probability of capture) is calculated here: multipleClosedProba += bhe_Proba # we calculate the maximal acceptable power around earch BHE MaxPowerBHE = DTmax*Qd*C_w/bhe_Proba # The global maximal acceptable power is ajusted depending on the current value and the BHE result: MaxPowerMultplieClosed = np.where( MaxPowerMultplieClosed >= MaxPowerBHE, MaxPowerBHE,MaxPowerMultplieClosed)

The final result is given below:

Xmax = 50 #to adjust the scale fig, axs = plt.subplots(1, 2,figsize=(10,6)) ax = axs[0] ax.set_aspect('equal') ax.set_xlim(Xmin, Xmax) ax.set_ylim(Ymin, Ymax) # Probability of capture cf1= ax.contourf(xi, yi, multipleClosedProba, [0.01, 0.02, 0.03, 0.04, 0.05, 0.06, 0.07, 0.08, 0.09, 0.1], cmap='PRGn_r') cb = fig.colorbar(cf1, ax = ax) cb.ax.set_ylabel('Probability of capture') ax.grid(color='black', linestyle='-', linewidth=0.1) # Location of BHEs BHE = ax.scatter([bhe[0] for bhe in BHES], [bhe[1] for bhe in BHES], marker='o', s=30, color='lightgray', edgecolors='k', zorder=2) # We add an arrow to indicate the groundwater flow direction: arr = ax.arrow(Xmin*0.7, Ymax*0.7, 40*np.cos(alpha*np.pi/180), 20*np.sin(alpha*np.pi/180), head_width=13, head_length=15, fc='k', ec='k') ax.legend([arr, BHE], ['Flow direction', 'BHE'], handler_map={mpatches.FancyArrow : HandlerPatch(patch_func=make_legend_arrow)}) ax = axs[1] ax.set_aspect('equal') ax.set_xlim(Xmin, Xmax) ax.set_ylim(Ymin, Ymax) # Probability of capture around multiple closed-loop systems cf2= ax.contourf(xi, yi, MaxPowerMultplieClosed/1000, [0, 200, 400, 600, 800, 1000], cmap='RdYlGn') cb = fig.colorbar(cf2, ax = ax) cb.ax.set_ylabel('Maximal acceptable power (kW)') ax.grid(color='black', linestyle='-', linewidth=0.1) # Location of BHEs BHE = ax.scatter([bhe[0] for bhe in BHES], [bhe[1] for bhe in BHES], marker='o', s=30, color='lightgray', edgecolors='k', zorder=2) # We add an arrow to indicate the groundwater flow direction: arr = ax.arrow(Xmin*0.7, Ymax*0.7, 40*np.cos(alpha*np.pi/180), 20*np.sin(alpha*np.pi/180), head_width=13, head_length=15, fc='k', ec='k') ax.legend([arr, BHE], ['Flow direction', 'BHE'], handler_map={mpatches.FancyArrow : HandlerPatch(patch_func=make_legend_arrow)}) plt.show()

The methodology presented in this notebook is intended to prevent thermal interference between both open- and closed-loop systems. It relies on the theory of transfer functions in hydrogeology. Here, the application of this theory was extended to the calculation of the probability of thermal capture around neighbouring installations. It allows for the understanding of where the extracted heat is coming from and what the heat contribution of various neighbouring sources would be. By linking a thermal threshold with capture probability, this methodology allows us to continuously and spatially quantify the compatibility between existing and planned new geothermal installations. It should be noted that the presented methodology can be applied using any other analytical solutions depending on the aim of a study, scale (e.g. 2D, 3D) and the hydrogeological context. Finally, the probability of capture around geothermal systems can be determined using numerical models to take into account refined variations of flow directions and hydrogeological heterogeneities.

This post is available as a notebook and can be run on Google Colab.

This notebook is based on a paper published by Attard et al., (2020) in *Renewable Energy*.

A publication dedicated to this topic is also available in french Éviter les interférences des échangeurs géothermiques on the Cerema website.

For further details on mathematical techniques for obtaining transfer functions in hydrogeology, readers are referred to:

Based on the analogy between the advection-dispersion equation for solute and heat transport, this theory was adopted by Milnes and Perrochet to assess the impact of thermal feedback and recycling within single geothermal well doublets Milnes and Perrochet (2014).

For further information on the shallow geothermal potential of cities and on the thermal impact of geothermal systems, readers are referred to:

Within the last decade, a huge amount of geospatial data, such as satellite data (e.g. land surface temperatures, vegetation) or the output of large scale, even global models (e.g. wind speed, groundwater recharge), have become freely available from multiple national agencies and universities (e.g. NASA, USGS, NOAA, and ESA). These geospactial data are used every day by scientists and engineers of all fields, to predict weather, prevent disasters, secure water supply or study the consequences of climate change. When using these geospatial data, things fast becomes tricky and the following questions arise:

– What data is available and where can we find it ?

– How can we access these data?

– How can we manipulate these petabytes of data?

Well, since a couple of years you can answer all three questions with “Google Earth Engine” – A platform that allows online cloud-computation on a large varierty of global datasets. After singin up (here) with your google-account (might take a couple of hours to unlock), you can either type https://code.earthengine.google.com/ in your browser to play with their seemingly endless amount of geospatial datasets typing Javascripts. Or…, you can continue to read this post to learn how to manipulate these remote sensing data through a Python API.

Google Earth Engine is a cloud-based platform for planetary-scale geospatial analysis that brings Google’s massive computational capabilities to bear on a variety of high-impact societal issuesal capacity needed to utilize traditional supercomputers or large-scale commodity cloud computing resources.(Golerick et al. 2017)

In this article, an introduction to the GEE Python API is presented. After some setups and some exploration of the Earth Engine Data Catalog, we’ll see how to handle geospatial datasets with pandas and make some plots with matplotlib. Particularly, we’ll see how to get the timeseries of a variable on a region of interest. An application of this procedure will be done to extract Land Surface Temperature in an urban and a rural area near the city of Lyon (France) to illustrate the heat island effect.

Then, we will focus on static mapping and a procedure to export the result in a geotiff file will be detailed.

Finally, the folium library will be introduced to make interactive maps. In this last part, we’ll see how to include some GEE datasets as tiles layers of a folium map.

Screenshot of the Javascript google earth engine platform

Have you ever thought that getting a meteorological dataset could be as easy as finding the nearest pizzeria? To convince you, go here and play with the search bar and select the dataset you want to explore.

Let’s say that we need to know the elevation of a region, some soil properties (e.g. clay/sand/silt content) and some meteorological data (e.g. temperature, precipitation, evapotranspiration). Well, inside the Earth Engine Catalog we find:

- global elevation with a resolution of 30m is available here,
- OpenLandMap datasets with some soil porperties with a resolution of 250m (e.g. clay, sand, and silt content)
- temperature, precipitation and evapotranspiration datasets with different resolution. For example you can explore the GRIDMET model data from the University of Idaho or for streight up satelite derived values look into the MODIS Collections, etc.

Of course the resolution, frequency, spatial and temporal extent as well as data source (e.g. satelite image, interpolated station data, or model output) vary from one dataset to another. Therefore, read the description carefully and make sure you know what kind of dataset you are selecting!

To give you a global picture of what can be found in the Earth Engine Data Catalog, you can have a look at the word cloud below summarizing tags associated to available datasets.

Word cloud based on Earth Engine Data Catalog tags

First of all, the client earthengine api should be installed. It can be done properly using the documentation here, or directly using the following command from your notebook.

!pip install earthengine-api --upgrade

Then, we can import the earthengine library and authenticate with a google account. After running the following cell, a new windows should open to give you a token associated to your google account (If no windows opens, just click the resulting link). Just copy and past the token into the empty cell poping up after the run.

import ee ee.Authenticate()

Now we can initialize the API:

ee.Initialize()

Congratulations ! Now we can play with global datasets =)

In the Earth Engine Catalog, datasets can have different shapes. Basically, we work with:

*features*which are geometric objects with a list of properties. For example, a watershed with some properties such as*name*,*area*, is a feature.*image*which are like features, but may include several bands. For example, The ground elevation given by the USGS here is an image.*collections*which are group of features or images. For example, the Global Administrative Unit Layers giving administrative boundaries is a*featureCollection*and the MODIS Land Surface Temperature dataset is an imageCollection.

If you want to know more about different data models, you may want to visit the earth engine user guide

In the following, we work with the MODIS Land Cover (LC), the MODIS Land Surface Temperature (LST) and with the USGS Ground Elevation (ELV) which are *imageCollections*. The descriptions provide us with all the information we need to import and manipulate these datasets: the availability, the provider, the Earth Engine Snippet, and the available bands associated to the collection.

Now, to import the LC, LST and ELV collections, we can copy and paste the Earth Engine Snippets:

# import a land cover Collection LC = ee.ImageCollection('MODIS/006/MCD12Q1') # import a land surface temperature Collection LST = ee.ImageCollection('MODIS/006/MOD11A1') # import a ground elevation Image ELV = ee.Image('USGS/SRTMGL1_003')

All of these images come in a different resolution, frequency, and possibly projection, ranging from daily images in a 1 km resolution for LST (hence an ee.ImageCollection – a collection of several ee.Images) to a sinlge image giving data for the year 2000 in a 30 m resolution for the ELV. While we need to have an eye on the frequency, GEE takes care of resolution and projection by resampling and reprojecting all data we are going to work with to a common projection. We can define the resolution (called scale in GEE) whenever necessary and of course have the option to force no reprojection.

As you can see in the description of the datasets, they include several sets of information stored in several bands. For example, these bands are associated to the LST collection:

- LST_Day_1km: Daytime Land Surface Temperature
- Day_view_time: Local time of day observation,
- LST_Night_1km: Nighttime Land Surface Temperature,
- …

The description page of the collection tells us that the name of the band associted to the daytime LST is ‘LST_Day_1km’ which is in Kelvin. In addition, values are ranging from 7500 to 65535 with a corrective scale of 0.02.

Then, we have to select the bands we want to work with. Therefore, I decide to focus on daytime LST so we select the daytime band with the .select() command. We also need to filter the collection on the period of time we want. We can do that using the *filterDate()* method.

# initial date of interest - inclusive i_date = '2013-01-01' # final date of interest - exclusive f_date = '2017-01-01' # selection of appropriate bands and dates for LST LST = LST.select('LST_Day_1km').filterDate(i_date, f_date)

Now, we can either upload existing shape files or define some points with longitude and latitude coordinates where we want know more about Land cover, Land Surface Temperature and Elevation. For example, let’s take two:

- The first one in the urban area of Lyon (France)
- The second one, 30 kilometers away the city center, in a rural area

# Definition of the urban location of interest with a point u_lon = 4.8148 #longitude of the location of interest u_lat = 45.7758 #latitude of the location of interest u_poi = ee.Geometry.Point(u_lon, u_lat) # Definition of the rural location of interest with a point r_lon = 5.175964 #longitude of the location of interest r_lat = 45.574064 #latitude of the location of interest r_poi = ee.Geometry.Point(r_lon, r_lat)

We can easily get information about our region/point of interest using following methods (to get more information about available methods and required arguments, please visit the API documentation here):

*sample()*: samples the image (does NOT work for an image collection – we’ll talk about sampeling an image collection later) according to a given geometry and a scale (in meters) of the projection to sample in. It restuns a featureCollection.*first()*: returns the first entry of the collection,*get()*: to select the appropriate band of your Image/Collection,*getInfo()*: returns the value, not just the object.

Then we can evaluation the ground elevation and LST around our point of interest using the following commands. Please be careful when evaluating LST. According to the dataset description, the value should be corrected by a factor of 0.02 to get Kelvins (do not forget the conversion). To get the mean mulit-annual daytime LST, we use the *mean()* method on the LST ImageCollection. (The following run might take some time: about 15-20 seconds for me).

scale = 1000 #scale in meters # Print the elevation near Lyon (France) elv = ELV.sample(u_poi, scale).first().get('elevation').getInfo() print('Ground elevation around the point:', elv, 'm') # Print the daytime LST near Lyon (France) # To take the mean value of the LST on the collection, we use the .mean() method: lst = LST.mean().sample(u_poi, scale).first().get('LST_Day_1km').getInfo() print('Average daytime LST around the point:', round(lst*0.02 -273.15,2), '°C') # Print the land cover type around the point: lct = LC.first().sample(u_poi, scale).first().get('LC_Type1').getInfo() print('The land cover value around the point is:', lct)

Ground elevation around the point: 196 m

Average daytime LST around the point: 21.92 °C

The land cover value around the point is: 13

Going back to the band description of the LC dataset, we see that a LC value of “13” conrresponds to an urban land. You can run the above cells with the rural point coordinates if you want to notice a difference.

Now that you see we can get geospatial information about a place of interest pretty easily, you may want to get some timeseries, probably make some charts and draw get statistics about a place. Hence, we import the data at the given locations using the *getRegion()* method.

#here is the buffer zone we consider around each point : 1000m buffer_points = 1000 # We get the data for the point in urban area LST_u_poi = LST.getRegion(u_poi, buffer_points).getInfo() # We get the data for the point in rural area LST_r_poi = LST.getRegion(r_poi, buffer_points).getInfo() LST_u_poi[:5]

[['id', 'longitude', 'latitude', 'time', 'LST_Day_1km'],

['2013_01_01', 4.810478346460038, 45.77365530231022, 1356998400000, None],

['2013_01_02', 4.810478346460038, 45.77365530231022, 1357084800000, None],

['2013_01_03', 4.810478346460038, 45.77365530231022, 1357171200000, None],

['2013_01_04', 4.810478346460038, 45.77365530231022, 1357257600000, None]]

Printing the first 5 lines of the result shows that we now have arrays full of data. We now define a function to transform this array into a pandas Dataframe which is much more convenient to manipulate.

import pandas as pd def ee_array_to_df(arr, band): df = pd.DataFrame(arr) # we rearrange the header headers = df.iloc[0] df = pd.DataFrame(df.values[1:], columns = headers) # we remove raws without data inside: df = df[['longitude', 'latitude', 'time', band]].dropna() # We converr the data to numeric values df[band] = pd.to_numeric(df[band], errors='coerce') # We also convert the Time filed into a datetime df['datetime'] = pd.to_datetime(df['time'], unit='ms') # We keep the columns we want df = df[['time','datetime', band]] return df

We apply this function to get the two timeseries we want (and we print one):

LSTdf_urban = ee_array_to_df(LST_u_poi,'LST_Day_1km') #Do not forget that the LST is corrected with a scale of 0.02. #So we convert the appropriate field of the dataframe to get temperature in celcius: LSTdf_urban['LST_Day_1km'] = 0.02*LSTdf_urban['LST_Day_1km'] - 273.15 #We do the same for the rural point: LSTdf_rural = ee_array_to_df(LST_r_poi,'LST_Day_1km') LSTdf_rural['LST_Day_1km'] = 0.02*LSTdf_rural['LST_Day_1km'] - 273.15 LSTdf_urban.head()

time | datetime | LST_Day_1km | |
---|---|---|---|

15 | 1358294400000 | 2013-01-16 | -1.49 |

21 | 1358812800000 | 2013-01-22 | 6.47 |

25 | 1359158400000 | 2013-01-26 | 0.33 |

29 | 1359504000000 | 2013-01-30 | 11.93 |

30 | 1359590400000 | 2013-01-31 | 5.13 |

Now that we have our data in a good shape, we can easily make plots and compare the trends. As Land Surface Temperature has a seasonality influence, we expect that data looking something like LST(t) = LST_{0} (1 + sin(\omega t + \phi))

Consequently, on the top of the data scatter plot, we plot the fitting curve using the scipy library:

import matplotlib.pyplot as plt import numpy as np from scipy import optimize %matplotlib inline # Fitting Curves: ## first, we extract x values (times) from our dfs x_data_u = np.asanyarray(LSTdf_urban['time'].apply(float)) #urban x_data_r = np.asanyarray(LSTdf_rural['time'].apply(float)) #rural ## Secondly, we extract y values (LST) from our dfs y_data_u = np.asanyarray(LSTdf_urban['LST_Day_1km'].apply(float)) #urban y_data_r = np.asanyarray(LSTdf_rural['LST_Day_1km'].apply(float)) #rural ## Then, we define the fitting function with parameters a, b, c, d: def fit_func(x, a, b, c): return a*(np.sin(b*x + c) + 1) ## We optimize the parameters using a good start p0 params_u, params_covariance_u = optimize.curve_fit(fit_func, x_data_u, y_data_u, p0 = [20., 0.002*np.pi/(365.*24.*3600.), 350]) params_r, params_covariance_r = optimize.curve_fit(fit_func, x_data_r, y_data_r, p0 = [20., 0.002*np.pi/(365.*24.*3600.), 350]) # Subplots fig, ax = plt.subplots(figsize=(21, 9)) # We add scatter plots ax.scatter(LSTdf_urban['datetime'], LSTdf_urban['LST_Day_1km'], c = 'black', alpha = 0.2, label = 'Urban (data)') ax.scatter(LSTdf_rural['datetime'], LSTdf_rural['LST_Day_1km'], c = 'green', alpha = 0.35, label = 'Rural (data)') # We add fitting curves ax.plot(LSTdf_urban['datetime'], fit_func(x_data_u, params_u[0], params_u[1], params_u[2]), label='Urban (fitted)', color = 'black', lw = 2.5) ax.plot(LSTdf_rural['datetime'], fit_func(x_data_r, params_r[0], params_r[1], params_r[2]), label='Rural (fitted)', color = 'green', lw = 2.5) # We add some parameters ax.set_title('Daytime Land Surface Tempearture near Lyon', fontsize = 16) ax.set_xlabel('Date', fontsize = 14) ax.set_ylabel('Temperature [K]', fontsize = 14) ax.set_ylim(-5, 40) ax.grid(lw = 0.2) ax.legend(fontsize = 14, loc = 'lower right') plt.show()

Now, we want to get static maps of Land surface Temperature and Ground Elevation around a region of interest. We define this region of interest with a rectangle around France using lower left and toper right corners coordinates (longitude and latitude).

# Definition of a region of interest with a rectangle l_left = [-6.1, 40.62] # lower left corner of the rectangle t_right = [10.07, 52.73] # toper right corner of the rectangle roi = ee.Geometry.Rectangle([l_left, t_right]) # region of interest

Also, we have to convert the LST ImageCollection into an Image, for example by taking the mean value of each pixel over the period of interest. And we convert the value of pixels into Celsius:

# ImageCollection to Image using the mean() method: LST_im = LST.mean() # Operation to take into account the scale factor: LST_im = LST_im.select('LST_Day_1km').multiply(0.02) # Kelvin -&amp;amp;amp;amp;amp;gt; Celsius LST_im = LST_im.select('LST_Day_1km').add(-273.15)

Then, we use the *getThumbUrl()* method to get an url and we can use the IPython librairy to plot the mean daytime LST map on the region of interest. Blue represents the coldest areas (< 10°C) and red represents the warmest areas (>30°C). (For me, the following run is quite long and the image can pop some times after the task appears to be finished)

from IPython.display import Image # Create the url associated to the Image you want url = LST_im.getThumbUrl({'min': 10, 'max': 30, 'dimensions': 512, 'region' : roi, 'palette': ['blue', 'yellow', 'orange', 'red']}) print(url) # Display a thumbnail Land Surface Temperature in France Image(url = url)

We do the same for ground elevation:

# We create a filter and apply a mask to get hide oceans and seas url2 = ELV.updateMask(ELV.gt(0)).getThumbUrl({'min': 0, 'max': 2500, 'region' : roi, 'dimensions': 512, 'palette': ['006633', 'E5FFCC', '662A00', 'D8D8D8', 'F5F5F5']}) Image(url = url2)

Of course you may want to have a closer look around a specific part of the map. So let’s define an other region, adjust the min/max scale and display:

# We create a buffer zone of 10km around our point of interest lyon = u_poi.buffer(10000) url3 = ELV.getThumbUrl({'min': 150, 'max': 350, 'region' : lyon, 'dimensions': 512, 'palette': ['006633', 'E5FFCC', '662A00', 'D8D8D8', 'F5F5F5']}) Image(url = url3)

In case you want to display an image over a given reagion (and nbot outside), we can clip our dataset using the region as an argument of the *clip()* method. Let’s say that we want to display the ground elevation in France. We can get the geometry of the administrative boundary of France with the FAO featureCollection and do as same as before:

# We get the feature collection of Administrative boundaries (level0) countries = ee.FeatureCollection('FAO/GAUL/2015/level0').select('ADM0_NAME') # We filter the featureCollection to get the feature we want france = countries.filter(ee.Filter.eq('ADM0_NAME', 'France')) # We clip the image on France ELV_fr = ELV.updateMask(ELV.gt(0)).clip(france) # Create the url associated to the Image you want url4 = ELV_fr.getThumbUrl({'min': 0, 'max': 2500, 'region' : roi, 'dimensions': 512, 'palette': ['006633', 'E5FFCC', '662A00', 'D8D8D8', 'F5F5F5']}) # Display a thumbnail of elevation in France. Image(url = url4)

After manipulating Earth Engine datasets, you may need to export a resulting Image to a geotiff. For example, to use it as an input of a numerical model, or to overlap it with personnal georeferecend files in your favorite GIS. There are multiple ways to do that. Here we explore two:

- In the first option, we save the Image you want in the Google Drive,
- In the second option, we directly download the Image.

To export the image to our Google Drive, we have to define a task and start it. We have to specify the size of pixels (here 30 m), the projection (here EPSG:4326), the file format (here GeoTIFF) and the region of interest (here the area of lyon defined before).

task = ee.batch.Export.image.toDrive(image = ELV, description = 'Elevation_near_Lyon_France', scale = 30, region = lyon.getInfo()['coordinates'], fileNamePrefix = 'my_export_Lyon', crs = 'EPSG:4326', fileFormat = 'GeoTIFF') task.start()

Then we can check the status of our task:

task.status()

Now you can check your google drive to find your file.

Similarly, we can use the *getDownloadUrl()* method and click on the provided link:

link = ELV.getDownloadUrl({ 'scale': 30, 'crs': 'EPSG:4326', 'fileFormat' : 'GeoTIFF', 'region': lyon.getInfo()['coordinates']}) print(link)

To display these GEE datasets on an interactive map, let me introduce you to folium. Folium is a python library based on leaflet.js (Open-source JavaScript library for mobile-friendly interactive maps) that you can use to make interactive maps. Folium supports WMS, GeoJSON layers, vector layers and tile layers which make it very convenient and straightforward to visulatise the data we manipulate with python. To install this folium, we use the python package installer:

!pip install folium

Then we create our first interactive map with one line of code, specifying the location where we want to center the map, the zoom strat, and the main dimensions of the map:

import folium my_map = folium.Map(location=[45.77, 4.855], zoom_start=10, width = 600) my_map

On top of this map, we now want to add the GEE layers we studied before: Land Cover (LC), Land Surface Temperature (LST) and Ground Elevation Model (GEM). For each GEE dataset, the process consists in adding a new tile layer to our map specifying some visualisation parameters. Particularly, we want to respect the common LC classes defined in the table of the previous section (hexadecimal codes are given for each class: water bodies are blue, urban area are grey, forests are green, etc.). Then we define visualisations parameters associated to LC as follow:

# Set visualization parameters for land cover. LCvis_params = {'min': 1,'max': 17, 'palette': ['05450a','086a10', '54a708', '78d203', '009900', 'c6b044', 'dcd159', 'dade48', 'fbff13', 'b6ff05', '27ff87', 'c24f44', 'a5a5a5', 'ff6d4c', '69fff8', 'f9ffa4', '1c0dff']}

Then, we use the getMapId method on our LC dataset to get the URL associated to the LC tiles, and we use it as the tiles argument of the TileLayer method applied to our map:

LC = LC.select('LC_Type1').filterDate(i_date) #selection of appropriate bands and dates for LC # We create a map my_map = folium.Map(location=[45.77, 4.855], zoom_start=8) # We get the url associted to the tiles we want to add on our map LC_ee_url_tiles = LC.getMapId(LCvis_params)['tile_fetcher'].url_format # We add a new tile layers to our map folium.TileLayer( tiles = LC_ee_url_tiles, attr = 'Google Earth Engine', name = 'Land Cover', overlay = True, control = True ).add_to(my_map) # We add a layer control button on our map, and we do not want it collapsed: folium.LayerControl(collapsed = False).add_to(my_map) # We display the result: my_map

Of course we can add other datasets similarly, by defining some visualization parameters and by addind the approriate tiles:

# Set visualization parameters for the ground elevation ELVvis_params = { 'min': 0, 'max': 4000, 'palette': ['006633', 'E5FFCC', '662A00', 'D8D8D8', 'F5F5F5']} # We get the url associted to the GEM tiles ELV_ee_url_tiles = ELV.updateMask(ELV.gt(0)).getMapId(ELVvis_params)['tile_fetcher'].url_format # Set visualization parameters for land surface Temperature. LSTvis_params = { 'min': 0, 'max': 40, 'palette': ['white','blue','green','yellow','orange','red']} # Here we consider that we want to disply the mean LST over the year 2016 (previously defined). # ImageCollection to Image using the mean() method: LST_im = LST.mean() # Operation to take into account the scale factor and conversion into Celsius: LST_im = LST_im.select('LST_Day_1km').multiply(0.02).add(-273.15) # We get the url associted to the LST tiles LST_ee_url_tiles = LST_im.getMapId(LSTvis_params)['tile_fetcher'].url_format # We arrange our tiles urls inside a list: ee_tiles = [ELV_ee_url_tiles, LST_ee_url_tiles, LC_ee_url_tiles] # We arrange our tiles names inside a list: ee_tiles_names = ['Elevation', 'Land Surface Temperature', 'Land Cover'] # We create a new map my_map = folium.Map(location=[45.77, 4.855], zoom_start=5) # We make a loop over our layers for ee_tile, name in zip(ee_tiles, ee_tiles_names): folium.TileLayer( tiles = ee_tile, attr = 'Google Earth Engine', name = name, overlay = True, control = True ).add_to(my_map) folium.LayerControl(collapsed = False).add_to(my_map) my_map

Finally, the map can be saved in *html* using the command below. Then, you can open it with your favorite navigator.

my_map.save('my_LC_LST_ELV_interactive_map.html')

This post is available as a notebook and can be run on Goolge Colab.

- The full documentation of the Google Earth Engine Pyhton API is available here
- The Google Earth engine User Guide is available here
- Some tutorials are available here
- An example based on the Google Earth Engine Javascript console dedicated to Land Surface Temperature estimation is provided in the open access supplementary material of Benz et al., (2017). You can access the code here.

The thermal impact assessment is a crucial step in a geothermal project. Particularly in regards of geothermal energy planning. In this regard, analytical solutions are straightforward tools for a preliminary impact assessment. In this article we explore three analytical solutions which can help to estimate the thermal impact caused by groundwater heat pump systems. The valididy of these (semi-)analytical solutions was discussed in details by Pophillat et al. (2020) [1]. Now, let’s dive into these equations.

Here we study the thermal impact of a hot water injection in an aquifer. The parameters of the problem read as follow:

- we study the thermal impact in a 2D plane x, y,
- the water injection rate is Q_{inj} and temperature difference between injection groundwater background temperature is \Delta T_{inj},
- the groundwater is flowing with an angle \alpha from positives x
- the injection is located in X_{0}, Y_{0} ,
- the background groundwater seepgae velocity is v_{a}, the effective porosity of the aquifer is n and its thickness is ( b ),
- we also need to define some hydraulic/thermal parameters such as : thermal conductivity of the aquifer ( \lambda _{m} ), volumetric heat capacity of the aquifer and of water ( C _{m,w} ), longitudinal and transverse dispersivities ( \alpha _{L,T} ),
- we calculate the temperature alteration after a time t of injection.

To define our models of intereset, we first need to import some usefull librairies.

import numpy as np import matplotlib as mpl import matplotlib.pyplot as plt from scipy.interpolate import griddata from scipy.special import erf,erfc,erfcinv

The radial heat transport model [2] is appropriate when the background groundwater flow velocity is almost zero. In that case, the injection of the heated water creates a radial thermal disturbance according to following equation:

\Delta T(x,y,t ) = \frac{1}{2} \text{erfc} \left(\frac{r^{2}- r*^{2}}{2 \left(\frac{4}{3} \alpha _{L} r*^{3} + \frac{\lambda _{m}}{A_{T} C_{m}} r*^{4}\right)^{1/2} }\right)

with: r* = \left( 2 A_{T}t \right)^{1/2}, A_{T} = \frac{Q_{inj}}{2R \pi n b}, and R = \frac{C_{m}}{nC_{w}}.

def RHT(x,y,X0,Y0,t,Qinj,DTinj,b,va,n,C_m,C_w,alpha_L,alpha_T,lambda_m): R = C_m/(n*C_w) At = (1/R)*(Qinj/(2*np.pi*n*b)) rp = np.sqrt(2*At*t) x = x - X0 y = y - Y0 r = np.sqrt(x**2+y**2) return (DTinj/2)*erfc((r**2-rp**2)/(2*np.sqrt((4*alpha_L/3)*(rp**3)+(lambda_m/At/C_m)*(rp**4))))

The planar advective heat transport model [3] accounts for the fact that injection may induce a high local hydraulic gradient around the injection well. In such a case, the geometry of this heat source cannot be considered only with a vertical line. Instead, the source is represented as an area in the yz-plane. Accordingly, in a 2D horizontal projection in the xy-plane, the heat source corresponds to a line perpendicular to the groundwater flow direction. Please note that this model is undefined upstream the injection (for x < 0).

\Delta T(x,y,t) = \frac{\Delta T_{0}}{4} \text{erfc}\left( \frac{Rx - v_{a}t}{2 \sqrt(D_{x}Rt)}\right) \left( \text{erf}\left( \frac{y + Y/2}{2 \sqrt(D_{y}x/v_{a})}\right) - \text{erf}\left( \frac{y - Y/2}{2 \sqrt(D_{y}x/v_{a})}\right)\right)

with \Delta T_{0} = \frac{F_{0}}{v_{a}nC_{w}Y}, F_{0} = \frac{q_{h}}{b}, q_{h} = \Delta T_{inj} C_{w} Q_{inj}, D_{x,y} = \frac{\lambda _{m}}{n C_{w}} + \alpha _{L,T} v_{a}, and Y = \frac{Q_{inj}}{2b v_{a} n}.

def PAHT(x, y, X0, Y0, alpha, t, Qinj, DTinj, b, va, n, C_m, C_w, alpha_L, alpha_T, lambda_m): Y = Qinj/(2*b*va*n) qh = DTinj*C_w*Qinj F0 = qh/b DT0 = F0/(va*n*C_w*Y) Dx = lambda_m/n/C_w + alpha_L*va Dy = lambda_m/n/C_w + alpha_T*va R = C_m/(n*C_w) alpha_rad = -alpha*np.pi/180 x1 = x - X0 y1 = y - Y0 x2 = np.cos(alpha_rad)*x1 - np.sin(alpha_rad)*y1 y2 = np.sin(alpha_rad)*x1 + np.cos(alpha_rad)*y1 res = DT0/4*erfc((R*x2 - va*t)/(2*np.sqrt(Dx*R*t)))*\ (erf((y2 + Y/2)/(2*np.sqrt(Dy*x2/va))) - erf((y2 - Y/2)/(2*np.sqrt(Dy*x2/va)))) return res

The linear model [4] is appropriate for higher groundwater flow velocities. It describes heat propagation from an injection well with transient

conditions, simulated as continuous line-source, considering background flow:

\Delta T(x,y,t) = \frac{Q_{inj} \Delta T_{inj}}{4 n b v_{a} \sqrt(\pi \alpha _ {T})} \text{exp} \left( \frac{x -r'}{2 \alpha _{L}}\right)\frac{1}{\sqrt(r')} \text{erfc} \left( \frac{r' -v_{a}t/R}{2 \sqrt(v_{a} \alpha _{L}t/R)} \right)

with r' = \left( x^{2} + y^{2} \frac{\alpha _{L}}{\alpha _{T}}\right) ^{1/2} .

To apply one of these models, we need to define a grid over a region of interest. So we need to define the extent of interest (X_{min}, X_{max} and Y_{min},Y_{max} ) and to discretize this area. We can do that using *linespace* and *meshgrid* functions of the *numpy* librairy :

#definition of your grid from Xmin to Xmax, and from Ymin to Ymax Xmin = -100 Xmax = 100 xgrid_len = 200 Ymin = -100 Ymax = 100 ygrid_len = 200 #You create your grif of interest xi = np.linspace(Xmin, Xmax, xgrid_len) yi = np.linspace(Ymin, Ymax, ygrid_len) xi, yi = np.meshgrid(xi, yi)

Before using our models over our grid, we need to define the parameters of the problem. So let’s take the values below as example, and let’s assume that we calculate the thermal impact after one year of hot water injection:

C_m = float(2888000) #volumetric heat capacity porous media (J/ kg / K) C_w = float(4185000) #volumetric heat capacity water (J/ kg / K) alpha_L = float(5) #longitudinal dispersivity (m) alpha_T = float(0.5) #transverse dispersivity (m) lambda_m = float(2.24) #thermal conductivity (W/m/K) alpha = float(20) # groundwater flow angle K = float(0.001) #permeability (m/s) b = float(10) #aquifer thickness [m] grad_h = float(0.001) #hydraulic gradient n = float(0.2) #effective porosity v0 = K*grad_h #darcy velocity va = v0/n #seepage velocity R = C_m/(n*C_w) #retardation factor #We also define the location of the hot water injection X0, Y0 = 20, 20 DTinj = 10. #temperature difference between pumping and reinjection Qinj = 0.0001 #injection rate m3/s time = 365*24*3600 # operation time in seconds (365 days)

Then, we can calculate the thermal impact over our grid using the three models previously defined. Please note that in the case of the radial model, there is not background groundwater velocity:

deltaT_rht = RHT(xi, yi, X0, Y0, time, Qinj, DTinj, b, va, n, C_m, C_w, alpha_L, alpha_T, lambda_m) deltaT_paht = PAHT(xi, yi, X0, Y0, alpha, time, Qinj, DTinj, b, va, n, C_m, C_w, alpha_L, alpha_T, lambda_m) deltaT_laht = LAHT(xi, yi, X0, Y0, alpha, time, Qinj, DTinj, b, va, n, C_m, C_w, alpha_L, alpha_T, lambda_m)

Now we can create our figure using matplotlib. The three subplots represent the thermal impact calculation using the Radial, Planar and Linear model:

#We define the titles of our subplots titles =["Radial model (no groundwater velocity)", "Planar model", "Linear model"] results = [deltaT_rht, deltaT_paht, deltaT_laht] fig, axs = plt.subplots(1,3, figsize=(23,6)) for i in range(3): ax = axs[i] ax.set_title(titles[i]) ax.axis('equal') cf= ax.contourf(xi, yi, results[i], [1,2,3,4,5,6,7,8,9,10], cmap='viridis') # analytical contour (1 K disturbance) ax.grid(color='black', linestyle='-', linewidth=0.1) ax.scatter(X0, Y0, color='red', label = "Injection point") ax.set_xlim(0, 100) ax.set_ylim(0, 100) ax.legend() fig.colorbar(cf, ax=axs.ravel().tolist(), label = "Temperature disturbance [K]") plt.show()

In case you want to calculate the thermal impact caused by several installations, you can iterate over your installations and calculate the total thermal impact (of course you have to assume that thermal impacts are additive which is not rigourously the case. However, this assumption can help to have an idea of the thermal stress of an higly sollicited area).

Let’s choose the linear model and calculate the thermal impact of three installations:

#inistialize an array of 0 with the good size deltaT_m = xi - xi #add the impact of the first instalation X0_1, Y0_1 = 0, 0 #location of the first installation deltaT_m += LAHT(xi, yi, X0_1, Y0_1, 20, time, 0.0002, 10, b, 5*va, n, C_m, C_w, alpha_L, alpha_T, lambda_m) #add the impact of the second instalation X0_2, Y0_2 = 0, 50 #location of the second installation deltaT_m += LAHT(xi, yi, X0_2, Y0_2, 25, time, 0.0001, 10, b , 5*va, n, C_m, C_w, alpha_L, alpha_T, lambda_m) #add the impact of the third instalation X0_3, Y0_3 = -50, -50 #location of the third installation deltaT_m += LAHT(xi, yi, X0_3, Y0_3, 0, time, 0.0001, 10, b, 5*va, n, C_m, C_w, alpha_L, alpha_T, lambda_m) #Creation of the figure fig, ax = plt.subplots(figsize=(7,7)) ax.set_title("Cumulative impact of multiple injections") ax.axis('equal') cf= ax.contourf(xi, yi, deltaT_m, [1,2,3,4,5,6,7,8,9,10], cmap='viridis') ax.grid(color='black', linestyle='-', linewidth=0.1) ax.scatter(X0_1, Y0_1, color='red', label = "Injection point 1") ax.scatter(X0_2, Y0_2, color='yellow', label = "Injection point 2") ax.scatter(X0_3, Y0_3, color='orange', label = "Injection point 3") ax.legend() fig.colorbar(cf, label = "Temperature disturbance [K]") plt.show()

It can be convenient to export our result in a shapefile. With a shapefile, we can project the results over a geographic map in our favourite GIS (The procedure to plot these kind of result on *basemaps *or on interactive* folium *map is not covered in this article but will be presented in a future post) and cross the result with other geographical data.

In the following a procedure to transform your *numpy* array associated to the thermal disturbance into a *geoDataframe* is presented. We’ll finally export this *geoDataframe* as a shapefile.

Let’s first import the librairies we need to do that job:

import pandas as pd from shapely.geometry import Point from geopandas import GeoDataFrame

Then, we can transform our *numpy* array calculated in the previous section into a pandas *dataFrame*:

dat = np.array([xi, yi, deltaT_m]).reshape(3, -1).T df = pd.DataFrame(dat) df.columns = ['X', 'Y', 'DeltaT'] df = df.dropna() df.head()

X | Y | DeltaT | |
---|---|---|---|

0 | -100.000000 | -100.0 | 2.373318e-10 |

1 | -98.994975 | -100.0 | 2.709839e-10 |

2 | -97.989950 | -100.0 | 3.092207e-10 |

3 | -96.984925 | -100.0 | 3.526389e-10 |

4 | -95.979899 | -100.0 | 4.019085e-10 |

Now, we can use *shapely* and *geopandas* to transforme the dataframe into a geodataframe. Fisrt a geometry field is created in our dataframe. Secondly, the *geodataframe* function is used sepecifying the geometry field of the dataframe.

df['geometry'] = df.apply(lambda x: Point((float(x.X), float(x.Y))), axis=1) geopd = GeoDataFrame(df, geometry='geometry') #Here is you geodataframe geopd.head()

X | Y | DeltaT | geometry | |
---|---|---|---|---|

0 | -100.000000 | -100.0 | 2.373318e-10 | POINT (-100.000 -100.000) |

1 | -98.994975 | -100.0 | 2.709839e-10 | POINT (-98.995 -100.000) |

2 | -97.989950 | -100.0 | 3.092207e-10 | POINT (-97.990 -100.000) |

3 | -96.984925 | -100.0 | 3.526389e-10 | POINT (-96.985 -100.000) |

4 | -95.979899 | -100.0 | 4.019085e-10 | POINT (-95.980 -100.000) |

Finally, our file can be exported using the *to_file()* method:

geopd.to_file('my_result.shp', driver='ESRI Shapefile')

This post is available as a notebook and can be run on Goolge Colab. What you need is a google account.

**[1]** Pophillat, W., Attard, G., Bayer, P., Hecht-Méndez, J., & Blum, P. (2020). Analytical solutions for predicting thermal plumes of groundwater heat pump systems. *Renewable Energy*, *147*, 2696-2707.

**[2] **Guimerà, J., Ortuño, F., Ruiz, E., Delos, A., & Pérez-Paricio, A. (2007, May). Influence of ground-source heat pumps on groundwater. In *Conference Proceedings: European Geothermal Congress*.

**[3] **Hähnlein, S., Molina-Giraldo, N., Blum, P., Bayer, P., & Grathwohl, P. (2010). Ausbreitung von Kältefahnen im Grundwasser bei Erdwärmesonden. *Grundwasser*, *15*(2), 123-133.

**[4] **Kinzelbach, W. (1987). *Numerische Methoden zur Modellierung des Transports von Schadstoffen im Grundwasser*. Oldenbourg.