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 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:

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

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 > 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', "Extraction well", "New project"], 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 > 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): """ 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.