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): """ We create a function with an array as input We return a pandas df """ 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;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.