diff --git a/pygfunction/__init__.py b/pygfunction/__init__.py index 61ca2bad..f047f8fd 100644 --- a/pygfunction/__init__.py +++ b/pygfunction/__init__.py @@ -6,4 +6,5 @@ from . import media from . import networks from . import pipes +from . import solvers from . import utilities diff --git a/pygfunction/borefield.py b/pygfunction/borefield.py index 8641e56c..8549002d 100644 --- a/pygfunction/borefield.py +++ b/pygfunction/borefield.py @@ -1,4 +1,5 @@ # -*- coding: utf-8 -*- +from collections.abc import Callable from typing import Union, List, Dict, Tuple from typing_extensions import Self # for compatibility with Python <= 3.10 @@ -6,8 +7,9 @@ from matplotlib.figure import Figure import numpy as np import numpy.typing as npt +from scipy.spatial.distance import pdist, squareform -from .boreholes import Borehole +from .boreholes import Borehole, _EquivalentBorehole from .utilities import _initialize_figure, _format_axes, _format_axes_3d @@ -45,10 +47,9 @@ def __init__( self, H: npt.ArrayLike, D: npt.ArrayLike, r_b: npt.ArrayLike, x: npt.ArrayLike, y: npt.ArrayLike, tilt: npt.ArrayLike = 0., orientation: npt.ArrayLike = 0.): - # Convert x and y coordinates to arrays - x = np.atleast_1d(x) - y = np.atleast_1d(y) - self.nBoreholes = np.maximum(len(x), len(y)) + self.nBoreholes = np.max( + [len(np.atleast_1d(var)) + for var in (H, D, r_b, x, y, tilt, orientation)]) # Broadcast all variables to arrays of length `nBoreholes` self.H = np.broadcast_to(H, self.nBoreholes) @@ -62,6 +63,8 @@ def __init__( self._is_tilted = np.broadcast_to( np.greater(np.abs(tilt), 1e-6), self.nBoreholes) + self._all_tilted = np.all(self._is_tilted) + self._all_vertical = not np.any(self._is_tilted) # Vertical boreholes default to an orientation of zero if not np.any(self._is_tilted): self.orientation = np.broadcast_to(0., self.nBoreholes) @@ -109,6 +112,99 @@ def __ne__( check = not self == other_field return check + @property + def all_tilted(self) -> np.ndarray: + """Return a bool. True if the all boreholes are inclined.""" + return self._all_tilted + + @property + def all_vertical(self) -> np.ndarray: + """Return an bool. True if all boreholes are vertical.""" + return self._all_vertical + + @property + def is_tilted(self) -> np.ndarray: + """Return an boolean array. True if the corresponding borehole is inclined.""" + return self._is_tilted + + @property + def is_vertical(self) -> np.ndarray: + """Return an boolean array. True if the corresponding borehole is vertical.""" + return np.logical_not(self._is_tilted) + + def distance( + self, other_field: Union[Borehole, Self, List[Borehole]], + outer: bool = True + ) -> np.ndarray: + """ + Evaluate horizontal distances to boreholes of another borefield. + + Parameters + ---------- + other_field : Borehole or Borefield object + Borefield with which to evaluate distances. + outer : , optional + True if the horizontal distance is to be evaluated for all + boreholes of the borefield onto all boreholes of the other + borefield to return a (nBoreholes_other_field, nBoreholes,) + array. If false, the horizontal distance is evaluated pairwise + between boreholes of the borefield and boreholes of the other + borefield. The numbers of boreholes should be the same + (i.e. nBoreholes == nBoreholes_other_field) and a (nBoreholes,) + array is returned. + Default is True. + + Returns + ------- + dis : array, shape (nBoreholes_other_field, nBoreholes,) or (nBoreholes,) + Horizontal distances between boreholes (in meters). + + """ + if outer: + dis = np.maximum( + np.sqrt( + np.subtract.outer(other_field.x, self.x)**2 + + np.subtract.outer(other_field.y, self.y)**2 + ), + self.r_b) + else: + dis = np.maximum( + np.sqrt( + (other_field.x - self.x)**2 + (other_field.y - self.y)**2), + self.r_b) + return dis + + def distance_to_self( + self, outer: bool = True) -> np.ndarray: + """ + Evaluate horizontal distances between boreholes within the borefield. + + Parameters + ---------- + outer : , optional + True if the horizontal distance is to be evaluated for all + boreholes of the borefield onto all of the boreholesto return a + (nBoreholes, nBoreholes,) array. If false, a condensed distance + vector corresponding to the upper triangle of the distance matrix + is return (excluding the diagonal elements). + Default is True. + + Returns + ------- + dis : array, shape (nBoreholes, nBoreholes,) or (nBoreholes,) + Horizontal distances between boreholes (in meters). + + """ + # Condensed vector of the distances between boreholes + dis = pdist(np.stack((self.x, self.y), axis=1)) + if outer: + # Convert to a square matrix + dis = squareform(dis) + # Overwrite the diagonal with the borehole radii + i, j = np.diag_indices(len(self)) + dis[i, j] = self.r_b + return dis + def evaluate_g_function( self, alpha: float, @@ -306,6 +402,77 @@ def evaluate_g_function( return gfunc.gFunc + def segments( + self, nSegments: Union[int, npt.ArrayLike], + segment_ratios: Union[ + None, npt.ArrayLike, List[npt.ArrayLike], Callable[[int], npt.ArrayLike] + ] = None) -> Self: + """ + Split boreholes in the bore field into segments. + + Parameters + ---------- + nSegments : int or (nBoreholes,) array + Number of segments per borehole. + segment_ratios : array or list of arrays, optional + Ratio of the borehole length represented by each segment. The sum + of ratios must be equal to 1. The shape of the array is of + (nSegments,). If all boreholes have the same number of segments + and the same ratios, a single (nSegments,) array can be provided. + Otherwise, a list of (nSegments_i,) arrays with the ratios + associated with each borehole must be provided. If + segment_ratios==None, segments of equal lengths are considered. + Default is None. + + Returns + ------- + segments : Borefield object + Segments in the bore field. + + Examples + -------- + >>> borefield = gt.borefield.Borefield.rectangle_field( + N_1=2, N_2=3, B_1 = 7.5, B_2=7.5, H=150., D=4., r_b=0.075) + >>> borefield.segments(5) + + """ + nSegments = np.broadcast_to(nSegments, self.nBoreholes) + if callable(segment_ratios): + segment_ratios = [segment_ratios(nSeg) for nSeg in nSegments] + if segment_ratios is None: + # Local coordinates of the top-edges of the segments + xi = np.concatenate( + [np.arange(nSeg_i) / nSeg_i for nSeg_i in nSegments]) + # Segment ratios + segment_ratios = np.concatenate( + [np.full(nSeg_i, 1. / nSeg_i) for nSeg_i in nSegments]) + elif isinstance(segment_ratios, list): + # Local coordinates of the top-edges of the segments + xi = np.concatenate( + [np.cumsum(np.concatenate([[0.], seg_rat_i[:-1]])) + for seg_rat_i in segment_ratios]) + # Segment ratios + segment_ratios = np.concatenate(segment_ratios) + else: + # Local coordinates of the top-edges of the segments + xi = np.tile( + np.cumsum(np.concatenate([[0.], segment_ratios[:-1]])), + self.nBoreholes) + # Segment ratios + segment_ratios = np.tile(segment_ratios, self.nBoreholes) + xi = xi * np.repeat(self.H, nSegments) + + # Geometrical parameters of the segments + H = np.repeat(self.H, nSegments) * segment_ratios + r_b = np.repeat(self.r_b, nSegments) + tilt = np.repeat(self.tilt, nSegments) + orientation = np.repeat(self.orientation, nSegments) + D = np.repeat(self.D, nSegments) + xi * np.cos(tilt) + x = np.repeat(self.x, nSegments) + xi * np.sin(tilt) * np.cos(orientation) + y = np.repeat(self.y, nSegments) + xi * np.sin(tilt) * np.sin(orientation) + + return Borefield(H, D, r_b, x, y, tilt=tilt, orientation=orientation) + def visualize_field( self, viewTop: bool = True, view3D: bool = True, labels: bool = True, showTilt: bool = True) -> Figure: @@ -1098,3 +1265,97 @@ def circle_field( # Create the bore field borefield = cls(H, D, r_b, x, y, tilt=tilt, orientation=orientation) return borefield + + +class _EquivalentBorefield(object): + + def __init__( + self, H: npt.ArrayLike, D: npt.ArrayLike, r_b: npt.ArrayLike, + x: List[npt.ArrayLike], y: List[npt.ArrayLike], + tilt: npt.ArrayLike, orientation: List[npt.ArrayLike]): + self.H = H + self.D = D + self.r_b = r_b + self.x = x + self.y = y + self.tilt = tilt + self.orientation = orientation + self.nBoreholes = len(x) + + # Identify tilted boreholes + self._is_tilted = np.broadcast_to( + np.greater(np.abs(tilt), 1e-6), + self.nBoreholes) + # Vertical boreholes default to an orientation of zero + if not np.any(self._is_tilted): + self.orientation = [np.broadcast_to(0., len(x_i)) for x_i in x] + else: + self.orientation = [ + np.multiply(orientation_i, _is_tilted_i) + for (orientation_i, _is_tilted_i) + in zip(self.orientation, self._is_tilted)] + + def __getitem__(self, key): + if isinstance(key, (int, np.integer)): + # Returns a borehole object if only one borehole is indexed + output_class = _EquivalentBorehole + else: + # Returns a _EquivalentBorehole object for slices and lists of + # indexes + output_class = _EquivalentBorefield + return output_class( + self.H[key], self.D[key], self.r_b[key], self.x[key], self.y[key], + tilt=self.tilt[key], orientation=self.orientation[key]) + + def __len__(self) -> int: + """Return the number of boreholes.""" + return self.nBoreholes + + def segments( + self, nSegments: int, + segment_ratios: Union[ + None, npt.ArrayLike, Callable[[int], npt.ArrayLike] + ] = None) -> Self: + """ + Split boreholes in the bore field into segments. + + Parameters + ---------- + nSegments : int or (nBoreholes,) array + Number of segments per borehole. + segment_ratios : array or list of arrays, optional + Ratio of the borehole length represented by each segment. The sum + of ratios must be equal to 1. The shape of the array is of + (nSegments,). If all boreholes have the same number of segments + and the same ratios, a single (nSegments,) array can be provided. + Otherwise, a list of (nSegments_i,) arrays with the ratios + associated with each borehole must be provided. If + segment_ratios==None, segments of equal lengths are considered. + Default is None. + + Returns + ------- + segments : Borefield object + Segments in the bore field. + + Examples + -------- + >>> borefield = gt.borefield.Borefield.rectangle_field( + N_1=2, N_2=3, B_1 = 7.5, B_2=7.5, H=150., D=4., r_b=0.075) + >>> borefield.segments(5) + + """ + return _EquivalentBorefield.from_equivalent_boreholes( + [seg for b in self for seg in b.segments(nSegments, segment_ratios=segment_ratios) + ]) + + @classmethod + def from_equivalent_boreholes(cls, equivalent_boreholes): + H = np.array([b.H for b in equivalent_boreholes]) + D = np.array([b.D for b in equivalent_boreholes]) + r_b = np.array([b.r_b for b in equivalent_boreholes]) + x = [b.x for b in equivalent_boreholes] + y = [b.y for b in equivalent_boreholes] + tilt = np.array([b.tilt for b in equivalent_boreholes]) + orientation = [b.orientation for b in equivalent_boreholes] + return cls(H, D, r_b, x, y, tilt, orientation) diff --git a/pygfunction/boreholes.py b/pygfunction/boreholes.py index 0cc8e709..9ed01794 100644 --- a/pygfunction/boreholes.py +++ b/pygfunction/boreholes.py @@ -1,5 +1,7 @@ # -*- coding: utf-8 -*- import warnings +from typing import Union +from typing_extensions import Self # for compatibility with Python <= 3.10 import matplotlib.pyplot as plt import numpy as np @@ -158,6 +160,8 @@ def segments(self, nSegments, segment_ratios=None): """ if segment_ratios is None: segment_ratios = np.full(nSegments, 1. / nSegments) + elif callable(segment_ratios): + segment_ratios = segment_ratios(nSegments) z = self._segment_edges(nSegments, segment_ratios=segment_ratios)[:-1] boreSegments = [] for z_i, ratios in zip(z, segment_ratios): @@ -206,6 +210,8 @@ def _segment_edges(self, nSegments, segment_ratios=None): """ if segment_ratios is None: segment_ratios = np.full(nSegments, 1. / nSegments) + elif callable(segment_ratios): + segment_ratios = segment_ratios(nSegments) z = np.concatenate(([0.], np.cumsum(segment_ratios))) * self.H return z @@ -239,6 +245,8 @@ def _segment_midpoints(self, nSegments, segment_ratios=None): """ if segment_ratios is None: segment_ratios = np.full(nSegments, 1. / nSegments) + elif callable(segment_ratios): + segment_ratios = segment_ratios(nSegments) z = self._segment_edges(nSegments, segment_ratios=segment_ratios)[:-1] \ + segment_ratios * self.H / 2 return z @@ -288,36 +296,76 @@ class _EquivalentBorehole(object): Simulation, 14 (4), 446-460. """ - def __init__(self, boreholes): - if isinstance(boreholes[0], Borehole): - self.H = boreholes[0].H - self.D = boreholes[0].D - self.r_b = boreholes[0].r_b - self.x = np.array([b.x for b in boreholes]) - self.y = np.array([b.y for b in boreholes]) - self.tilt = boreholes[0].tilt - self.orientation = np.array([b.orientation for b in boreholes]) - elif isinstance(boreholes[0], _EquivalentBorehole): - self.H = boreholes[0].H - self.D = boreholes[0].D - self.r_b = boreholes[0].r_b - self.x = np.concatenate([b.x for b in boreholes]) - self.y = np.concatenate([b.y for b in boreholes]) - self.tilt = boreholes[0].tilt - self.orientation = np.concatenate( - [b.orientation for b in boreholes]) - elif type(boreholes) is tuple: - self.H, self.D, self.r_b, self.x, self.y = boreholes[:5] - self.x = np.atleast_1d(self.x) - self.y = np.atleast_1d(self.y) - if len(boreholes)==7: - self.tilt, self.orientation = boreholes[5:] - - self.nBoreholes = len(self.x) - # Check if borehole is inclined - self._is_tilted = np.abs(self.tilt) > 1.0e-6 + def __init__(self, H, D, r_b, x, y, tilt=0., orientation=0.): + self.nBoreholes = np.maximum(len(x), len(y)) + + # Broadcast all variables to arrays of length `nBoreholes` + self.H = H + self.D = D + self.r_b = r_b + self.x = x + self.y = y + self.tilt = tilt + + # Identify tilted boreholes + self._is_tilted = np.abs(tilt) > 1e-6 + # Vertical boreholes default to an orientation of zero + if not self._is_tilted: + self.orientation = np.broadcast_to(0., self.nBoreholes) + elif np.all(self._is_tilted): + self.orientation = np.broadcast_to(orientation, self.nBoreholes) + else: + self.orientation = np.multiply(orientation, self._is_tilted) - def distance(self, target): + def __getitem__(self, key): + if isinstance(key, (int, np.integer)): + # Returns a borehole object if only one borehole is indexed + output_class = Borehole + else: + # Returns a _EquivalentBorehole object for slices and lists of + # indexes + output_class = _EquivalentBorehole + return output_class( + self.H, self.D, self.r_b, self.x[key], self.y[key], + tilt=self.tilt, orientation=self.orientation[key]) + + def __len__(self) -> int: + """Return the number of boreholes.""" + return self.nBoreholes + + @classmethod + def from_borefield(cls, borefield): + assert np.allclose(borefield.H, borefield.H[0], rtol=1e-6) + assert np.allclose(borefield.D, borefield.D[0], rtol=1e-6) + assert np.allclose(borefield.r_b, borefield.r_b[0], rtol=1e-6) + assert np.allclose(borefield.tilt, borefield.tilt[0], rtol=1e-6) + H = borefield.H[0] + D = borefield.D[0] + r_b = borefield.r_b[0] + x = borefield.x + y = borefield.y + tilt = borefield.tilt[0] + orientation = borefield.orientation + return cls(H, D, r_b, x, y, tilt=tilt, orientation=orientation) + + @classmethod + def from_boreholes(cls, boreholes): + assert np.allclose([b.H for b in boreholes], boreholes[0].H, rtol=1e-6) + assert np.allclose([b.D for b in boreholes], boreholes[0].D, rtol=1e-6) + assert np.allclose( + [b.r_b for b in boreholes], boreholes[0].r_b, rtol=1e-6) + assert np.allclose( + [b.tilt for b in boreholes], boreholes[0].tilt, rtol=1e-6) + H = boreholes[0].H + D = boreholes[0].D + r_b = boreholes[0].r_b + x = np.array([b.x for b in boreholes]) + y = np.array([b.y for b in boreholes]) + tilt = boreholes[0].tilt + orientation = np.array([b.orientation for b in boreholes]) + return cls(H, D, r_b, x, y, tilt=tilt, orientation=orientation) + + def distance(self, other_borehole: Self) -> np.ndarray: """ Evaluate the distance between the current borehole and a target borehole. @@ -347,10 +395,13 @@ def distance(self, target): """ dis = np.maximum( np.sqrt( - np.add.outer(target.x, -self.x)**2 + np.add.outer(target.y, -self.y)**2), + np.add.outer(other_borehole.x, -self.x)**2 + + np.add.outer(other_borehole.y, -self.y)**2 + ), self.r_b) return dis + @property def is_tilted(self): """ Returns true if the borehole is inclined. @@ -363,6 +414,7 @@ def is_tilted(self): """ return self._is_tilted + @property def is_vertical(self): """ Returns true if the borehole is vertical. @@ -421,15 +473,17 @@ def segments(self, nSegments, segment_ratios=None): """ if segment_ratios is None: segment_ratios = np.full(nSegments, 1. / nSegments) + elif callable(segment_ratios): + segment_ratios = segment_ratios(nSegments) z = self._segment_edges(nSegments, segment_ratios=segment_ratios)[:-1] segments = [_EquivalentBorehole( - (ratios * self.H, - self.D + z_i * np.cos(self.tilt), - self.r_b, - self.x + z_i * np.sin(self.tilt) * np.cos(self.orientation), - self.y + z_i * np.sin(self.tilt) * np.sin(self.orientation), - self.tilt, - self.orientation) + ratios * self.H, + self.D + z_i * np.cos(self.tilt), + self.r_b, + self.x + z_i * np.sin(self.tilt) * np.cos(self.orientation), + self.y + z_i * np.sin(self.tilt) * np.sin(self.orientation), + tilt=self.tilt, + orientation=self.orientation ) for z_i, ratios in zip(z, segment_ratios)] return segments @@ -525,6 +579,8 @@ def _segment_edges(self, nSegments, segment_ratios=None): """ if segment_ratios is None: segment_ratios = np.full(nSegments, 1. / nSegments) + elif callable(segment_ratios): + segment_ratios = segment_ratios(nSegments) z = np.concatenate(([0.], np.cumsum(segment_ratios))) * self.H return z @@ -558,6 +614,8 @@ def _segment_midpoints(self, nSegments, segment_ratios=None): """ if segment_ratios is None: segment_ratios = np.full(nSegments, 1. / nSegments) + elif callable(segment_ratios): + segment_ratios = segment_ratios(nSegments) z = self._segment_edges(nSegments, segment_ratios=segment_ratios)[:-1] \ + segment_ratios * self.H / 2 return z diff --git a/pygfunction/gfunction.py b/pygfunction/gfunction.py index 20cc29b9..746c5d1f 100644 --- a/pygfunction/gfunction.py +++ b/pygfunction/gfunction.py @@ -4,17 +4,13 @@ import matplotlib.pyplot as plt import numpy as np -from scipy.cluster.hierarchy import cut_tree, dendrogram, linkage -from scipy.constants import pi from scipy.interpolate import interp1d as interp1d -from .boreholes import Borehole, _EquivalentBorehole, find_duplicates +from .boreholes import Borehole, find_duplicates from .borefield import Borefield -from .heat_transfer import finite_line_source, finite_line_source_vectorized, \ - finite_line_source_equivalent_boreholes_vectorized, \ - finite_line_source_inclined_vectorized -from .networks import Network, _EquivalentNetwork, network_thermal_resistance +from .networks import Network from .utilities import _initialize_figure, _format_axes +from .solvers import Detailed, Similarities, Equivalent from . import utilities @@ -259,17 +255,17 @@ def __init__(self, boreholes_or_network, alpha, time=None, # Load the chosen solver if self.method.lower()=='similarities': - self.solver = _Similarities( + self.solver = Similarities( self.boreholes, self.network, self.time, self.boundary_condition, self.m_flow_borehole, self.m_flow_network, self.cp_f, **self.options) elif self.method.lower()=='detailed': - self.solver = _Detailed( + self.solver = Detailed( self.boreholes, self.network, self.time, self.boundary_condition, self.m_flow_borehole, self.m_flow_network, self.cp_f, **self.options) elif self.method.lower()=='equivalent': - self.solver = _Equivalent( + self.solver = Equivalent( self.boreholes, self.network, self.time, self.boundary_condition, self.m_flow_borehole, self.m_flow_network, self.cp_f, **self.options) @@ -402,11 +398,11 @@ def visualize_heat_extraction_rates( """ # If iBoreholes is None, then plot all boreholes if iBoreholes is None: - iBoreholes = range(len(self.solver.boreholes)) + iBoreholes = range(len(self.solver.borefield)) # Import heat extraction rates Q_t = self._heat_extraction_rates(iBoreholes) # Borefield characteristic time - ts = np.mean([b.H for b in self.solver.boreholes])**2/(9.*self.alpha) + ts = np.mean([b.H for b in self.solver.borefield])**2/(9.*self.alpha) # Dimensionless time (log) lntts = np.log(self.time/ts) @@ -424,7 +420,7 @@ def visualize_heat_extraction_rates( _format_axes(ax2) # Plot curves for requested boreholes - for i, borehole in enumerate(self.solver.boreholes): + for i, borehole in enumerate(self.solver.borefield): if i in iBoreholes: # Draw heat extraction rate line = ax2.plot(lntts, Q_t[iBoreholes.index(i)]) @@ -479,7 +475,7 @@ def visualize_heat_extraction_rates( _format_axes(ax2) # Plot curves for requested boreholes - for i, borehole in enumerate(self.solver.boreholes): + for i, borehole in enumerate(self.solver.borefield): if i in iBoreholes: # Draw heat extraction rate line = ax2.plot(lntts, Q_t[iBoreholes.index(i)][n]) @@ -551,7 +547,7 @@ def visualize_heat_extraction_rate_profiles( """ # If iBoreholes is None, then plot all boreholes if iBoreholes is None: - iBoreholes = range(len(self.solver.boreholes)) + iBoreholes = range(len(self.solver.borefield)) # Import heat extraction rate profiles z, Q_b = self._heat_extraction_rate_profiles(time, iBoreholes) @@ -570,7 +566,7 @@ def visualize_heat_extraction_rate_profiles( _format_axes(ax2) # Plot curves for requested boreholes - for i, borehole in enumerate(self.solver.boreholes): + for i, borehole in enumerate(self.solver.borefield): if i in iBoreholes: # Draw heat extraction rate profile line = ax2.plot( @@ -627,7 +623,7 @@ def visualize_heat_extraction_rate_profiles( _format_axes(ax2) # Plot curves for requested boreholes - for i, borehole in enumerate(self.solver.boreholes): + for i, borehole in enumerate(self.solver.borefield): if i in iBoreholes: # Draw heat extraction rate profile line = ax2.plot( @@ -695,11 +691,11 @@ def visualize_temperatures( """ # If iBoreholes is None, then plot all boreholes if iBoreholes is None: - iBoreholes = range(len(self.solver.boreholes)) + iBoreholes = range(len(self.solver.borefield)) # Import temperatures T_b = self._temperatures(iBoreholes) # Borefield characteristic time - ts = np.mean([b.H for b in self.solver.boreholes])**2/(9.*self.alpha) + ts = np.mean([b.H for b in self.solver.borefield])**2/(9.*self.alpha) # Dimensionless time (log) lntts = np.log(self.time/ts) @@ -717,7 +713,7 @@ def visualize_temperatures( ax2.set_ylabel(r'$\bar{T}_b$') _format_axes(ax2) # Plot curves for requested boreholes - for i, borehole in enumerate(self.solver.boreholes): + for i, borehole in enumerate(self.solver.borefield): if i in iBoreholes: # Draw borehole wall temperature line = ax2.plot(lntts, T_b[iBoreholes.index(i)]) @@ -771,7 +767,7 @@ def visualize_temperatures( ax2.set_ylabel(r'$\bar{T}_b$') _format_axes(ax2) # Plot curves for requested boreholes - for i, borehole in enumerate(self.solver.boreholes): + for i, borehole in enumerate(self.solver.borefield): if i in iBoreholes: # Draw borehole wall temperature line = ax2.plot(lntts, T_b[iBoreholes.index(i)][n]) @@ -859,7 +855,7 @@ def visualize_temperature_profiles( _format_axes(ax2) # Plot curves for requested boreholes - for i, borehole in enumerate(self.solver.boreholes): + for i, borehole in enumerate(self.solver.borefield): if i in iBoreholes: # Draw borehole wall temperature profile line = ax2.plot( @@ -916,7 +912,7 @@ def visualize_temperature_profiles( _format_axes(ax2) # Plot curves for requested boreholes - for i, borehole in enumerate(self.solver.boreholes): + for i, borehole in enumerate(self.solver.borefield): if i in iBoreholes: # Draw borehole wall temperature profile line = ax2.plot( @@ -982,7 +978,13 @@ def _heat_extraction_rates(self, iBoreholes): # heat extraction rate. i0 = self.solver._i0Segments[i] i1 = self.solver._i1Segments[i] - segment_ratios = self.solver.segment_ratios[i] + segment_ratios = self.solver.segment_ratios + if segment_ratios is None: + segment_ratios = np.full(i1 - i0, 1. / (i1 - i0)) + if isinstance(segment_ratios, list): + segment_ratios = segment_ratios[i] + if callable(segment_ratios): + segment_ratios = self.solver.segment_ratios(i1 - i0) if self.solver.nMassFlow == 0: Q_t.append( np.sum( @@ -1021,6 +1023,9 @@ def _heat_extraction_rate_profiles(self, time, iBoreholes): # Initialize lists z = [] Q_b = [] + nBoreSegments = np.broadcast_to( + self.solver.nSegments, + len(self.solver.borefield)) for i in iBoreholes: if self.boundary_condition == 'UHTR': # For the UHTR boundary condition, the solver only returns one @@ -1028,8 +1033,8 @@ def _heat_extraction_rate_profiles(self, time, iBoreholes): # The heat extraction rate is duplicated to draw from # z = D to z = D + H. z.append( - np.array([self.solver.boreholes[i].D, - self.solver.boreholes[i].D + self.solver.boreholes[i].H])) + np.array([self.solver.borefield[i].D, + self.solver.borefield[i].D + self.solver.borefield[i].H])) Q_b.append(np.array(2*[self.solver.Q_b])) else: i0 = self.solver._i0Segments[i] @@ -1057,21 +1062,25 @@ def _heat_extraction_rate_profiles(self, time, iBoreholes): kind='linear', copy=False, axis=2)(time) - if self.solver.nBoreSegments[i] > 1: + if nBoreSegments[i] > 1: # Borehole length ratio at the mid-depth of each segment - segment_ratios = self.solver.segment_ratios[i] + segment_ratios = self.solver.segment_ratios + if segment_ratios is None: + segment_ratios = np.full(i1 - i0, 1. / (i1 - i0)) + if isinstance(segment_ratios, list): + segment_ratios = segment_ratios[i] z.append( - self.solver.boreholes[i].D \ - + self.solver.boreholes[i]._segment_midpoints( - self.solver.nBoreSegments[i], + self.solver.borefield[i].D \ + + self.solver.borefield[i]._segment_midpoints( + nBoreSegments[i], segment_ratios=segment_ratios)) Q_b.append(Q_bi) else: # If there is only one segment, the heat extraction rate is # duplicated to draw from z = D to z = D + H. z.append( - np.array([self.solver.boreholes[i].D, - self.solver.boreholes[i].D + self.solver.boreholes[i].H])) + np.array([self.solver.borefield[i].D, + self.solver.borefield[i].D + self.solver.borefield[i].H])) if self.solver.nMassFlow == 0: Q_b.append(np.repeat(Q_bi, 2, axis=0)) else: @@ -1106,7 +1115,11 @@ def _temperatures(self, iBoreholes): # borehole wall temperature. i0 = self.solver._i0Segments[i] i1 = self.solver._i1Segments[i] - segment_ratios = self.solver.segment_ratios[i] + segment_ratios = self.solver.segment_ratios + if segment_ratios is None: + segment_ratios = np.full(i1 - i0, 1. / (i1 - i0)) + if isinstance(segment_ratios, list): + segment_ratios = segment_ratios[i] if self.solver.nMassFlow == 0: T_b.append( np.sum( @@ -1144,6 +1157,9 @@ def _temperature_profiles(self, time, iBoreholes): # Initialize lists z = [] T_b = [] + nBoreSegments = np.broadcast_to( + self.solver.nSegments, + len(self.solver.borefield)) for i in iBoreholes: if self.boundary_condition == 'UBWT': # For the UBWT boundary condition, the solver only returns one @@ -1151,8 +1167,8 @@ def _temperature_profiles(self, time, iBoreholes): # boreholes). The temperature is duplicated to draw from # z = D to z = D + H. z.append( - np.array([self.solver.boreholes[i].D, - self.solver.boreholes[i].D + self.solver.boreholes[i].H])) + np.array([self.solver.borefield[i].D, + self.solver.borefield[i].D + self.solver.borefield[i].H])) if time is None: # If time is None, temperatures are extracted at the last # time step. @@ -1196,22 +1212,26 @@ def _temperature_profiles(self, time, iBoreholes): kind='linear', copy=False, axis=2)(time) - if self.solver.nBoreSegments[i] > 1: + if nBoreSegments[i] > 1: # Borehole length ratio at the mid-depth of each segment - segment_ratios = self.solver.segment_ratios[i] + segment_ratios = self.solver.segment_ratios + if segment_ratios is None: + segment_ratios = np.full(i1 - i0, 1. / (i1 - i0)) + if isinstance(segment_ratios, list): + segment_ratios = segment_ratios[i] z.append( - self.solver.boreholes[i].D \ - + self.solver.boreholes[i]._segment_midpoints( - self.solver.nBoreSegments[i], + self.solver.borefield[i].D \ + + self.solver.borefield[i]._segment_midpoints( + nBoreSegments[i], segment_ratios=segment_ratios)) T_b.append(T_bi) else: # If there is only one segment, the temperature is # duplicated to draw from z = D to z = D + H. z.append( - np.array([self.solver.boreholes[i].D, - self.solver.boreholes[i].D + self.solver.boreholes[i].H])) + np.array([self.solver.borefield[i].D, + self.solver.borefield[i].D + self.solver.borefield[i].H])) if self.solver.nMassFlow == 0: T_b.append(np.repeat(T_bi, 2, axis=0)) else: @@ -1754,3061 +1774,3 @@ def mixed_inlet_temperature( boundary_condition=boundary_condition, options=options) return gFunc.gFunc - - -class _BaseSolver(object): - """ - Template for solver classes. - - Solver classes inherit from this class. - - Attributes - ---------- - boreholes : list of Borehole objects - List of boreholes included in the bore field. - network : network object - Model of the network. - time : float or array - Values of time (in seconds) for which the g-function is evaluated. - boundary_condition : str - Boundary condition for the evaluation of the g-function. Should be one - of - - - 'UHTR' : - Uniform heat transfer rate. - - 'UBWT' : - Uniform borehole wall temperature. - - 'MIFT' : - Mixed inlet fluid temperatures. - - nSegments : int or list, optional - Number of line segments used per borehole, or list of number of - line segments used for each borehole. - Default is 8. - segment_ratios : array, list of arrays, or callable, optional - Ratio of the borehole length represented by each segment. The - sum of ratios must be equal to 1. The shape of the array is of - (nSegments,) or list of (nSegments[i],). If segment_ratios==None, - segments of equal lengths are considered. If a callable is provided, it - must return an array of size (nSegments,) when provided with nSegments - (of type int) as an argument, or an array of size (nSegments[i],) when - provided with an element of nSegments (of type list). - Default is :func:`utilities.segment_ratios`. - m_flow_borehole : (nInlets,) array or (nMassFlow, nInlets,) array, optional - Fluid mass flow rate into each circuit of the network. If a - (nMassFlow, nInlets,) array is supplied, the - (nMassFlow, nMassFlow,) variable mass flow rate g-functions - will be evaluated using the method of Cimmino (2024) - [#gFunction-CimBer2024]_. Only required for the 'MIFT' boundary - condition. Only one of 'm_flow_borehole' and 'm_flow_network' can be - provided. - Default is None. - m_flow_network : float or (nMassFlow,) array, optional - Fluid mass flow rate into the network of boreholes. If an array - is supplied, the (nMassFlow, nMassFlow,) variable mass flow - rate g-functions will be evaluated using the method of Cimmino - (2024) [#gFunction-CimBer2024]_. Only required for the 'MIFT' boundary - condition. Only one of 'm_flow_borehole' and 'm_flow_network' can be - provided. - Default is None. - cp_f : float, optional - Fluid specific isobaric heat capacity (in J/kg.degC). Only required - for the 'MIFT' boundary condition. - Default is None. - approximate_FLS : bool, optional - Set to true to use the approximation of the FLS solution of Cimmino - (2021). This approximation does not require the numerical evaluation of - any integral. When using the 'equivalent' solver, the approximation is - only applied to the thermal response at the borehole radius. Thermal - interaction between boreholes is evaluated using the FLS solution. - Default is False. - nFLS : int, optional - Number of terms in the approximation of the FLS solution. This - parameter is unused if `approximate_FLS` is set to False. - Default is 10. Maximum is 25. - mQuad : int, optional - Number of Gauss-Legendre sample points for the integral over :math:`u` - in the inclined FLS solution. - Default is 11. - linear_threshold : float, optional - Threshold time (in seconds) under which the g-function is - linearized. The g-function value is then interpolated between 0 - and its value at the threshold. If linear_threshold==None, the - g-function is linearized for times - `t < r_b**2 / (25 * self.alpha)`. - Default is None. - disp : bool, optional - Set to true to print progression messages. - Default is False. - profiles : bool, optional - Set to true to keep in memory the temperatures and heat extraction - rates. - Default is False. - kind : string, optional - Interpolation method used for segment-to-segment thermal response - factors. See documentation for scipy.interpolate.interp1d. - Default is 'linear'. - dtype : numpy dtype, optional - numpy data type used for matrices and vectors. Should be one of - numpy.single or numpy.double. - Default is numpy.double. - - """ - def __init__(self, boreholes, network, time, boundary_condition, - m_flow_borehole=None, m_flow_network=None, cp_f=None, - nSegments=8, segment_ratios=utilities.segment_ratios, - approximate_FLS=False, mQuad=11, nFLS=10, - linear_threshold=None, disp=False, profiles=False, - kind='linear', dtype=np.double, **other_options): - self.boreholes = boreholes - self.network = network - # Convert time to a 1d array - self.time = np.atleast_1d(time).flatten() - self.linear_threshold = linear_threshold - self.r_b_max = np.max([b.r_b for b in self.boreholes]) - self.boundary_condition = boundary_condition - nBoreholes = len(self.boreholes) - # Format number of segments and segment ratios - if type(nSegments) is int: - self.nBoreSegments = [nSegments] * nBoreholes - else: - self.nBoreSegments = nSegments - if isinstance(segment_ratios, np.ndarray): - segment_ratios = [segment_ratios] * nBoreholes - elif segment_ratios is None: - segment_ratios = [np.full(n, 1./n) for n in self.nBoreSegments] - elif callable(segment_ratios): - segment_ratios = [segment_ratios(n) for n in self.nBoreSegments] - self.segment_ratios = segment_ratios - # Shortcut for segment_ratios comparisons - self._equal_segment_ratios = \ - (np.all(np.array(self.nBoreSegments, dtype=np.uint) == self.nBoreSegments[0]) - and np.all([np.allclose(segment_ratios, self.segment_ratios[0]) for segment_ratios in self.segment_ratios])) - # Boreholes with a uniform discretization - self._uniform_segment_ratios = [ - np.allclose(segment_ratios, - segment_ratios[0:1], - rtol=1e-6) - for segment_ratios in self.segment_ratios] - # Find indices of first and last segments along boreholes - self._i0Segments = [sum(self.nBoreSegments[0:i]) - for i in range(nBoreholes)] - self._i1Segments = [sum(self.nBoreSegments[0:(i + 1)]) - for i in range(nBoreholes)] - self.nMassFlow = 0 - self.m_flow_borehole = m_flow_borehole - if self.m_flow_borehole is not None: - if not self.m_flow_borehole.ndim == 1: - self.nMassFlow = np.size(self.m_flow_borehole, axis=0) - self.m_flow_borehole = np.atleast_2d(self.m_flow_borehole) - self.m_flow = self.m_flow_borehole - self.m_flow_network = m_flow_network - if self.m_flow_network is not None: - if not isinstance(self.m_flow_network, (np.floating, float)): - self.nMassFlow = len(self.m_flow_network) - self.m_flow_network = np.atleast_1d(self.m_flow_network) - self.m_flow = self.m_flow_network - self.cp_f = cp_f - self.approximate_FLS = approximate_FLS - self.mQuad = mQuad - self.nFLS = nFLS - self.disp = disp - self.profiles = profiles - self.kind = kind - self.dtype = dtype - # Check the validity of inputs - self._check_inputs() - # Initialize the solver with solver-specific options - self.nSources = self.initialize(**other_options) - - return - - def initialize(self, *kwargs): - """ - Perform any calculation required at the initialization of the solver - and returns the number of finite line heat sources in the borefield. - - Raises - ------ - NotImplementedError - - Returns - ------- - nSources : int - Number of finite line heat sources in the borefield used to - initialize the matrix of segment-to-segment thermal response - factors (of size: nSources x nSources). - - """ - raise NotImplementedError( - 'initialize class method not implemented, this method should ' - 'return the number of finite line heat sources in the borefield ' - 'used to initialize the matrix of segment-to-segment thermal ' - 'response factors (of size: nSources x nSources)') - return None - - def solve(self, time, alpha): - """ - Build and solve the system of equations. - - Parameters - ---------- - time : float or array - Values of time (in seconds) for which the g-function is evaluated. - alpha : float - Soil thermal diffusivity (in m2/s). - - Returns - ------- - gFunc : float or array - Values of the g-function - - """ - # Number of time values - self.time = time - nt = len(self.time) - # Evaluate threshold time for g-function linearization - if self.linear_threshold is None: - time_threshold = self.r_b_max**2 / (25 * alpha) - else: - time_threshold = self.linear_threshold - # Find the number of g-function values to be linearized - p_long = np.searchsorted(self.time, time_threshold, side='right') - if p_long > 0: - time_long = np.concatenate([[time_threshold], self.time[p_long:]]) - else: - time_long = self.time - nt_long = len(time_long) - # Calculate segment to segment thermal response factors - h_ij = self.thermal_response_factors(time_long, alpha, kind=self.kind) - # Segment lengths - H_b = self.segment_lengths() - if self.boundary_condition == 'MIFT': - Hb_individual = np.array([b.H for b in self.boreSegments], dtype=self.dtype) - H_tot = np.sum(H_b) - if self.disp: print('Building and solving the system of equations ...', - end='') - # Initialize chrono - tic = perf_counter() - - if self.boundary_condition == 'UHTR': - # Initialize g-function - gFunc = np.zeros(nt) - # Initialize segment heat extraction rates - Q_b = 1 - # Initialize borehole wall temperatures - T_b = np.zeros((self.nSources, nt), dtype=self.dtype) - - # Build and solve the system of equations at all times - p0 = max(0, p_long-1) - for p in range(nt_long): - # Evaluate the g-function with uniform heat extraction along - # boreholes - - # Thermal response factors evaluated at time t[p] - h_dt = h_ij.y[:,:,p+1] - # Borehole wall temperatures are calculated by the sum of - # contributions of all segments - T_b[:,p+p0] = np.sum(h_dt, axis=1) - # The g-function is the average of all borehole wall - # temperatures - gFunc[p+p0] = np.sum(T_b[:,p+p0]*H_b)/H_tot - - # Linearize g-function for times under threshold - if p_long > 0: - gFunc[:p_long] = gFunc[p_long-1] * self.time[:p_long] / time_threshold - T_b[:,:p_long] = T_b[:,p_long-1:p_long] * self.time[:p_long] / time_threshold - - elif self.boundary_condition == 'UBWT': - # Initialize g-function - gFunc = np.zeros(nt) - # Initialize segment heat extraction rates - Q_b = np.zeros((self.nSources, nt), dtype=self.dtype) - T_b = np.zeros(nt, dtype=self.dtype) - - # Build and solve the system of equations at all times - p0 = max(0, p_long-1) - for p in range(nt_long): - # Current thermal response factor matrix - if p > 0: - dt = time_long[p] - time_long[p-1] - else: - dt = time_long[p] - # Thermal response factors evaluated at t=dt - h_dt = h_ij(dt) - # Reconstructed load history - Q_reconstructed = self.load_history_reconstruction( - time_long[0:p+1], Q_b[:,p0:p+p0+1]) - # Borehole wall temperature for zero heat extraction at - # current step - T_b0 = self.temporal_superposition( - h_ij.y[:,:,1:], Q_reconstructed) - - # Evaluate the g-function with uniform borehole wall - # temperature - # --------------------------------------------------------- - # Build a system of equation [A]*[X] = [B] for the - # evaluation of the g-function. [A] is a coefficient - # matrix, [X] = [Q_b,T_b] is a state space vector of the - # borehole heat extraction rates and borehole wall - # temperature (equal for all segments), [B] is a - # coefficient vector. - # - # Spatial superposition: [T_b] = [T_b0] + [h_ij_dt]*[Q_b] - # Energy conservation: sum([Q_b*Hb]) = sum([Hb]) - # --------------------------------------------------------- - A = np.block([[h_dt, -np.ones((self.nSources, 1), - dtype=self.dtype)], - [H_b, 0.]]) - B = np.hstack((-T_b0, H_tot)) - # Solve the system of equations - X = np.linalg.solve(A, B) - # Store calculated heat extraction rates - Q_b[:,p+p0] = X[0:self.nSources] - # The borehole wall temperatures are equal for all segments - T_b[p+p0] = X[-1] - gFunc[p+p0] = T_b[p+p0] - - # Linearize g-function for times under threshold - if p_long > 0: - gFunc[:p_long] = gFunc[p_long-1] * self.time[:p_long] / time_threshold - Q_b[:,:p_long] = 1 + (Q_b[:,p_long-1:p_long] - 1) * self.time[:p_long] / time_threshold - T_b[:p_long] = T_b[p_long-1] * self.time[:p_long] / time_threshold - - elif self.boundary_condition == 'MIFT': - if self.nMassFlow == 0: - # Initialize g-function - gFunc = np.zeros((1, 1, nt)) - # Initialize segment heat extraction rates - Q_b = np.zeros((1, self.nSources, nt), dtype=self.dtype) - T_b = np.zeros((1, self.nSources, nt), dtype=self.dtype) - else: - # Initialize g-function - gFunc = np.zeros((self.nMassFlow, self.nMassFlow, nt)) - # Initialize segment heat extraction rates - Q_b = np.zeros( - (self.nMassFlow, self.nSources, nt), dtype=self.dtype) - T_b = np.zeros( - (self.nMassFlow, self.nSources, nt), dtype=self.dtype) - - for j in range(np.maximum(self.nMassFlow, 1)): - # Build and solve the system of equations at all times - p0 = max(0, p_long-1) - a_in_j, a_b_j = self.network.coefficients_borehole_heat_extraction_rate( - self.m_flow[j], - self.cp_f, - self.nBoreSegments, - segment_ratios=self.segment_ratios) - k_s = self.network.p[0].k_s - for p in range(nt_long): - # Current thermal response factor matrix - if p > 0: - dt = time_long[p] - time_long[p-1] - else: - dt = time_long[p] - # Thermal response factors evaluated at t=dt - h_dt = h_ij(dt) - # Reconstructed load history - Q_reconstructed = self.load_history_reconstruction( - time_long[0:p+1], Q_b[j,:,p0:p+p0+1]) - # Borehole wall temperature for zero heat extraction at - # current step - T_b0 = self.temporal_superposition( - h_ij.y[:,:,1:], Q_reconstructed) - - # Evaluate the g-function with mixed inlet fluid - # temperatures - # --------------------------------------------------------- - # Build a system of equation [A]*[X] = [B] for the - # evaluation of the g-function. [A] is a coefficient - # matrix, [X] = [Q_b,T_b,Tf_in] is a state space vector of - # the borehole heat extraction rates, borehole wall - # temperatures and inlet fluid temperature (into the bore - # field), [B] is a coefficient vector. - # - # Spatial superposition: [T_b] = [T_b0] + [h_ij_dt]*[Q_b] - # Heat transfer inside boreholes: - # [Q_{b,i}] = [a_in]*[T_{f,in}] + [a_{b,i}]*[T_{b,i}] - # Energy conservation: sum([Q_b*H_b]) = sum([H_b]) - # --------------------------------------------------------- - A = np.block( - [[h_dt, - -np.eye(self.nSources, dtype=self.dtype), - np.zeros((self.nSources, 1), dtype=self.dtype)], - [np.eye(self.nSources, dtype=self.dtype), - a_b_j/(2.0*pi*k_s*np.atleast_2d(Hb_individual).T), - a_in_j/(2.0*pi*k_s*np.atleast_2d(Hb_individual).T)], - [H_b, np.zeros(self.nSources + 1, dtype=self.dtype)]]) - B = np.hstack( - (-T_b0, - np.zeros(self.nSources, dtype=self.dtype), - H_tot)) - # Solve the system of equations - X = np.linalg.solve(A, B) - # Store calculated heat extraction rates - Q_b[j,:,p+p0] = X[0:self.nSources] - T_b[j,:,p+p0] = X[self.nSources:2*self.nSources] - # Inlet fluid temperature - T_f_in = X[-1] - # The gFunction is equal to the effective borehole wall - # temperature - # Outlet fluid temperature - T_f_out = T_f_in - 2 * pi * k_s * H_tot / ( - np.sum(np.abs(self.m_flow[j]) * self.cp_f)) - # Average fluid temperature - T_f = 0.5*(T_f_in + T_f_out) - # Borefield thermal resistance - R_field = network_thermal_resistance( - self.network, self.m_flow[j], self.cp_f) - # Effective borehole wall temperature - T_b_eff = T_f - 2 * pi * k_s * R_field - gFunc[j,j,p+p0] = T_b_eff - - for i in range(np.maximum(self.nMassFlow, 1)): - for j in range(np.maximum(self.nMassFlow, 1)): - if not i == j: - # Inlet fluid temperature - a_in, a_b = self.network.coefficients_network_heat_extraction_rate( - self.m_flow[i], - self.cp_f, - self.nBoreSegments, - segment_ratios=self.segment_ratios) - T_f_in = (-2 * pi * k_s * H_tot - a_b @ T_b[j,:,p0:]) / a_in - # The gFunction is equal to the effective borehole wall - # temperature - # Outlet fluid temperature - T_f_out = T_f_in - 2 * pi * k_s * H_tot / np.sum(np.abs(self.m_flow[i]) * self.cp_f) - # Borefield thermal resistance - R_field = network_thermal_resistance( - self.network, self.m_flow[i], self.cp_f) - # Effective borehole wall temperature - T_b_eff = 0.5 * (T_f_in + T_f_out) - 2 * pi * k_s * R_field - gFunc[i,j,p0:] = T_b_eff - - # Linearize g-function for times under threshold - if p_long > 0: - gFunc[:,:,:p_long] = gFunc[:,:,p_long-1] * self.time[:p_long] / time_threshold - Q_b[:,:,:p_long] = 1 + (Q_b[:,:,p_long-1:p_long] - 1) * self.time[:p_long] / time_threshold - T_b[:,:,:p_long] = T_b[:,:,p_long-1:p_long] * self.time[:p_long] / time_threshold - if self.nMassFlow == 0: - gFunc = gFunc[0,0,:] - Q_b = Q_b[0,:,:] - T_b = T_b[0,:,:] - - # Store temperature and heat extraction rate profiles - if self.profiles: - self.Q_b = Q_b - self.T_b = T_b - toc = perf_counter() - if self.disp: print(f' {toc - tic:.3f} sec') - return gFunc - - def segment_lengths(self): - """ - Return the length of all segments in the bore field. - - The segments lengths are used for the energy balance in the calculation - of the g-function. - - Returns - ------- - H : array - Array of segment lengths (in m). - - """ - # Borehole lengths - H_b = np.array([b.H for b in self.boreSegments], dtype=self.dtype) - return H_b - - def borehole_segments(self): - """ - Split boreholes into segments. - - This function goes through the list of boreholes and builds a new list, - with each borehole split into nSegments of equal lengths. - - Returns - ------- - boreSegments : list - List of borehole segments. - - """ - boreSegments = [] # list for storage of boreSegments - for b, nSegments, segment_ratios in zip(self.boreholes, self.nBoreSegments, self.segment_ratios): - segments = b.segments(nSegments, segment_ratios=segment_ratios) - boreSegments.extend(segments) - - return boreSegments - - def temporal_superposition(self, h_ij, Q_reconstructed): - """ - Temporal superposition for inequal time steps. - - Parameters - ---------- - h_ij : array - Values of the segment-to-segment thermal response factor increments - at the given time step. - Q_reconstructed : array - Reconstructed heat extraction rates of all segments at all times. - - Returns - ------- - T_b0 : array - Current values of borehole wall temperatures assuming no heat - extraction during current time step. - - """ - # Number of heat sources - nSources = Q_reconstructed.shape[0] - # Number of time steps - nt = Q_reconstructed.shape[1] - # Spatial and temporal superpositions - dQ = np.concatenate( - (Q_reconstructed[:,0:1], - Q_reconstructed[:,1:] - Q_reconstructed[:,0:-1]), axis=1)[:,::-1] - # Borehole wall temperature - T_b0 = np.einsum('ijk,jk', h_ij[:,:,:nt], dQ) - - return T_b0 - - def load_history_reconstruction(self, time, Q_b): - """ - Reconstructs the load history. - - This function calculates an equivalent load history for an inverted - order of time step sizes. - - Parameters - ---------- - time : array - Values of time (in seconds) in the load history. - Q_b : array - Heat extraction rates (in Watts) of all segments at all times. - - Returns - ------- - Q_reconstructed : array - Reconstructed load history. - - """ - # Number of heat sources - nSources = Q_b.shape[0] - # Time step sizes - dt = np.hstack((time[0], time[1:]-time[:-1])) - # Time vector - t = np.hstack((0., time, time[-1] + time[0])) - # Inverted time step sizes - dt_reconstructed = dt[::-1] - # Reconstructed time vector - t_reconstructed = np.hstack((0., np.cumsum(dt_reconstructed))) - # Accumulated heat extracted - f = np.hstack( - (np.zeros((nSources, 1), dtype=self.dtype), - np.cumsum(Q_b*dt, axis=1))) - f = np.hstack((f, f[:,-1:])) - # Create interpolation object for accumulated heat extracted - sf = interp1d(t, f, kind='linear', axis=1) - # Reconstructed load history - Q_reconstructed = (sf(t_reconstructed[1:]) - sf(t_reconstructed[:-1])) \ - / dt_reconstructed - - return Q_reconstructed - - def _check_inputs(self): - """ - This method ensures that the instances filled in the Solver object - are what is expected. - - """ - assert isinstance(self.boreholes, (list, Borefield)), \ - "Boreholes must be provided in a list." - assert len(self.boreholes) > 0, \ - "The list of boreholes is empty." - assert np.all([isinstance(b, Borehole) for b in self.boreholes]), \ - "The list of boreholes contains elements that are not Borehole " \ - "objects." - assert self.network is None or isinstance(self.network, Network), \ - "The network is not a valid 'Network' object." - if self.boundary_condition == 'MIFT': - assert not (self.m_flow_network is None and self.m_flow_borehole is None), \ - "The mass flow rate 'm_flow_borehole' or 'm_flow_network' must " \ - "be provided when using the 'MIFT' boundary condition." - assert not (self.m_flow_network is not None and self.m_flow_borehole is not None), \ - "Only one of 'm_flow_borehole' or 'm_flow_network' can " \ - "be provided when using the 'MIFT' boundary condition." - assert not self.cp_f is None, \ - "The heat capacity 'cp_f' must " \ - "be provided when using the 'MIFT' boundary condition." - assert not (type(self.m_flow_borehole) is np.ndarray and not np.size(self.m_flow_borehole, axis=1)==self.network.nInlets), \ - "The number of mass flow rates in 'm_flow_borehole' must " \ - "correspond to the number of circuits in the network." - assert type(self.time) is np.ndarray or isinstance(self.time, (float, np.floating)) or self.time is None, \ - "Time should be a float or an array." - # self.nSegments can now be an int or list - assert type(self.nBoreSegments) is list and len(self.nBoreSegments) == \ - len(self.nBoreSegments) and min(self.nBoreSegments) >= 1, \ - "The argument for number of segments `nSegments` should be " \ - "of type int or a list of integers. If passed as a list, the " \ - "length of the list should be equal to the number of boreholes" \ - "in the borefield. nSegments >= 1 is/are required." - acceptable_boundary_conditions = ['UHTR', 'UBWT', 'MIFT'] - assert type(self.boundary_condition) is str and self.boundary_condition in acceptable_boundary_conditions, \ - f"Boundary condition '{self.boundary_condition}' is not an " \ - f"acceptable boundary condition. \n" \ - f"Please provide one of the following inputs : " \ - f"{acceptable_boundary_conditions}" - assert type(self.approximate_FLS) is bool, \ - "The option 'approximate_FLS' should be set to True or False." - assert type(self.nFLS) is int and 1 <= self.nFLS <= 25, \ - "The option 'nFLS' should be a positive int and lower or equal to " \ - "25." - assert type(self.disp) is bool, \ - "The option 'disp' should be set to True or False." - assert type(self.profiles) is bool, \ - "The option 'profiles' should be set to True or False." - assert type(self.kind) is str, \ - "The option 'kind' should be set to a valid interpolation kind " \ - "in accordance with scipy.interpolate.interp1d options." - acceptable_dtypes = (np.single, np.double) - assert np.any([self.dtype is dtype for dtype in acceptable_dtypes]), \ - f"Data type '{self.dtype}' is not an acceptable data type. \n" \ - f"Please provide one of the following inputs : {acceptable_dtypes}" - # Check segment ratios - for j, (ratios, nSegments) in enumerate( - zip(self.segment_ratios, self.nBoreSegments)): - assert len(ratios) == nSegments, \ - f"The length of the segment ratios vectors must correspond to " \ - f"the number of segments, check borehole {j}." - error = np.abs(1. - np.sum(ratios)) - assert(error < 1.0e-6), \ - f"Defined segment ratios must add up to 1. " \ - f", check borehole {j}." - - return - - -class _Detailed(_BaseSolver): - """ - Detailed solver for the evaluation of the g-function. - - This solver superimposes the finite line source (FLS) solution to - estimate the g-function of a geothermal bore field. Each borehole is - modeled as a series of finite line source segments, as proposed in - [#Detailed-CimBer2014]_. - - Parameters - ---------- - boreholes : list of Borehole objects - List of boreholes included in the bore field. - network : network object - Model of the network. - time : float or array - Values of time (in seconds) for which the g-function is evaluated. - boundary_condition : str - Boundary condition for the evaluation of the g-function. Should be one - of - - - 'UHTR' : - **Uniform heat transfer rate**. This is corresponds to boundary - condition *BC-I* as defined by Cimmino and Bernier (2014) - [#Detailed-CimBer2014]_. - - 'UBWT' : - **Uniform borehole wall temperature**. This is corresponds to - boundary condition *BC-III* as defined by Cimmino and Bernier - (2014) [#Detailed-CimBer2014]_. - - 'MIFT' : - **Mixed inlet fluid temperatures**. This boundary condition was - introduced by Cimmino (2015) [#gFunction-Cimmin2015]_ for - parallel-connected boreholes and extended to mixed - configurations by Cimmino (2019) [#Detailed-Cimmin2019]_. - - nSegments : int or list, optional - Number of line segments used per borehole, or list of number of - line segments used for each borehole. - Default is 8. - segment_ratios : array, list of arrays, or callable, optional - Ratio of the borehole length represented by each segment. The - sum of ratios must be equal to 1. The shape of the array is of - (nSegments,) or list of (nSegments[i],). If segment_ratios==None, - segments of equal lengths are considered. If a callable is provided, it - must return an array of size (nSegments,) when provided with nSegments - (of type int) as an argument, or an array of size (nSegments[i],) when - provided with an element of nSegments (of type list). - Default is :func:`utilities.segment_ratios`. - m_flow_borehole : (nInlets,) array or (nMassFlow, nInlets,) array, optional - Fluid mass flow rate into each circuit of the network. If a - (nMassFlow, nInlets,) array is supplied, the - (nMassFlow, nMassFlow,) variable mass flow rate g-functions - will be evaluated using the method of Cimmino (2024) - [#gFunction-CimBer2024]_. Only required for the 'MIFT' boundary - condition. Only one of 'm_flow_borehole' and 'm_flow_network' can be - provided. - Default is None. - m_flow_network : float or (nMassFlow,) array, optional - Fluid mass flow rate into the network of boreholes. If an array - is supplied, the (nMassFlow, nMassFlow,) variable mass flow - rate g-functions will be evaluated using the method of Cimmino - (2024) [#gFunction-CimBer2024]_. Only required for the 'MIFT' boundary - condition. Only one of 'm_flow_borehole' and 'm_flow_network' can be - provided. - Default is None. - cp_f : float, optional - Fluid specific isobaric heat capacity (in J/kg.degC). Only required - for the 'MIFT' boundary condition. - Default is None. - approximate_FLS : bool, optional - Set to true to use the approximation of the FLS solution of Cimmino - (2021) [#Detailed-Cimmin2021]_. This approximation does not require the - numerical evaluation of any integral. - Default is False. - nFLS : int, optional - Number of terms in the approximation of the FLS solution. This - parameter is unused if `approximate_FLS` is set to False. - Default is 10. Maximum is 25. - mQuad : int, optional - Number of Gauss-Legendre sample points for the integral over :math:`u` - in the inclined FLS solution. - Default is 11. - linear_threshold : float, optional - Threshold time (in seconds) under which the g-function is - linearized. The g-function value is then interpolated between 0 - and its value at the threshold. If linear_threshold==None, the - g-function is linearized for times - `t < r_b**2 / (25 * self.alpha)`. - Default is None. - disp : bool, optional - Set to true to print progression messages. - Default is False. - profiles : bool, optional - Set to true to keep in memory the temperatures and heat extraction - rates. - Default is False. - kind : string, optional - Interpolation method used for segment-to-segment thermal response - factors. See documentation for scipy.interpolate.interp1d. - Default is 'linear'. - dtype : numpy dtype, optional - numpy data type used for matrices and vectors. Should be one of - numpy.single or numpy.double. - Default is numpy.double. - - References - ---------- - .. [#Detailed-CimBer2014] Cimmino, M., & Bernier, M. (2014). A - semi-analytical method to generate g-functions for geothermal bore - fields. International Journal of Heat and Mass Transfer, 70, 641-650. - .. [#Detailed-Cimmin2019] Cimmino, M. (2019). Semi-analytical method for - g-function calculation of bore fields with series- and - parallel-connected boreholes. Science and Technology for the Built - Environment, 25 (8), 1007-1022. - .. [#Detailed-Cimmin2021] Cimmino, M. (2021). An approximation of the - finite line source solution to model thermal interactions between - geothermal boreholes. International Communications in Heat and Mass - Transfer, 127, 105496. - - """ - def initialize(self, **kwargs): - """ - Split boreholes into segments. - - Returns - ------- - nSources : int - Number of finite line heat sources in the borefield used to - initialize the matrix of segment-to-segment thermal response - factors (of size: nSources x nSources). - - """ - # Split boreholes into segments - self.boreSegments = self.borehole_segments() - nSources = len(self.boreSegments) - return nSources - - def thermal_response_factors(self, time, alpha, kind='linear'): - """ - Evaluate the segment-to-segment thermal response factors for all pairs - of segments in the borefield at all time steps using the finite line - source solution. - - This method returns a scipy.interpolate.interp1d object of the matrix - of thermal response factors, containing a copy of the matrix accessible - by h_ij.y[:nSources,:nSources,:nt+1]. The first index along the - third axis corresponds to time t=0. The interp1d object can be used to - obtain thermal response factors at any intermediate time by - h_ij(t)[:nSources,:nSources]. - - Attributes - ---------- - time : float or array - Values of time (in seconds) for which the g-function is evaluated. - alpha : float - Soil thermal diffusivity (in m2/s). - kind : string, optional - Interpolation method used for segment-to-segment thermal response - factors. See documentation for scipy.interpolate.interp1d. - Default is 'linear'. - - Returns - ------- - h_ij : interp1d - interp1d object (scipy.interpolate) of the matrix of - segment-to-segment thermal response factors. - - """ - if self.disp: - print('Calculating segment to segment response factors ...', - end='') - # Number of time values - nt = len(np.atleast_1d(time)) - # Initialize chrono - tic = perf_counter() - # Initialize segment-to-segment response factors - h_ij = np.zeros((self.nSources, self.nSources, nt+1), dtype=self.dtype) - nBoreholes = len(self.boreholes) - segment_lengths = self.segment_lengths() - - # --------------------------------------------------------------------- - # Segment-to-segment thermal response factors for same-borehole - # thermal interactions - # --------------------------------------------------------------------- - h, i_segment, j_segment = \ - self._thermal_response_factors_borehole_to_self(time, alpha) - # Broadcast values to h_ij matrix - h_ij[j_segment, i_segment, 1:] = h - # --------------------------------------------------------------------- - # Segment-to-segment thermal response factors for - # borehole-to-borehole thermal interactions - # --------------------------------------------------------------------- - for i, (i0, i1) in enumerate(zip(self._i0Segments, self._i1Segments)): - # Segments of the receiving borehole - b2 = self.boreSegments[i0:i1] - if i+1 < nBoreholes: - # Segments of the emitting borehole - b1 = self.boreSegments[i1:] - h = finite_line_source( - time, alpha, b1, b2, approximation=self.approximate_FLS, - N=self.nFLS, M=self.mQuad) - # Broadcast values to h_ij matrix - h_ij[i0:i1, i1:, 1:] = h - h_ij[i1:, i0:i1, 1:] = \ - np.swapaxes(h, 0, 1) * np.divide.outer( - segment_lengths[i0:i1], - segment_lengths[i1:]).T[:,:,np.newaxis] - - # Return 2d array if time is a scalar - if np.isscalar(time): - h_ij = h_ij[:,:,1] - - # Interp1d object for thermal response factors - h_ij = interp1d(np.hstack((0., time)), h_ij, - kind=kind, copy=True, axis=2) - toc = perf_counter() - if self.disp: print(f' {toc - tic:.3f} sec') - - return h_ij - - def _thermal_response_factors_borehole_to_self(self, time, alpha): - """ - Evaluate the segment-to-segment thermal response factors for all pairs - of segments between each borehole and itself. - - Attributes - ---------- - time : float or array - Values of time (in seconds) for which the g-function is evaluated. - alpha : float - Soil thermal diffusivity (in m2/s). - - Returns - ------- - h : array - Finite line source solution. - i_segment : list - Indices of the emitting segments in the bore field. - j_segment : list - Indices of the receiving segments in the bore field. - """ - # Indices of the thermal response factors into h_ij - i_segment = np.concatenate( - [np.repeat(np.arange(i0, i1), nSegments) - for i0, i1, nSegments in zip( - self._i0Segments, self._i1Segments, self.nBoreSegments) - ]) - j_segment = np.concatenate( - [np.tile(np.arange(i0, i1), nSegments) - for i0, i1, nSegments in zip( - self._i0Segments, self._i1Segments, self.nBoreSegments) - ]) - # Unpack parameters - x = np.array([b.x for b in self.boreSegments]) - y = np.array([b.y for b in self.boreSegments]) - H = np.array([b.H for b in self.boreSegments]) - D = np.array([b.D for b in self.boreSegments]) - r_b = np.array([b.r_b for b in self.boreSegments]) - # Distances between boreholes - dis = np.maximum( - np.sqrt((x[i_segment] - x[j_segment])**2 + (y[i_segment] - y[j_segment])**2), - r_b[i_segment]) - # FLS solution - if np.all([b.is_vertical() for b in self.boreholes]): - h = finite_line_source_vectorized( - time, alpha, - dis, H[i_segment], D[i_segment], H[j_segment], D[j_segment], - approximation=self.approximate_FLS, N=self.nFLS) - else: - tilt = np.array([b.tilt for b in self.boreSegments]) - orientation = np.array([b.orientation for b in self.boreSegments]) - h = finite_line_source_inclined_vectorized( - time, alpha, - r_b[i_segment], x[i_segment], y[i_segment], H[i_segment], - D[i_segment], tilt[i_segment], orientation[i_segment], - x[j_segment], y[j_segment], H[j_segment], D[j_segment], - tilt[j_segment], orientation[j_segment], M=self.mQuad, - approximation=self.approximate_FLS, N=self.nFLS) - return h, i_segment, j_segment - - - -class _Similarities(_BaseSolver): - """ - Similarities solver for the evaluation of the g-function. - - This solver superimposes the finite line source (FLS) solution to - estimate the g-function of a geothermal bore field. Each borehole is - modeled as a series of finite line source segments, as proposed in - [#Similarities-CimBer2014]_. The number of evaluations of the FLS solution - is decreased by identifying similar pairs of boreholes, for which the same - FLS value can be applied [#Similarities-Cimmin2018]_. - - Parameters - ---------- - boreholes : list of Borehole objects - List of boreholes included in the bore field. - network : network object - Model of the network. - time : float or array - Values of time (in seconds) for which the g-function is evaluated. - boundary_condition : str - Boundary condition for the evaluation of the g-function. Should be one - of - - - 'UHTR' : - **Uniform heat transfer rate**. This is corresponds to boundary - condition *BC-I* as defined by Cimmino and Bernier (2014) - [#Similarities-CimBer2014]_. - - 'UBWT' : - **Uniform borehole wall temperature**. This is corresponds to - boundary condition *BC-III* as defined by Cimmino and Bernier - (2014) [#Similarities-CimBer2014]_. - - 'MIFT' : - **Mixed inlet fluid temperatures**. This boundary condition was - introduced by Cimmino (2015) [#Similarities-Cimmin2015]_ for - parallel-connected boreholes and extended to mixed - configurations by Cimmino (2019) [#Similarities-Cimmin2019]_. - - nSegments : int or list, optional - Number of line segments used per borehole, or list of number of - line segments used for each borehole. - Default is 8. - segment_ratios : array, list of arrays, or callable, optional - Ratio of the borehole length represented by each segment. The - sum of ratios must be equal to 1. The shape of the array is of - (nSegments,) or list of (nSegments[i],). If segment_ratios==None, - segments of equal lengths are considered. If a callable is provided, it - must return an array of size (nSegments,) when provided with nSegments - (of type int) as an argument, or an array of size (nSegments[i],) when - provided with an element of nSegments (of type list). - Default is :func:`utilities.segment_ratios`. - m_flow_borehole : (nInlets,) array or (nMassFlow, nInlets,) array, optional - Fluid mass flow rate into each circuit of the network. If a - (nMassFlow, nInlets,) array is supplied, the - (nMassFlow, nMassFlow,) variable mass flow rate g-functions - will be evaluated using the method of Cimmino (2024) - [#gFunction-CimBer2024]_. Only required for the 'MIFT' boundary - condition. Only one of 'm_flow_borehole' and 'm_flow_network' can be - provided. - Default is None. - m_flow_network : float or (nMassFlow,) array, optional - Fluid mass flow rate into the network of boreholes. If an array - is supplied, the (nMassFlow, nMassFlow,) variable mass flow - rate g-functions will be evaluated using the method of Cimmino - (2024) [#gFunction-CimBer2024]_. Only required for the 'MIFT' boundary - condition. Only one of 'm_flow_borehole' and 'm_flow_network' can be - provided. - Default is None. - cp_f : float, optional - Fluid specific isobaric heat capacity (in J/kg.degC). Only required - for the 'MIFT' boundary condition. - Default is None. - approximate_FLS : bool, optional - Set to true to use the approximation of the FLS solution of Cimmino - (2021) [#Similarities-Cimmin2021]_. This approximation does not require - the numerical evaluation of any integral. - Default is False. - nFLS : int, optional - Number of terms in the approximation of the FLS solution. This - parameter is unused if `approximate_FLS` is set to False. - Default is 10. Maximum is 25. - mQuad : int, optional - Number of Gauss-Legendre sample points for the integral over :math:`u` - in the inclined FLS solution. - Default is 11. - linear_threshold : float, optional - Threshold time (in seconds) under which the g-function is - linearized. The g-function value is then interpolated between 0 - and its value at the threshold. If linear_threshold==None, the - g-function is linearized for times - `t < r_b**2 / (25 * self.alpha)`. - Default is None. - disp : bool, optional - Set to true to print progression messages. - Default is False. - profiles : bool, optional - Set to true to keep in memory the temperatures and heat extraction - rates. - Default is False. - kind : string, optional - Interpolation method used for segment-to-segment thermal response - factors. See documentation for scipy.interpolate.interp1d. - Default is 'linear'. - dtype : numpy dtype, optional - numpy data type used for matrices and vectors. Should be one of - numpy.single or numpy.double. - Default is numpy.double. - disTol : float, optional - Relative tolerance on radial distance. Two distances - (d1, d2) between two pairs of boreholes are considered equal if the - difference between the two distances (abs(d1-d2)) is below tolerance. - Default is 0.01. - tol : float, optional - Relative tolerance on length and depth. Two lengths H1, H2 - (or depths D1, D2) are considered equal if abs(H1 - H2)/H2 < tol. - Default is 1.0e-6. - - References - ---------- - .. [#Similarities-CimBer2014] Cimmino, M., & Bernier, M. (2014). A - semi-analytical method to generate g-functions for geothermal bore - fields. International Journal of Heat and Mass Transfer, 70, 641-650. - .. [#Similarities-Cimmin2015] Cimmino, M. (2015). The effects of borehole - thermal resistances and fluid flow rate on the g-functions of geothermal - bore fields. International Journal of Heat and Mass Transfer, 91, - 1119-1127. - .. [#Similarities-Cimmin2018] Cimmino, M. (2018). Fast calculation of the - g-functions of geothermal borehole fields using similarities in the - evaluation of the finite line source solution. Journal of Building - Performance Simulation, 11 (6), 655-668. - .. [#Similarities-Cimmin2019] Cimmino, M. (2019). Semi-analytical method - for g-function calculation of bore fields with series- and - parallel-connected boreholes. Science and Technology for the Built - Environment, 25 (8), 1007-1022. - .. [#Similarities-Cimmin2021] Cimmino, M. (2021). An approximation of the - finite line source solution to model thermal interactions between - geothermal boreholes. International Communications in Heat and Mass - Transfer, 127, 105496. - - """ - def initialize(self, disTol=0.01, tol=1.0e-6, **kwargs): - """ - Split boreholes into segments and identify similarities in the - borefield. - - Returns - ------- - nSources : int - Number of finite line heat sources in the borefield used to - initialize the matrix of segment-to-segment thermal response - factors (of size: nSources x nSources). - - """ - self.disTol = disTol - self.tol = tol - # Check the validity of inputs - self._check_solver_specific_inputs() - # Split boreholes into segments - self.boreSegments = self.borehole_segments() - # Initialize similarities - self.find_similarities() - return len(self.boreSegments) - - def thermal_response_factors(self, time, alpha, kind='linear'): - """ - Evaluate the segment-to-segment thermal response factors for all pairs - of segments in the borefield at all time steps using the finite line - source solution. - - This method returns a scipy.interpolate.interp1d object of the matrix - of thermal response factors, containing a copy of the matrix accessible - by h_ij.y[:nSources,:nSources,:nt+1]. The first index along the - third axis corresponds to time t=0. The interp1d object can be used to - obtain thermal response factors at any intermediat time by - h_ij(t)[:nSources,:nSources]. - - Attributes - ---------- - time : float or array - Values of time (in seconds) for which the g-function is evaluated. - alpha : float - Soil thermal diffusivity (in m2/s). - kind : string, optional - Interpolation method used for segment-to-segment thermal response - factors. See documentation for scipy.interpolate.interp1d. - Default is 'linear'. - - Returns - ------- - h_ij : interp1d - interp1d object (scipy.interpolate) of the matrix of - segment-to-segment thermal response factors. - - """ - if self.disp: - print('Calculating segment to segment response factors ...', - end='') - # Number of time values - nt = len(np.atleast_1d(time)) - # Initialize chrono - tic = perf_counter() - # Initialize segment-to-segment response factors - h_ij = np.zeros((self.nSources, self.nSources, nt+1), dtype=self.dtype) - - # --------------------------------------------------------------------- - # Segment-to-segment thermal response factors for same-borehole thermal - # interactions (vertical boreholes) - # --------------------------------------------------------------------- - # Evaluate FLS at all time steps - h, i_segment, j_segment, k_segment = \ - self._thermal_response_factors_borehole_to_self_vertical( - time, alpha) - # Broadcast values to h_ij matrix - h_ij[j_segment, i_segment, 1:] = h[k_segment, :] - # --------------------------------------------------------------------- - # Segment-to-segment thermal response factors for same-borehole thermal - # interactions (inclined boreholes) - # --------------------------------------------------------------------- - # Evaluate FLS at all time steps - h, i_segment, j_segment, k_segment = \ - self._thermal_response_factors_borehole_to_self_inclined( - time, alpha) - # Broadcast values to h_ij matrix - h_ij[j_segment, i_segment, 1:] = h[k_segment, :] - # --------------------------------------------------------------------- - # Segment-to-segment thermal response factors for borehole-to-borehole - # thermal interactions (vertical boreholes) - # --------------------------------------------------------------------- - for pairs, distances, distance_indices in zip( - self.borehole_to_borehole_vertical, - self.borehole_to_borehole_distances_vertical, - self.borehole_to_borehole_indices_vertical): - # Index of first borehole pair in group - i, j = pairs[0] - # Find segment-to-segment similarities - H1, D1, H2, D2, i_pair, j_pair, k_pair = \ - self._map_axial_segment_pairs_vertical(i, j) - # Locate thermal response factors in the h_ij matrix - i_segment, j_segment, k_segment, l_segment = \ - self._map_segment_pairs_vertical( - i_pair, j_pair, k_pair, pairs, distance_indices) - # Evaluate FLS at all time steps - dis = np.reshape(distances, (-1, 1)) - H1 = H1.reshape(1, -1) - H2 = H2.reshape(1, -1) - D1 = D1.reshape(1, -1) - D2 = D2.reshape(1, -1) - h = finite_line_source_vectorized( - time, alpha, dis, H1, D1, H2, D2, - approximation=self.approximate_FLS, N=self.nFLS) - # Broadcast values to h_ij matrix - h_ij[j_segment, i_segment, 1:] = h[l_segment, k_segment, :] - if (self._compare_boreholes(self.boreholes[j], self.boreholes[i]) and - self.nBoreSegments[i] == self.nBoreSegments[j] and - self._uniform_segment_ratios[i] and - self._uniform_segment_ratios[j]): - h_ij[i_segment, j_segment, 1:] = h[l_segment, k_segment, :] - else: - h_ij[i_segment, j_segment, 1:] = (h * H2.T / H1.T)[l_segment, k_segment, :] - # --------------------------------------------------------------------- - # Segment-to-segment thermal response factors for borehole-to-borehole - # thermal interactions (inclined boreholes) - # --------------------------------------------------------------------- - # Evaluate FLS at all time steps - h, hT, i_segment, j_segment, k_segment = \ - self._thermal_response_factors_borehole_to_borehole_inclined( - time, alpha) - # Broadcast values to h_ij matrix - h_ij[j_segment, i_segment, 1:] = h[k_segment, :] - h_ij[i_segment, j_segment, 1:] = hT[k_segment, :] - - # Return 2d array if time is a scalar - if np.isscalar(time): - h_ij = h_ij[:,:,1] - - # Interp1d object for thermal response factors - h_ij = interp1d( - np.hstack((0., time)), h_ij, - kind=kind, copy=True, assume_sorted=True, axis=2) - toc = perf_counter() - if self.disp: print(f' {toc - tic:.3f} sec') - - return h_ij - - def _thermal_response_factors_borehole_to_borehole_inclined( - self, time, alpha): - """ - Evaluate the segment-to-segment thermal response factors for all pairs - of inclined segments. - - Attributes - ---------- - time : float or array - Values of time (in seconds) for which the g-function is evaluated. - alpha : float - Soil thermal diffusivity (in m2/s). - - Returns - ------- - h : array - Finite line source solution. - hT : array - Reciprocal finite line source solution. - i_segment : list - Indices of the emitting segments in the bore field. - j_segment : list - Indices of the receiving segments in the bore field. - k_segment : list - Indices of unique segment pairs in the (H1, D1, H2, D2) dimensions - corresponding to all pairs in (i_pair, j_pair) in the bore field. - """ - rb1 = np.array([]) - x1 = np.array([]) - y1 = np.array([]) - H1 = np.array([]) - D1 = np.array([]) - tilt1 = np.array([]) - orientation1 = np.array([]) - x2 = np.array([]) - y2 = np.array([]) - H2 = np.array([]) - D2 = np.array([]) - tilt2 = np.array([]) - orientation2 = np.array([]) - i_segment = np.array([], dtype=np.uint) - j_segment = np.array([], dtype=np.uint) - k_segment = np.array([], dtype=np.uint) - k0 = 0 - for pairs in self.borehole_to_borehole_inclined: - # Index of first borehole pair in group - i, j = pairs[0] - # Find segment-to-segment similarities - rb1_i, x1_i, y1_i, H1_i, D1_i, tilt1_i, orientation1_i, \ - x2_i, y2_i, H2_i, D2_i, tilt2_i, orientation2_i, \ - i_pair, j_pair, k_pair = \ - self._map_axial_segment_pairs_inclined(i, j) - # Locate thermal response factors in the h_ij matrix - i_segment_i, j_segment_i, k_segment_i = \ - self._map_segment_pairs_inclined(i_pair, j_pair, k_pair, pairs) - # Append lists - rb1 = np.append(rb1, rb1_i) - x1 = np.append(x1, x1_i) - y1 = np.append(y1, y1_i) - H1 = np.append(H1, H1_i) - D1 = np.append(D1, D1_i) - tilt1 = np.append(tilt1, tilt1_i) - orientation1 = np.append(orientation1, orientation1_i) - x2 = np.append(x2, x2_i) - y2 = np.append(y2, y2_i) - H2 = np.append(H2, H2_i) - D2 = np.append(D2, D2_i) - tilt2 = np.append(tilt2, tilt2_i) - orientation2 = np.append(orientation2, orientation2_i) - i_segment = np.append(i_segment, i_segment_i) - j_segment = np.append(j_segment, j_segment_i) - k_segment = np.append(k_segment, k_segment_i + k0) - k0 += len(k_pair) - # Evaluate FLS at all time steps - h = finite_line_source_inclined_vectorized( - time, alpha, rb1, x1, y1, H1, D1, tilt1, orientation1, - x2, y2, H2, D2, tilt2, orientation2, M=self.mQuad, - approximation=self.approximate_FLS, N=self.nFLS) - hT = (h.T * H2 / H1).T - return h, hT, i_segment, j_segment, k_segment - - def _thermal_response_factors_borehole_to_self_inclined(self, time, alpha): - """ - Evaluate the segment-to-segment thermal response factors for all pairs - of segments between each inclined borehole and itself. - - Attributes - ---------- - time : float or array - Values of time (in seconds) for which the g-function is evaluated. - alpha : float - Soil thermal diffusivity (in m2/s). - - Returns - ------- - h : array - Finite line source solution. - i_segment : list - Indices of the emitting segments in the bore field. - j_segment : list - Indices of the receiving segments in the bore field. - k_segment : list - Indices of unique segment pairs in the (H1, D1, H2, D2) dimensions - corresponding to all pairs in (i_pair, j_pair) in the bore field. - """ - rb1 = np.array([]) - x1 = np.array([]) - y1 = np.array([]) - H1 = np.array([]) - D1 = np.array([]) - tilt1 = np.array([]) - orientation1 = np.array([]) - x2 = np.array([]) - y2 = np.array([]) - H2 = np.array([]) - D2 = np.array([]) - tilt2 = np.array([]) - orientation2 = np.array([]) - i_segment = np.array([], dtype=np.uint) - j_segment = np.array([], dtype=np.uint) - k_segment = np.array([], dtype=np.uint) - k0 = 0 - for group in self.borehole_to_self_inclined: - # Index of first borehole in group - i = group[0] - # Find segment-to-segment similarities - rb1_i, x1_i, y1_i, H1_i, D1_i, tilt1_i, orientation1_i, \ - x2_i, y2_i, H2_i, D2_i, tilt2_i, orientation2_i, \ - i_pair, j_pair, k_pair = \ - self._map_axial_segment_pairs_inclined(i, i) - # Locate thermal response factors in the h_ij matrix - i_segment_i, j_segment_i, k_segment_i = \ - self._map_segment_pairs_inclined( - i_pair, j_pair, k_pair, [(n, n) for n in group]) - # Append lists - rb1 = np.append(rb1, rb1_i) - x1 = np.append(x1, x1_i) - y1 = np.append(y1, y1_i) - H1 = np.append(H1, H1_i) - D1 = np.append(D1, D1_i) - tilt1 = np.append(tilt1, tilt1_i) - orientation1 = np.append(orientation1, orientation1_i) - x2 = np.append(x2, x2_i) - y2 = np.append(y2, y2_i) - H2 = np.append(H2, H2_i) - D2 = np.append(D2, D2_i) - tilt2 = np.append(tilt2, tilt2_i) - orientation2 = np.append(orientation2, orientation2_i) - i_segment = np.append(i_segment, i_segment_i) - j_segment = np.append(j_segment, j_segment_i) - k_segment = np.append(k_segment, k_segment_i + k0) - k0 += len(k_pair) - # Evaluate FLS at all time steps - h = finite_line_source_inclined_vectorized( - time, alpha, rb1, x1, y1, H1, D1, tilt1, orientation1, - x2, y2, H2, D2, tilt2, orientation2, M=self.mQuad, - approximation=self.approximate_FLS, N=self.nFLS) - return h, i_segment, j_segment, k_segment - - def _thermal_response_factors_borehole_to_self_vertical(self, time, alpha): - """ - Evaluate the segment-to-segment thermal response factors for all pairs - of segments between each vertical borehole and itself. - - Attributes - ---------- - time : float or array - Values of time (in seconds) for which the g-function is evaluated. - alpha : float - Soil thermal diffusivity (in m2/s). - - Returns - ------- - h : array - Finite line source solution. - i_segment : list - Indices of the emitting segments in the bore field. - j_segment : list - Indices of the receiving segments in the bore field. - k_segment : list - Indices of unique segment pairs in the (H1, D1, H2, D2) dimensions - corresponding to all pairs in (i_pair, j_pair) in the bore field. - """ - H1 = np.array([]) - D1 = np.array([]) - H2 = np.array([]) - D2 = np.array([]) - dis = np.array([]) - i_segment = np.array([], dtype=np.uint) - j_segment = np.array([], dtype=np.uint) - k_segment = np.array([], dtype=np.uint) - k0 = 0 - for group in self.borehole_to_self_vertical: - # Index of first borehole in group - i = group[0] - # Find segment-to-segment similarities - H1_i, D1_i, H2_i, D2_i, i_pair, j_pair, k_pair = \ - self._map_axial_segment_pairs_vertical(i, i) - # Locate thermal response factors in the h_ij matrix - i_segment_i, j_segment_i, k_segment_i, l_segment_i = \ - self._map_segment_pairs_vertical( - i_pair, j_pair, k_pair, [(n, n) for n in group], [0]) - # Append lists - H1 = np.append(H1, H1_i) - D1 = np.append(D1, D1_i) - H2 = np.append(H2, H2_i) - D2 = np.append(D2, D2_i) - if len(self.borehole_to_self_vertical) > 1: - dis = np.append(dis, np.full(len(H1_i), self.boreholes[i].r_b)) - else: - dis = self.boreholes[i].r_b - i_segment = np.append(i_segment, i_segment_i) - j_segment = np.append(j_segment, j_segment_i) - k_segment = np.append(k_segment, k_segment_i + k0) - k0 += np.max(k_pair) + 1 - # Evaluate FLS at all time steps - h = finite_line_source_vectorized( - time, alpha, dis, H1, D1, H2, D2, - approximation=self.approximate_FLS, N=self.nFLS) - return h, i_segment, j_segment, k_segment - - def find_similarities(self): - """ - Find similarities in the FLS solution for groups of boreholes. - - This function identifies pairs of boreholes for which the evaluation - of the Finite Line Source (FLS) solution is equivalent. - - """ - if self.disp: print('Identifying similarities ...', end='') - # Initialize chrono - tic = perf_counter() - - # Find similar pairs of boreholes - # Boreholes can only be similar if their segments are similar - self.borehole_to_self_vertical, self.borehole_to_self_inclined, \ - self.borehole_to_borehole_vertical, self.borehole_to_borehole_inclined = \ - self._find_axial_borehole_pairs(self.boreholes) - # Find distances for each similar pairs of vertical boreholes - self.borehole_to_borehole_distances_vertical, self.borehole_to_borehole_indices_vertical = \ - self._find_distances( - self.boreholes, self.borehole_to_borehole_vertical) - - # Stop chrono - toc = perf_counter() - if self.disp: print(f' {toc - tic:.3f} sec') - - return - - def _compare_boreholes(self, borehole1, borehole2): - """ - Compare two boreholes and checks if they have the same dimensions : - H, D, r_b, and tilt. - - Parameters - ---------- - borehole1 : Borehole object - First borehole. - borehole2 : Borehole object - Second borehole. - - Returns - ------- - similarity : bool - True if the two boreholes have the same dimensions. - - """ - # Compare lengths (H), buried depth (D) and radius (r_b) - if (abs((borehole1.H - borehole2.H)/borehole1.H) < self.tol and - abs((borehole1.r_b - borehole2.r_b)/borehole1.r_b) < self.tol and - abs((borehole1.D - borehole2.D)/(borehole1.D + 1e-30)) < self.tol and - abs(abs(borehole1.tilt) - abs(borehole2.tilt))/(abs(borehole1.tilt) + 1e-30) < self.tol): - similarity = True - else: - similarity = False - return similarity - - def _compare_real_pairs_vertical(self, pair1, pair2): - """ - Compare two pairs of vertical boreholes or segments and return True if - the two pairs have the same FLS solution for real sources. - - Parameters - ---------- - pair1 : Tuple of Borehole objects - First pair of boreholes or segments. - pair2 : Tuple of Borehole objects - Second pair of boreholes or segments. - - Returns - ------- - similarity : bool - True if the two pairs have the same FLS solution. - - """ - deltaD1 = pair1[1].D - pair1[0].D - deltaD2 = pair2[1].D - pair2[0].D - - # Equality of lengths between pairs - cond_H = (abs((pair1[0].H - pair2[0].H)/pair1[0].H) < self.tol - and abs((pair1[1].H - pair2[1].H)/pair1[1].H) < self.tol) - # Equality of lengths in each pair - equal_H = abs((pair1[0].H - pair1[1].H)/pair1[0].H) < self.tol - # Equality of buried depths differences - cond_deltaD = abs(deltaD1 - deltaD2)/abs(deltaD1 + 1e-30) < self.tol - # Equality of buried depths differences if all boreholes have the same - # length - cond_deltaD_equal_H = abs((abs(deltaD1) - abs(deltaD2))/(abs(deltaD1) + 1e-30)) < self.tol - if cond_H and (cond_deltaD or (equal_H and cond_deltaD_equal_H)): - similarity = True - else: - similarity = False - return similarity - - def _compare_image_pairs_vertical(self, pair1, pair2): - """ - Compare two pairs of vertical boreholes or segments and return True if - the two pairs have the same FLS solution for mirror sources. - - Parameters - ---------- - pair1 : Tuple of Borehole objects - First pair of boreholes or segments. - pair2 : Tuple of Borehole objects - Second pair of boreholes or segments. - - Returns - ------- - similarity : bool - True if the two pairs have the same FLS solution. - - """ - sumD1 = pair1[1].D + pair1[0].D - sumD2 = pair2[1].D + pair2[0].D - - # Equality of lengths between pairs - cond_H = (abs((pair1[0].H - pair2[0].H)/pair1[0].H) < self.tol - and abs((pair1[1].H - pair2[1].H)/pair1[1].H) < self.tol) - # Equality of buried depths sums - cond_sumD = abs((sumD1 - sumD2)/(sumD1 + 1e-30)) < self.tol - if cond_H and cond_sumD: - similarity = True - else: - similarity = False - return similarity - - def _compare_realandimage_pairs_vertical(self, pair1, pair2): - """ - Compare two pairs of vertical boreholes or segments and return True if - the two pairs have the same FLS solution for both real and mirror - sources. - - Parameters - ---------- - pair1 : Tuple of Borehole objects - First pair of boreholes or segments. - pair2 : Tuple of Borehole objects - Second pair of boreholes or segments. - - Returns - ------- - similarity : bool - True if the two pairs have the same FLS solution. - - """ - if (self._compare_real_pairs_vertical(pair1, pair2) - and self._compare_image_pairs_vertical(pair1, pair2)): - similarity = True - else: - similarity = False - return similarity - - def _compare_real_pairs_inclined(self, pair1, pair2): - """ - Compare two pairs of inclined boreholes or segments and return True if - the two pairs have the same FLS solution for real sources. - - Parameters - ---------- - pair1 : Tuple of Borehole objects - First pair of boreholes or segments. - pair2 : Tuple of Borehole objects - Second pair of boreholes or segments. - - Returns - ------- - similarity : bool - True if the two pairs have the same FLS solution. - - """ - dx1 = pair1[0].x - pair1[1].x; dx2 = pair2[0].x - pair2[1].x - dy1 = pair1[0].y - pair1[1].y; dy2 = pair2[0].y - pair2[1].y - dis1 = np.sqrt(dx1**2 + dy1**2); dis2 = np.sqrt(dx2**2 + dy2**2) - theta_12_1 = np.arctan2(dy1, dx1); theta_12_2 = np.arctan2(dy2, dx2) - deltaD1 = pair1[0].D - pair1[1].D; deltaD2 = pair2[0].D - pair2[1].D - # Equality of lengths between pairs - cond_H = (abs((pair1[0].H - pair2[0].H)/pair1[0].H) < self.tol - and abs((pair1[1].H - pair2[1].H)/pair1[1].H) < self.tol) - # Equality of buried depths differences - cond_deltaD = abs(deltaD1 - deltaD2)/(abs(deltaD1) + 1e-30) < self.tol - # Equality of distances - cond_dis = abs(dis1 - dis2)/(abs(dis1) + 1e-30) < self.disTol - # Equality of tilts - cond_beta = ( - abs(abs(pair1[0].tilt) - abs(pair2[0].tilt))/(abs(pair1[0].tilt) + 1e-30) < self.tol - and abs(abs(pair1[1].tilt) - abs(pair2[1].tilt))/(abs(pair1[1].tilt) + 1e-30) < self.tol) - # Equality of relative orientations - sin_b1_cos_dt1_1 = np.sin(pair1[0].tilt) * np.cos(theta_12_1 - pair1[0].orientation) - sin_b2_cos_dt2_1 = np.sin(pair1[1].tilt) * np.cos(theta_12_1 - pair1[1].orientation) - sin_b1_cos_dt1_2 = np.sin(pair2[0].tilt) * np.cos(theta_12_2 - pair2[0].orientation) - sin_b2_cos_dt2_2 = np.sin(pair2[1].tilt) * np.cos(theta_12_2 - pair2[1].orientation) - cond_theta = ( - abs(sin_b1_cos_dt1_1 - sin_b1_cos_dt1_2) / (abs(sin_b1_cos_dt1_1) + 1e-30) > self.tol - and abs(sin_b2_cos_dt2_1 - sin_b2_cos_dt2_2) / (abs(sin_b2_cos_dt2_1) + 1e-30) > self.tol) - if cond_H and cond_deltaD and cond_dis and cond_beta and cond_theta: - similarity = True - else: - similarity = False - return similarity - - def _compare_image_pairs_inclined(self, pair1, pair2): - """ - Compare two pairs of inclined boreholes or segments and return True if - the two pairs have the same FLS solution for mirror sources. - - Parameters - ---------- - pair1 : Tuple of Borehole objects - First pair of boreholes or segments. - pair2 : Tuple of Borehole objects - Second pair of boreholes or segments. - - Returns - ------- - similarity : bool - True if the two pairs have the same FLS solution. - - """ - dx1 = pair1[0].x - pair1[1].x; dx2 = pair2[0].x - pair2[1].x - dy1 = pair1[0].y - pair1[1].y; dy2 = pair2[0].y - pair2[1].y - dis1 = np.sqrt(dx1**2 + dy1**2); dis2 = np.sqrt(dx2**2 + dy2**2) - theta_12_1 = np.arctan2(dy1, dx1); theta_12_2 = np.arctan2(dy2, dx2) - sumD1 = pair1[0].D + pair1[1].D; sumD2 = pair2[0].D + pair2[1].D - # Equality of lengths between pairs - cond_H = (abs((pair1[0].H - pair2[0].H)/pair1[0].H) < self.tol - and abs((pair1[1].H - pair2[1].H)/pair1[1].H) < self.tol) - # Equality of buried depths sums - cond_sumD = abs(sumD1 - sumD2)/(abs(sumD1) + 1e-30) < self.tol - # Equality of distances - cond_dis = abs(dis1 - dis2)/(abs(dis1) + 1e-30) < self.disTol - # Equality of tilts - cond_beta = ( - abs(abs(pair1[0].tilt) - abs(pair2[0].tilt))/(abs(pair1[0].tilt) + 1e-30) < self.tol - and abs(abs(pair1[1].tilt) - abs(pair2[1].tilt))/(abs(pair1[1].tilt) + 1e-30) < self.tol) - # Equality of relative orientations - sin_b1_cos_dt1_1 = np.sin(pair1[0].tilt) * np.cos(theta_12_1 - pair1[0].orientation) - sin_b2_cos_dt2_1 = np.sin(pair1[1].tilt) * np.cos(theta_12_1 - pair1[1].orientation) - sin_b1_cos_dt1_2 = np.sin(pair2[0].tilt) * np.cos(theta_12_2 - pair2[0].orientation) - sin_b2_cos_dt2_2 = np.sin(pair2[1].tilt) * np.cos(theta_12_2 - pair2[1].orientation) - cond_theta = ( - abs(sin_b1_cos_dt1_1 - sin_b1_cos_dt1_2) / (abs(sin_b1_cos_dt1_1) + 1e-30) > self.tol - and abs(sin_b2_cos_dt2_1 - sin_b2_cos_dt2_2) / (abs(sin_b2_cos_dt2_1) + 1e-30) > self.tol) - if cond_H and cond_sumD and cond_dis and cond_beta and cond_theta: - similarity = True - else: - similarity = False - return similarity - - def _compare_realandimage_pairs_inclined(self, pair1, pair2): - """ - Compare two pairs of inclined boreholes or segments and return True if - the two pairs have the same FLS solution for both real and mirror - sources. - - Parameters - ---------- - pair1 : Tuple of Borehole objects - First pair of boreholes or segments. - pair2 : Tuple of Borehole objects - Second pair of boreholes or segments. - - Returns - ------- - similarity : bool - True if the two pairs have the same FLS solution. - - Notes - ----- - For inclined boreholes the similarity condition is the same for real - and image parts of the solution. - - """ - dx1 = pair1[0].x - pair1[1].x; dx2 = pair2[0].x - pair2[1].x - dy1 = pair1[0].y - pair1[1].y; dy2 = pair2[0].y - pair2[1].y - dis1 = np.sqrt(dx1**2 + dy1**2); dis2 = np.sqrt(dx2**2 + dy2**2) - theta_12_1 = np.arctan2(dy1, dx1); theta_12_2 = np.arctan2(dy2, dx2) - # Equality of lengths between pairs - cond_H = (abs((pair1[0].H - pair2[0].H)/pair1[0].H) < self.tol - and abs((pair1[1].H - pair2[1].H)/pair1[1].H) < self.tol) - # Equality of buried depths - cond_D = ( - abs(pair1[0].D - pair2[0].D)/(abs(pair1[0].D) + 1e-30) < self.tol - and abs(pair1[1].D - pair2[1].D)/(abs(pair1[1].D) + 1e-30) < self.tol) - # Equality of distances - cond_dis = abs(dis1 - dis2)/(abs(dis1) + 1e-30) < self.disTol - # Equality of tilts - cond_beta = ( - abs(abs(pair1[0].tilt) - abs(pair2[0].tilt))/(abs(pair1[0].tilt) + 1e-30) < self.tol - and abs(abs(pair1[1].tilt) - abs(pair2[1].tilt))/(abs(pair1[1].tilt) + 1e-30) < self.tol) - # Equality of relative orientations - sin_b1_cos_dt1_1 = np.sin(pair1[0].tilt) * np.cos(theta_12_1 - pair1[0].orientation) - sin_b2_cos_dt2_1 = np.sin(pair1[1].tilt) * np.cos(theta_12_1 - pair1[1].orientation) - sin_b1_cos_dt1_2 = np.sin(pair2[0].tilt) * np.cos(theta_12_2 - pair2[0].orientation) - sin_b2_cos_dt2_2 = np.sin(pair2[1].tilt) * np.cos(theta_12_2 - pair2[1].orientation) - cond_theta = ( - abs(sin_b1_cos_dt1_1 - sin_b1_cos_dt1_2) / (abs(sin_b1_cos_dt1_1) + 1e-30) < self.tol - and abs(sin_b2_cos_dt2_1 - sin_b2_cos_dt2_2) / (abs(sin_b2_cos_dt2_1) + 1e-30) < self.tol) - if cond_H and cond_D and cond_dis and cond_beta and cond_theta: - similarity = True - else: - similarity = False - return similarity - - def _find_axial_borehole_pairs(self, boreholes): - """ - Find axial (i.e. disregarding the radial distance) similarities between - borehole pairs to simplify the evaluation of the FLS solution. - - Parameters - ---------- - boreholes : list of Borehole objects - Boreholes in the bore field. - - Returns - ------- - borehole_to_self : list - Lists of borehole indexes for each unique set of borehole - dimensions (H, D, r_b) in the bore field. - borehole_to_borehole : list - Lists of tuples of borehole indexes for each unique pair of - boreholes that share the same (pairwise) dimensions (H, D). - - """ - nBoreholes = len(boreholes) - borehole_to_self_vertical = [] - borehole_to_self_inclined = [] - # Only check for similarities if there is more than one borehole - if nBoreholes > 1: - borehole_to_borehole_vertical = [] - borehole_to_borehole_inclined = [] - for i, (borehole_i, nSegments_i, ratios_i) in enumerate( - zip(boreholes, self.nBoreSegments, self.segment_ratios)): - # Compare the borehole to all known unique sets of dimensions - if borehole_i.is_vertical(): - borehole_to_self = borehole_to_self_vertical - compare_pairs = self._compare_realandimage_pairs_vertical - else: - borehole_to_self = borehole_to_self_inclined - compare_pairs = self._compare_realandimage_pairs_inclined - for k, borehole_set in enumerate(borehole_to_self): - m = borehole_set[0] - # Add the borehole to the group if a similar borehole is - # found - if (self._compare_boreholes(borehole_i, boreholes[m]) and - (self._equal_segment_ratios or - (nSegments_i == self.nBoreSegments[m] and - np.allclose(ratios_i, - self.segment_ratios[m], - rtol=self.tol)))): - borehole_set.append(i) - break - else: - # If no similar boreholes are known, append the groups - borehole_to_self.append([i]) - - for j, (borehole_j, nSegments_j, ratios_j) in enumerate( - zip(boreholes[i+1:], - self.nBoreSegments[i+1:], - self.segment_ratios[i+1:]), - start=i+1): - pair0 = (borehole_i, borehole_j) # pair - pair1 = (borehole_j, borehole_i) # reciprocal pair - # Compare pairs of boreholes to known unique pairs - if borehole_i.is_vertical() and borehole_j.is_vertical(): - borehole_to_borehole = borehole_to_borehole_vertical - compare_pairs = self._compare_realandimage_pairs_vertical - else: - borehole_to_borehole = borehole_to_borehole_inclined - compare_pairs = self._compare_realandimage_pairs_inclined - for pairs in borehole_to_borehole: - m, n = pairs[0] - pair_ref = (boreholes[m], boreholes[n]) - # Add the pair (or the reciprocal pair) to a group - # if a similar one is found - if (compare_pairs(pair0, pair_ref) and - (self._equal_segment_ratios or - (nSegments_i == self.nBoreSegments[m] and - nSegments_j == self.nBoreSegments[n] and - np.allclose(ratios_i, - self.segment_ratios[m], - rtol=self.tol) and - np.allclose(ratios_j, - self.segment_ratios[n], - rtol=self.tol)))): - pairs.append((i, j)) - break - elif (compare_pairs(pair1, pair_ref) and - (self._equal_segment_ratios or - (nSegments_j == self.nBoreSegments[m] and - nSegments_i == self.nBoreSegments[n] and - np.allclose(ratios_j, - self.segment_ratios[m], - rtol=self.tol) and - np.allclose(ratios_i, - self.segment_ratios[n], - rtol=self.tol)))): - pairs.append((j, i)) - break - # If no similar pairs are known, append the groups - else: - borehole_to_borehole.append([(i, j)]) - - else: - # Outputs for a single borehole - if boreholes[0].is_vertical: - borehole_to_self_vertical = [[0]] - borehole_to_self_inclined = [] - else: - borehole_to_self_vertical = [] - borehole_to_self_inclined = [[0]] - borehole_to_borehole_vertical = [] - borehole_to_borehole_inclined = [] - return borehole_to_self_vertical, borehole_to_self_inclined, \ - borehole_to_borehole_vertical, borehole_to_borehole_inclined - - def _find_distances(self, boreholes, borehole_to_borehole): - """ - Find unique distances between pairs of boreholes for each unique pair - of boreholes in the bore field. - - Parameters - ---------- - boreholes : list of Borehole objects - Boreholes in the bore field. - borehole_to_borehole : list - Lists of tuples of borehole indexes for each unique pair of - boreholes that share the same (pairwise) dimensions (H, D). - - Returns - ------- - borehole_to_borehole_distances : list - Sorted lists of borehole-to-borehole radial distances for each - unique pair of boreholes. - borehole_to_borehole_indices : list - Lists of indexes of distances associated with each borehole pair. - - """ - nGroups = len(borehole_to_borehole) - borehole_to_borehole_distances = [[] for i in range(nGroups)] - borehole_to_borehole_indices = \ - [np.empty(len(group), dtype=np.uint) for group in borehole_to_borehole] - # Find unique distances for each group - for i, (pairs, distances, distance_indices) in enumerate( - zip(borehole_to_borehole, - borehole_to_borehole_distances, - borehole_to_borehole_indices)): - nPairs = len(pairs) - # Array of all borehole-to-borehole distances within the group - all_distances = np.array( - [boreholes[pair[0]].distance(boreholes[pair[1]]) - for pair in pairs]) - # Indices to sort the distance array - i_sort = all_distances.argsort() - # Sort the distance array - distances_sorted = all_distances[i_sort] - j0 = 0 - j1 = 1 - nDis = 0 - # For each increasing distance in the sorted array : - # 1 - find all distances that are within tolerance - # 2 - add the average distance in the list of unique distances - # 3 - associate the distance index to all pairs for the identified - # distances - # 4 - re-start at the next distance index not yet accounted for. - while j0 < nPairs and j1 > 0: - # Find the first distance outside tolerance - j1 = np.argmax( - distances_sorted >= (1+self.disTol)*distances_sorted[j0]) - if j1 > j0: - # Average distance between pairs of boreholes - distances.append(np.mean(distances_sorted[j0:j1])) - # Apply distance index to borehole pairs - distance_indices[i_sort[j0:j1]] = nDis - else: - # Average distance between pairs of boreholes - distances.append(np.mean(distances_sorted[j0:])) - # Apply distance index to borehole pairs - distance_indices[i_sort[j0:]] = nDis - j0 = j1 - nDis += 1 - return borehole_to_borehole_distances, borehole_to_borehole_indices - - def _map_axial_segment_pairs_vertical( - self, i, j, reaSource=True, imgSource=True): - """ - Find axial (i.e. disregarding the radial distance) similarities between - segment pairs along two boreholes to simplify the evaluation of the - FLS solution. - - The returned H1, D1, H2, and D2 can be used to evaluate the segment-to- - segment response factors using scipy.integrate.quad_vec. - - Parameters - ---------- - i : int - Index of the first borehole. - j : int - Index of the second borehole. - - Returns - ------- - H1 : array - Length of the emitting segments. - D1 : array - Array of buried depths of the emitting segments. - H2 : array - Length of the receiving segments. - D2 : array - Array of buried depths of the receiving segments. - i_pair : list - Indices of the emitting segments along a borehole. - j_pair : list - Indices of the receiving segments along a borehole. - k_pair : list - Indices of unique segment pairs in the (H1, D1, H2, D2) dimensions - corresponding to all pairs in (i_pair, j_pair). - - """ - # Initialize local variables - borehole1 = self.boreholes[i] - borehole2 = self.boreholes[j] - assert reaSource or imgSource, \ - "At least one of reaSource and imgSource must be True." - if reaSource and imgSource: - # Find segment pairs for the full (real + image) FLS solution - compare_pairs = self._compare_realandimage_pairs_vertical - elif reaSource: - # Find segment pairs for the real FLS solution - compare_pairs = self._compare_real_pairs_vertical - elif imgSource: - # Find segment pairs for the image FLS solution - compare_pairs = self._compare_image_pairs_vertical - # Dive both boreholes into segments - segments1 = borehole1.segments( - self.nBoreSegments[i], segment_ratios=self.segment_ratios[i]) - segments2 = borehole2.segments( - self.nBoreSegments[j], segment_ratios=self.segment_ratios[j]) - # Prepare lists of segment lengths - H1 = [] - H2 = [] - # Prepare lists of segment buried depths - D1 = [] - D2 = [] - # All possible pairs (i, j) of indices between segments - i_pair = np.repeat(np.arange(self.nBoreSegments[i], dtype=np.uint), - self.nBoreSegments[j]) - j_pair = np.tile(np.arange(self.nBoreSegments[j], dtype=np.uint), - self.nBoreSegments[i]) - # Empty list of indices for unique pairs - k_pair = np.empty(self.nBoreSegments[i] * self.nBoreSegments[j], - dtype=np.uint) - unique_pairs = [] - nPairs = 0 - - p = 0 - for ii, segment_i in enumerate(segments1): - for jj, segment_j in enumerate(segments2): - pair = (segment_i, segment_j) - # Compare the segment pairs to all known unique pairs - for k, pair_k in enumerate(unique_pairs): - m, n = pair_k[0], pair_k[1] - pair_ref = (segments1[m], segments2[n]) - # Stop if a similar pair is found and assign the index - if compare_pairs(pair, pair_ref): - k_pair[p] = k - break - # If no similar pair is found : add a new pair, increment the - # number of unique pairs, and extract the associated buried - # depths - else: - k_pair[p] = nPairs - H1.append(segment_i.H) - H2.append(segment_j.H) - D1.append(segment_i.D) - D2.append(segment_j.D) - unique_pairs.append((ii, jj)) - nPairs += 1 - p += 1 - return np.array(H1), np.array(D1), np.array(H2), np.array(D2), i_pair, j_pair, k_pair - - def _map_axial_segment_pairs_inclined( - self, i, j, reaSource=True, imgSource=True): - """ - Find axial similarities between segment pairs along two boreholes to - simplify the evaluation of the FLS solution. - - The returned H1, D1, H2, and D2 can be used to evaluate the segment-to- - segment response factors using scipy.integrate.quad_vec. - - Parameters - ---------- - i : int - Index of the first borehole. - j : int - Index of the second borehole. - - Returns - ------- - rb1 : array - Radii of the emitting heat sources. - x1 : array - x-Positions of the emitting heat sources. - y1 : array - y-Positions of the emitting heat sources. - H1 : array - Lengths of the emitting heat sources. - D1 : array - Buried depths of the emitting heat sources. - tilt1 : array - Angles (in radians) from vertical of the emitting heat sources. - orientation1 : array - Directions (in radians) of the tilt the emitting heat sources. - x2 : array - x-Positions of the receiving heat sources. - y2 : array - y-Positions of the receiving heat sources. - H2 : array - Lengths of the receiving heat sources. - D2 : array - Buried depths of the receiving heat sources. - tilt2 : array - Angles (in radians) from vertical of the receiving heat sources. - orientation2 : array - Directions (in radians) of the tilt the receiving heat sources. - i_pair : list - Indices of the emitting segments along a borehole. - j_pair : list - Indices of the receiving segments along a borehole. - k_pair : list - Indices of unique segment pairs in the (H1, D1, H2, D2) dimensions - corresponding to all pairs in (i_pair, j_pair). - """ - # Initialize local variables - borehole1 = self.boreholes[i] - borehole2 = self.boreholes[j] - assert reaSource or imgSource, \ - "At least one of reaSource and imgSource must be True." - if reaSource and imgSource: - # Find segment pairs for the full (real + image) FLS solution - compare_pairs = self._compare_realandimage_pairs_inclined - elif reaSource: - # Find segment pairs for the real FLS solution - compare_pairs = self._compare_real_pairs_inclined - elif imgSource: - # Find segment pairs for the image FLS solution - compare_pairs = self._compare_image_pairs_inclined - # Dive both boreholes into segments - segments1 = borehole1.segments( - self.nBoreSegments[i], segment_ratios=self.segment_ratios[i]) - segments2 = borehole2.segments( - self.nBoreSegments[j], segment_ratios=self.segment_ratios[j]) - # Prepare lists of FLS-inclined arguments - rb1 = [] - x1 = [] - y1 = [] - H1 = [] - D1 = [] - tilt1 = [] - orientation1 = [] - x2 = [] - y2 = [] - H2 = [] - D2 = [] - tilt2 = [] - orientation2 = [] - # All possible pairs (i, j) of indices between segments - i_pair = np.repeat(np.arange(self.nBoreSegments[i], dtype=np.uint), - self.nBoreSegments[j]) - j_pair = np.tile(np.arange(self.nBoreSegments[j], dtype=np.uint), - self.nBoreSegments[i]) - # Empty list of indices for unique pairs - k_pair = np.empty(self.nBoreSegments[i] * self.nBoreSegments[j], - dtype=np.uint) - unique_pairs = [] - nPairs = 0 - - p = 0 - for ii, segment_i in enumerate(segments1): - for jj, segment_j in enumerate(segments2): - pair = (segment_i, segment_j) - # Compare the segment pairs to all known unique pairs - for k, pair_k in enumerate(unique_pairs): - m, n = pair_k[0], pair_k[1] - pair_ref = (segments1[m], segments2[n]) - # Stop if a similar pair is found and assign the index - if compare_pairs(pair, pair_ref): - k_pair[p] = k - break - # If no similar pair is found : add a new pair, increment the - # number of unique pairs, and extract the associated buried - # depths - else: - k_pair[p] = nPairs - rb1.append(segment_i.r_b) - x1.append(segment_i.x) - y1.append(segment_i.y) - H1.append(segment_i.H) - D1.append(segment_i.D) - tilt1.append(segment_i.tilt) - orientation1.append(segment_i.orientation) - x2.append(segment_j.x) - y2.append(segment_j.y) - H2.append(segment_j.H) - D2.append(segment_j.D) - tilt2.append(segment_j.tilt) - orientation2.append(segment_j.orientation) - unique_pairs.append((ii, jj)) - nPairs += 1 - p += 1 - return np.array(rb1), np.array(x1), np.array(y1), np.array(H1), \ - np.array(D1), np.array(tilt1), np.array(orientation1), \ - np.array(x2), np.array(y2), np.array(H2), np.array(D2), \ - np.array(tilt2), np.array(orientation2), i_pair, j_pair, k_pair - - def _map_segment_pairs_vertical( - self, i_pair, j_pair, k_pair, borehole_to_borehole, - borehole_to_borehole_indices): - """ - Return the maping of the unique segment-to-segment thermal response - factors (h) to the complete h_ij array of the borefield, such that: - - h_ij[j_segment, i_segment, :nt] = h[:nt, l_segment, k_segment].T, - - where h is the array of unique segment-to-segment thermal response - factors for a given unique pair of boreholes at all unique distances. - - Parameters - ---------- - i_pair : list - Indices of the emitting segments. - j_pair : list - Indices of the receiving segments. - k_pair : list - Indices of unique segment pairs in the (H1, D1, H2, D2) dimensions - corresponding to all pairs in (i_pair, j_pair). - borehole_to_borehole : list - Tuples of borehole indexes. - borehole_to_borehole_indices : list - Indexes of distances. - - Returns - ------- - i_segment : list - Indices of the emitting segments in the bore field. - j_segment : list - Indices of the receiving segments in the bore field. - k_segment : list - Indices of unique segment pairs in the (H1, D1, H2, D2) dimensions - corresponding to all pairs in (i_pair, j_pair) in the bore field. - l_segment : list - Indices of unique distances for all pairs in (i_pair, j_pair) - in the bore field. - - """ - i_segment = np.concatenate( - [i_pair + self._i0Segments[i] for (i, j) in borehole_to_borehole]) - j_segment = np.concatenate( - [j_pair + self._i0Segments[j] for (i, j) in borehole_to_borehole]) - k_segment = np.tile(k_pair, len(borehole_to_borehole)) - l_segment = np.concatenate( - [np.repeat(i, len(k_pair)) for i in borehole_to_borehole_indices]) - return i_segment, j_segment, k_segment, l_segment - - def _map_segment_pairs_inclined( - self, i_pair, j_pair, k_pair, borehole_to_borehole): - """ - Return the maping of the unique segment-to-segment thermal response - factors (h) to the complete h_ij array of the borefield, such that: - - h_ij[j_segment, i_segment, :nt] = h[:nt, k_segment].T, - - where h is the array of unique segment-to-segment thermal response - factors for a given unique pair of boreholes at all unique distances. - - Parameters - ---------- - i_pair : list - Indices of the emitting segments. - j_pair : list - Indices of the receiving segments. - k_pair : list - Indices of unique segment pairs in the (H1, D1, H2, D2) dimensions - corresponding to all pairs in (i_pair, j_pair). - borehole_to_borehole : list - Tuples of borehole indexes. - - Returns - ------- - i_segment : list - Indices of the emitting segments in the bore field. - j_segment : list - Indices of the receiving segments in the bore field. - k_segment : list - Indices of unique segment pairs in the (H1, D1, H2, D2) dimensions - corresponding to all pairs in (i_pair, j_pair) in the bore field. - - """ - i_segment = np.concatenate( - [i_pair + self._i0Segments[i] for (i, j) in borehole_to_borehole]) - j_segment = np.concatenate( - [j_pair + self._i0Segments[j] for (i, j) in borehole_to_borehole]) - k_segment = np.tile(k_pair, len(borehole_to_borehole)) - return i_segment, j_segment, k_segment - - def _check_solver_specific_inputs(self): - """ - This method ensures that solver specific inputs to the Solver object - are what is expected. - - """ - assert isinstance(self.disTol, (np.floating, float)) and self.disTol > 0., \ - "The distance tolerance 'disTol' should be a positive float." - assert isinstance(self.tol, (np.floating, float)) and self.tol > 0., \ - "The relative tolerance 'tol' should be a positive float." - return - - -class _Equivalent(_BaseSolver): - """ - Equivalent solver for the evaluation of the g-function. - - This solver uses hierarchical agglomerative clustering to identify groups - of boreholes that are expected to have similar borehole wall temperatures - and heat extraction rates, as proposed by Prieto and Cimmino (2021) - [#Equivalent-PriCim2021]_. Each group of boreholes is represented by a - single equivalent borehole. The FLS solution is adapted to evaluate - thermal interactions between groups of boreholes. This greatly reduces - the number of evaluations of the FLS solution and the size of the system of - equations to evaluate the g-function. - - Parameters - ---------- - boreholes : list of Borehole objects - List of boreholes included in the bore field. - network : network object - Model of the network. - time : float or array - Values of time (in seconds) for which the g-function is evaluated. - boundary_condition : str - Boundary condition for the evaluation of the g-function. Should be one - of - - - 'UHTR' : - **Uniform heat transfer rate**. This is corresponds to boundary - condition *BC-I* as defined by Cimmino and Bernier (2014) - [#Equivalent-CimBer2014]_. - - 'UBWT' : - **Uniform borehole wall temperature**. This is corresponds to - boundary condition *BC-III* as defined by Cimmino and Bernier - (2014) [#Equivalent-CimBer2014]_. - - 'MIFT' : - **Mixed inlet fluid temperatures**. This boundary condition was - introduced by Cimmino (2015) [#Equivalent-Cimmin2015]_ for - parallel-connected boreholes and extended to mixed - configurations by Cimmino (2019) [#Equivalent-Cimmin2019]_. - - nSegments : int or list, optional - Number of line segments used per borehole, or list of number of - line segments used for each borehole. - Default is 8. - segment_ratios : array, list of arrays, or callable, optional - Ratio of the borehole length represented by each segment. The - sum of ratios must be equal to 1. The shape of the array is of - (nSegments,) or list of (nSegments[i],). If segment_ratios==None, - segments of equal lengths are considered. If a callable is provided, it - must return an array of size (nSegments,) when provided with nSegments - (of type int) as an argument, or an array of size (nSegments[i],) when - provided with an element of nSegments (of type list). - Default is :func:`utilities.segment_ratios`. - m_flow_borehole : (nInlets,) array or (nMassFlow, nInlets,) array, optional - Fluid mass flow rate into each circuit of the network. If a - (nMassFlow, nInlets,) array is supplied, the - (nMassFlow, nMassFlow,) variable mass flow rate g-functions - will be evaluated using the method of Cimmino (2024) - [#gFunction-CimBer2024]_. Only required for the 'MIFT' boundary - condition. Only one of 'm_flow_borehole' and 'm_flow_network' can be - provided. - Default is None. - m_flow_network : float or (nMassFlow,) array, optional - Fluid mass flow rate into the network of boreholes. If an array - is supplied, the (nMassFlow, nMassFlow,) variable mass flow - rate g-functions will be evaluated using the method of Cimmino - (2024) [#gFunction-CimBer2024]_. Only required for the 'MIFT' boundary - condition. Only one of 'm_flow_borehole' and 'm_flow_network' can be - provided. - Default is None. - cp_f : float, optional - Fluid specific isobaric heat capacity (in J/kg.degC). Only required - for the 'MIFT' boundary condition. - Default is None. - approximate_FLS : bool, optional - Set to true to use the approximation of the FLS solution of Cimmino - (2021) [#Equivalent-Cimmin2021]_. This approximation does not require - the numerical evaluation of any integral. When using the 'equivalent' - solver, the approximation is only applied to the thermal response at - the borehole radius. Thermal interaction between boreholes is evaluated - using the FLS solution. - Default is False. - nFLS : int, optional - Number of terms in the approximation of the FLS solution. This - parameter is unused if `approximate_FLS` is set to False. - Default is 10. Maximum is 25. - mQuad : int, optional - Number of Gauss-Legendre sample points for the integral over :math:`u` - in the inclined FLS solution. - Default is 11. - linear_threshold : float, optional - Threshold time (in seconds) under which the g-function is - linearized. The g-function value is then interpolated between 0 - and its value at the threshold. If linear_threshold==None, the - g-function is linearized for times - `t < r_b**2 / (25 * self.alpha)`. - Default is None. - disp : bool, optional - Set to true to print progression messages. - Default is False. - profiles : bool, optional - Set to true to keep in memory the temperatures and heat extraction - rates. - Default is False. - kind : string, optional - Interpolation method used for segment-to-segment thermal response - factors. See documentation for scipy.interpolate.interp1d. - Default is 'linear'. - dtype : numpy dtype, optional - numpy data type used for matrices and vectors. Should be one of - numpy.single or numpy.double. - Default is numpy.double. - disTol : float, optional - Relative tolerance on radial distance. Two distances - (d1, d2) between two pairs of boreholes are considered equal if the - difference between the two distances (abs(d1-d2)) is below tolerance. - Default is 0.01. - tol : float, optional - Relative tolerance on length and depth. Two lengths H1, H2 - (or depths D1, D2) are considered equal if abs(H1 - H2)/H2 < tol. - Default is 1.0e-6. - kClusters : int, optional - Increment on the minimum number of equivalent boreholes determined by - cutting the dendrogram of the bore field given by the hierarchical - agglomerative clustering method. Increasing the value of this parameter - increases the accuracy of the method. - Default is 1. - - References - ---------- - .. [#Equivalent-CimBer2014] Cimmino, M., & Bernier, M. (2014). A - semi-analytical method to generate g-functions for geothermal bore - fields. International Journal of Heat and Mass Transfer, 70, 641-650. - .. [#Equivalent-Cimmin2015] Cimmino, M. (2015). The effects of borehole - thermal resistances and fluid flow rate on the g-functions of geothermal - bore fields. International Journal of Heat and Mass Transfer, 91, - 1119-1127. - .. [#Equivalent-Cimmin2018] Cimmino, M. (2018). Fast calculation of the - g-functions of geothermal borehole fields using similarities in the - evaluation of the finite line source solution. Journal of Building - Performance Simulation, 11 (6), 655-668. - .. [#Equivalent-PriCim2021] Prieto, C., & Cimmino, M. - (2021). Thermal interactions in large irregular fields of geothermal - boreholes: the method of equivalent borehole. Journal of Building - Performance Simulation, 14 (4), 446-460. - .. [#Equivalent-Cimmin2021] Cimmino, M. (2021). An approximation of the - finite line source solution to model thermal interactions between - geothermal boreholes. International Communications in Heat and Mass - Transfer, 127, 105496. - - """ - def initialize(self, disTol=0.01, tol=1.0e-6, kClusters=1, **kwargs): - """ - Initialize paramteters. Identify groups for equivalent boreholes. - - Returns - ------- - nSources : int - Number of finite line heat sources in the borefield used to - initialize the matrix of segment-to-segment thermal response - factors (of size: nSources x nSources). - - """ - self.disTol = disTol - self.tol = tol - self.kClusters = kClusters - # Check the validity of inputs - self._check_solver_specific_inputs() - # Initialize groups for equivalent boreholes - nSources = self.find_groups() - self.nBoreSegments = [self.nBoreSegments[0]] * self.nEqBoreholes - self.segment_ratios = [self.segment_ratios[0]] * self.nEqBoreholes - self.boreSegments = self.borehole_segments() - self._i0Segments = [sum(self.nBoreSegments[0:i]) - for i in range(self.nEqBoreholes)] - self._i1Segments = [sum(self.nBoreSegments[0:(i + 1)]) - for i in range(self.nEqBoreholes)] - return nSources - - def thermal_response_factors(self, time, alpha, kind='linear'): - """ - Evaluate the segment-to-segment thermal response factors for all pairs - of segments in the borefield at all time steps using the finite line - source solution. - - This method returns a scipy.interpolate.interp1d object of the matrix - of thermal response factors, containing a copy of the matrix accessible - by h_ij.y[:nSources,:nSources,:nt+1]. The first index along the - third axis corresponds to time t=0. The interp1d object can be used to - obtain thermal response factors at any intermediate time by - h_ij(t)[:nSources,:nSources]. - - Parameters - ---------- - time : float or array - Values of time (in seconds) for which the g-function is evaluated. - alpha : float - Soil thermal diffusivity (in m2/s). - kind : string, optional - Interpolation method used for segment-to-segment thermal response - factors. See documentation for scipy.interpolate.interp1d. - Default is linear. - - Returns - ------- - h_ij : interp1d - interp1d object (scipy.interpolate) of the matrix of - segment-to-segment thermal response factors. - - """ - if self.disp: - print('Calculating segment to segment response factors ...', - end='') - # Number of time values - nt = len(np.atleast_1d(time)) - # Initialize chrono - tic = perf_counter() - # Initialize segment-to-segment response factors - h_ij = np.zeros((self.nSources, self.nSources, nt+1), dtype=self.dtype) - segment_lengths = self.segment_lengths() - - # --------------------------------------------------------------------- - # Segment-to-segment thermal response factors for borehole-to-borehole - # thermal interactions - # --------------------------------------------------------------------- - # Groups correspond to unique pairs of borehole dimensions - for pairs in self.borehole_to_borehole: - i, j = pairs[0] - # Prepare inputs to the FLS function - dis, wDis = self._find_unique_distances(self.dis, pairs) - H1, D1, H2, D2, i_pair, j_pair, k_pair = \ - self._map_axial_segment_pairs(i, j) - H1 = H1.reshape(1, -1) - H2 = H2.reshape(1, -1) - D1 = D1.reshape(1, -1) - D2 = D2.reshape(1, -1) - N2 = np.array( - [[self.boreholes[j].nBoreholes for (i, j) in pairs]]).T - # Evaluate FLS at all time steps - h = finite_line_source_equivalent_boreholes_vectorized( - time, alpha, dis, wDis, H1, D1, H2, D2, N2) - # Broadcast values to h_ij matrix - for k, (i, j) in enumerate(pairs): - i_segment = self._i0Segments[i] + i_pair - j_segment = self._i0Segments[j] + j_pair - h_ij[j_segment, i_segment, 1:] = h[k, k_pair, :] - if not i == j: - h_ij[i_segment, j_segment, 1:] = (h[k, k_pair, :].T \ - * segment_lengths[j_segment]/segment_lengths[i_segment]).T - - # --------------------------------------------------------------------- - # Segment-to-segment thermal response factors for same-borehole thermal - # interactions - # --------------------------------------------------------------------- - # Groups correspond to unique borehole dimensions - for group in self.borehole_to_self: - # Index of first borehole in group - i = group[0] - # Find segment-to-segment similarities - H1, D1, H2, D2, i_pair, j_pair, k_pair = \ - self._map_axial_segment_pairs(i, i) - # Evaluate FLS at all time steps - dis = self.boreholes[i].r_b - H1 = H1.reshape(1, -1) - H2 = H2.reshape(1, -1) - D1 = D1.reshape(1, -1) - D2 = D2.reshape(1, -1) - h = finite_line_source_vectorized( - time, alpha, dis, H1, D1, H2, D2, - approximation=self.approximate_FLS, N=self.nFLS) - # Broadcast values to h_ij matrix - for i in group: - i_segment = self._i0Segments[i] + i_pair - j_segment = self._i0Segments[i] + j_pair - h_ij[j_segment, i_segment, 1:] = \ - h_ij[j_segment, i_segment, 1:] + h[0, k_pair, :] - - # Return 2d array if time is a scalar - if np.isscalar(time): - h_ij = h_ij[:,:,1] - - # Interp1d object for thermal response factors - h_ij = interp1d(np.hstack((0., time)), h_ij, - kind=kind, copy=True, axis=2) - toc = perf_counter() - if self.disp: print(f' {toc - tic:.3f} sec') - - return h_ij - - def find_groups(self, tol=1e-6): - """ - Identify groups of boreholes that can be represented by a single - equivalent borehole for the calculation of the g-function. - - Hierarchical agglomerative clustering is applied to the superposed - steady-state finite line source solution (i.e. the steady-state - dimensionless borehole wall temperature due to a uniform heat - extraction equal for all boreholes). The number of clusters is - evaluated by cutting the dendrogram at the half-height of the longest - branch and incrementing the number of intercepted branches by the value - of the kClusters parameter. - - Parameters - ---------- - tol : float - Tolerance on the temperature to identify the maxiumum number of - equivalent boreholes. - Default is 1e-6. - - Returns - ------- - nSources : int - Number of heat sources in the bore field. - - """ - if self.disp: print('Identifying equivalent boreholes ...', end='') - # Initialize chrono - tic = perf_counter() - - # Temperature change of individual boreholes - self.nBoreholes = len(self.boreholes) - # Equivalent field formed by all boreholes - eqField = _EquivalentBorehole(self.boreholes) - if self.nBoreholes > 1: - # Spatial superposition of the steady-state FLS solution - data = np.sum(finite_line_source(np.inf, 1., self.boreholes, self.boreholes), axis=1).reshape(-1,1) - # Split boreholes into groups of same dimensions - unique_boreholes = self._find_unique_boreholes(self.boreholes) - # Initialize empty list of clusters - self.clusters = [] - self.nEqBoreholes = 0 - for group in unique_boreholes: - if len(group) > 1: - # Maximum temperature - maxTemp = np.max(data[group]) - # Hierarchical agglomerative clustering based on temperatures - clusterization = linkage(data[group], method='complete') - dcoord = np.array( - dendrogram(clusterization, no_plot=True)['dcoord']) - # Maximum number of clusters - # Height to cut each tree to obtain the minimum number of clusters - disLeft = dcoord[:,1] - dcoord[:,0] - disRight = dcoord[:,2] - dcoord[:,3] - if np.max(disLeft) >= np.max(disRight): - i = disLeft.argmax() - height = 0.5*(dcoord[i,1] + dcoord[i,0]) - else: - i = disRight.argmax() - height = 0.5*(dcoord[i,2] + dcoord[i,3]) - # Find the number of clusters and increment by kClusters - # Maximum number of clusters - nClustersMax = min(np.sum(dcoord[:,1] > tol*maxTemp) + 1, - len(group)) - # Optimal number of cluster - nClusters = np.max( - cut_tree(clusterization, height=height)) + 1 - nClusters = min(nClusters + self.kClusters, nClustersMax) - # Cut the tree to find the borehole groups - clusters = cut_tree( - clusterization, n_clusters=nClusters) - self.clusters = self.clusters + \ - [label + self.nEqBoreholes for label in clusters] - else: - nClusters = 1 - self.clusters.append(self.nEqBoreholes) - self.nEqBoreholes += nClusters - else: - self.nEqBoreholes = self.nBoreholes - self.clusters = range(self.nBoreholes) - # Overwrite boreholes with equivalent boreholes - self.boreholes = [_EquivalentBorehole( - [borehole - for borehole, cluster in zip(self.boreholes, self.clusters) - if cluster==i]) - for i in range(self.nEqBoreholes)] - self.wBoreholes = np.array([b.nBoreholes for b in self.boreholes]) - # Find similar pairs of boreholes - self.borehole_to_self, self.borehole_to_borehole = \ - self._find_axial_borehole_pairs(self.boreholes) - # Store unique distances in the bore field - self.dis = eqField.unique_distance(eqField, self.disTol)[0][1:] - - if self.boundary_condition == 'MIFT': - pipes = [self.network.p[self.clusters.index(i)] - for i in range(self.nEqBoreholes)] - self.network = _EquivalentNetwork( - self.boreholes, - pipes, - nSegments=self.nBoreSegments[0], - segment_ratios=self.segment_ratios[0]) - - # Stop chrono - toc = perf_counter() - if self.disp: - print(f' {toc - tic:.3f} sec') - print(f'Calculations will be done using {self.nEqBoreholes} ' - f'equivalent boreholes') - - return self.nBoreSegments[0]*self.nEqBoreholes - - def segment_lengths(self): - """ - Return the length of all segments in the bore field. - - The segments lengths are used for the energy balance in the calculation - of the g-function. For equivalent boreholes, the length of segments - is multiplied by the number of boreholes in the group. - - Returns - ------- - H : array - Array of segment lengths (in m). - - """ - # Borehole lengths - H = np.array([seg.H*seg.nBoreholes - for (borehole, nSegments, ratios) in zip( - self.boreholes, - self.nBoreSegments, - self.segment_ratios) - for seg in borehole.segments( - nSegments, segment_ratios=ratios)], - dtype=self.dtype) - return H - - def _compare_boreholes(self, borehole1, borehole2): - """ - Compare two boreholes and checks if they have the same dimensions : - H, D, and r_b. - - Parameters - ---------- - borehole1 : Borehole object - First borehole. - borehole2 : Borehole object - Second borehole. - - Returns - ------- - similarity : bool - True if the two boreholes have the same dimensions. - - """ - # Compare lengths (H), buried depth (D) and radius (r_b) - if (abs((borehole1.H - borehole2.H)/borehole1.H) < self.tol and - abs((borehole1.r_b - borehole2.r_b)/borehole1.r_b) < self.tol and - abs((borehole1.D - borehole2.D)/(borehole1.D + 1e-30)) < self.tol): - similarity = True - else: - similarity = False - return similarity - - def _compare_real_pairs(self, pair1, pair2): - """ - Compare two pairs of boreholes or segments and return True if the two - pairs have the same FLS solution for real sources. - - Parameters - ---------- - pair1 : Tuple of Borehole objects - First pair of boreholes or segments. - pair2 : Tuple of Borehole objects - Second pair of boreholes or segments. - - Returns - ------- - similarity : bool - True if the two pairs have the same FLS solution. - - """ - deltaD1 = pair1[1].D - pair1[0].D - deltaD2 = pair2[1].D - pair2[0].D - - # Equality of lengths between pairs - cond_H = (abs((pair1[0].H - pair2[0].H)/pair1[0].H) < self.tol - and abs((pair1[1].H - pair2[1].H)/pair1[1].H) < self.tol) - # Equality of lengths in each pair - equal_H = abs((pair1[0].H - pair1[1].H)/pair1[0].H) < self.tol - # Equality of buried depths differences - cond_deltaD = abs(deltaD1 - deltaD2)/abs(deltaD1 + 1e-30) < self.tol - # Equality of buried depths differences if all boreholes have the same - # length - cond_deltaD_equal_H = abs((abs(deltaD1) - abs(deltaD2))/(abs(deltaD1) + 1e-30)) < self.tol - if cond_H and (cond_deltaD or (equal_H and cond_deltaD_equal_H)): - similarity = True - else: - similarity = False - return similarity - - def _compare_image_pairs(self, pair1, pair2): - """ - Compare two pairs of boreholes or segments and return True if the two - pairs have the same FLS solution for mirror sources. - - Parameters - ---------- - pair1 : Tuple of Borehole objects - First pair of boreholes or segments. - pair2 : Tuple of Borehole objects - Second pair of boreholes or segments. - - Returns - ------- - similarity : bool - True if the two pairs have the same FLS solution. - - """ - sumD1 = pair1[1].D + pair1[0].D - sumD2 = pair2[1].D + pair2[0].D - - # Equality of lengths between pairs - cond_H = (abs((pair1[0].H - pair2[0].H)/pair1[0].H) < self.tol - and abs((pair1[1].H - pair2[1].H)/pair1[1].H) < self.tol) - # Equality of buried depths sums - cond_sumD = abs((sumD1 - sumD2)/(sumD1 + 1e-30)) < self.tol - if cond_H and cond_sumD: - similarity = True - else: - similarity = False - return similarity - - def _compare_realandimage_pairs(self, pair1, pair2): - """ - Compare two pairs of boreholes or segments and return True if the two - pairs have the same FLS solution for both real and mirror sources. - - Parameters - ---------- - pair1 : Tuple of Borehole objects - First pair of boreholes or segments. - pair2 : Tuple of Borehole objects - Second pair of boreholes or segments. - - Returns - ------- - similarity : bool - True if the two pairs have the same FLS solution. - - """ - if (self._compare_real_pairs(pair1, pair2) - and self._compare_image_pairs(pair1, pair2)): - similarity = True - else: - similarity = False - return similarity - - def _find_axial_borehole_pairs(self, boreholes): - """ - Find axial (i.e. disregarding the radial distance) similarities between - borehole pairs to simplify the evaluation of the FLS solution. - - Parameters - ---------- - boreholes : list of Borehole objects - Boreholes in the bore field. - - Returns - ------- - borehole_to_self : list - Lists of borehole indexes for each unique set of borehole - dimensions (H, D, r_b) in the bore field. - borehole_to_borehole : list - Lists of tuples of borehole indexes for each unique pair of - boreholes that share the same (pairwise) dimensions (H, D). - - """ - # Compare for the full (real + image) FLS solution - compare_pairs = self._compare_realandimage_pairs - - nBoreholes = len(boreholes) - borehole_to_self = [] - # Only check for similarities if there is more than one borehole - if nBoreholes > 1: - borehole_to_borehole = [] - for i, borehole_i in enumerate(boreholes): - # Compare the borehole to all known unique sets of dimensions - for k, borehole_set in enumerate(borehole_to_self): - m = borehole_set[0] - # Add the borehole to the group if a similar borehole is - # found - if self._compare_boreholes(borehole_i, boreholes[m]): - borehole_set.append(i) - break - else: - # If no similar boreholes are known, append the groups - borehole_to_self.append([i]) - # Note : The range is different from similarities since - # an equivalent borehole to itself includes borehole-to- - # borehole thermal interactions - for j, borehole_j in enumerate(boreholes[i:], start=i): - pair0 = (borehole_i, borehole_j) # pair - pair1 = (borehole_j, borehole_i) # reciprocal pair - # Compare pairs of boreholes to known unique pairs - for pairs in borehole_to_borehole: - m, n = pairs[0] - pair_ref = (boreholes[m], boreholes[n]) - # Add the pair (or the reciprocal pair) to a group - # if a similar one is found - if compare_pairs(pair0, pair_ref): - pairs.append((i, j)) - break - elif compare_pairs(pair1, pair_ref): - pairs.append((j, i)) - break - # If no similar pairs are known, append the groups - else: - borehole_to_borehole.append([(i, j)]) - else: - # Outputs for a single borehole - borehole_to_self = [[0]] - borehole_to_borehole = [[(0, 0)]] - return borehole_to_self, borehole_to_borehole - - def _find_unique_boreholes(self, boreholes): - """ - Find unique sets of dimensions (h, D, r_b) in the bore field. - - Parameters - ---------- - boreholes : list of Borehole objects - Boreholes in the bore field. - - Returns - ------- - unique_boreholes : list - List of list of borehole indices that correspond to unique - borehole dimensions (H, D, r_b). - - """ - unique_boreholes = [] - for i, borehole_1 in enumerate(boreholes): - for group in unique_boreholes: - borehole_2 = boreholes[group[0]] - # Add the borehole to a group if similar dimensions are found - if self._compare_boreholes(borehole_1, borehole_2): - group.append(i) - break - else: - # If no similar boreholes are known, append the groups - unique_boreholes.append([i]) - - return unique_boreholes - - def _find_unique_distances(self, dis, indices): - """ - Find the number of occurences of each unique distances between pairs - of boreholes. - - Parameters - ---------- - dis : array - Array of unique distances (in meters) in the bore field. - indices : list - List of tuples of borehole indices. - - Returns - ------- - dis : array - Array of unique distances (in meters) in the bore field. - wDis : array - Array of number of occurences of each unique distance for each - pair of equivalent boreholes in indices. - - """ - wDis = np.zeros((len(dis), len(indices)), dtype=np.uint) - for k, pair in enumerate(indices): - i, j = pair - b1, b2 = self.boreholes[i], self.boreholes[j] - # Generate a flattened array of distances between boreholes i and j - if not i == j: - dis_ij = b1.distance(b2).flatten() - else: - # Remove the borehole radius from the distances - dis_ij = b1.distance(b2)[ - ~np.eye(b1.nBoreholes, dtype=bool)].flatten() - wDis_ij = np.zeros(len(dis), dtype=np.uint) - # Get insert positions for the distances - iDis = np.searchsorted(dis, dis_ij, side='left') - # Find indexes where previous index is closer - prev_iDis_is_less = ((iDis == len(dis))|(np.fabs(dis_ij - dis[np.maximum(iDis-1, 0)]) < np.fabs(dis_ij - dis[np.minimum(iDis, len(dis)-1)]))) - iDis[prev_iDis_is_less] -= 1 - np.add.at(wDis_ij, iDis, 1) - wDis[:,k] = wDis_ij - - return dis.reshape((1, -1)), wDis - - def _map_axial_segment_pairs(self, iBor, jBor, - reaSource=True, imgSource=True): - """ - Find axial (i.e. disregarding the radial distance) similarities between - segment pairs along two boreholes to simplify the evaluation of the - FLS solution. - - The returned H1, D1, H2, and D2 can be used to evaluate the segment-to- - segment response factors using scipy.integrate.quad_vec. - - Parameters - ---------- - iBor : int - Index of the first borehole. - jBor : int - Index of the second borehole. - - Returns - ------- - H1 : float - Length of the emitting segments. - D1 : array - Array of buried depths of the emitting segments. - H2 : float - Length of the receiving segments. - D2 : array - Array of buried depths of the receiving segments. - i_pair : list - Indices of the emitting segments along a borehole. - j_pair : list - Indices of the receiving segments along a borehole. - k_pair : list - Indices of unique segment pairs in the (H1, D1, H2, D2) dimensions - corresponding to all pairs in (i_pair, j_pair). - - """ - # Initialize local variables - borehole1 = self.boreholes[iBor] - borehole2 = self.boreholes[jBor] - assert reaSource or imgSource, \ - "At least one of reaSource and imgSource must be True." - if reaSource and imgSource: - # Find segment pairs for the full (real + image) FLS solution - compare_pairs = self._compare_realandimage_pairs - elif reaSource: - # Find segment pairs for the real FLS solution - compare_pairs = self._compare_real_pairs - elif imgSource: - # Find segment pairs for the image FLS solution - compare_pairs = self._compare_image_pairs - # Dive both boreholes into segments - segments1 = borehole1.segments( - self.nBoreSegments[iBor], segment_ratios=self.segment_ratios[iBor]) - segments2 = borehole2.segments( - self.nBoreSegments[jBor], segment_ratios=self.segment_ratios[jBor]) - # Prepare lists of segment lengths - H1 = [] - H2 = [] - # Prepare lists of segment buried depths - D1 = [] - D2 = [] - # All possible pairs (i, j) of indices between segments - i_pair = np.repeat(np.arange(self.nBoreSegments[iBor], dtype=np.uint), - self.nBoreSegments[jBor]) - j_pair = np.tile(np.arange(self.nBoreSegments[jBor], dtype=np.uint), - self.nBoreSegments[iBor]) - # Empty list of indices for unique pairs - k_pair = np.empty(self.nBoreSegments[iBor] * self.nBoreSegments[jBor], - dtype=np.uint) - unique_pairs = [] - nPairs = 0 - - p = 0 - for i, segment_i in enumerate(segments1): - for j, segment_j in enumerate(segments2): - pair = (segment_i, segment_j) - # Compare the segment pairs to all known unique pairs - for k, pair_k in enumerate(unique_pairs): - m, n = pair_k[0], pair_k[1] - pair_ref = (segments1[m], segments2[n]) - # Stop if a similar pair is found and assign the index - if compare_pairs(pair, pair_ref): - k_pair[p] = k - break - # If no similar pair is found : add a new pair, increment the - # number of unique pairs, and extract the associated buried - # depths - else: - k_pair[p] = nPairs - H1.append(segment_i.H) - H2.append(segment_j.H) - D1.append(segment_i.D) - D2.append(segment_j.D) - unique_pairs.append((i, j)) - nPairs += 1 - p += 1 - return np.array(H1), np.array(D1), np.array(H2), np.array(D2), i_pair, j_pair, k_pair - - def _check_solver_specific_inputs(self): - """ - This method ensures that solver specific inputs to the Solver object - are what is expected. - - """ - assert type(self.disTol) is float and self.disTol > 0., \ - "The distance tolerance 'disTol' should be a positive float." - assert type(self.tol) is float and self.tol > 0., \ - "The relative tolerance 'tol' should be a positive float." - assert type(self.kClusters) is int and self.kClusters >= 0, \ - "The precision increment 'kClusters' should be a positive int." - assert np.all(np.array(self.nBoreSegments, dtype=np.uint) == self.nBoreSegments[0]), \ - "Solver 'equivalent' can only handle equal numbers of segments." - assert np.all([np.allclose(segment_ratios, self.segment_ratios[0]) for segment_ratios in self.segment_ratios]), \ - "Solver 'equivalent' can only handle identical segment_ratios for all boreholes." - assert not np.any([b.is_tilted() for b in self.boreholes]), \ - "Solver 'equivalent' can only handle vertical boreholes." - if self.boundary_condition == 'MIFT': - assert np.all(np.array(self.network.c, dtype=int) == -1), \ - "Solver 'equivalent' is only valid for parallel-connected " \ - "boreholes." - assert (self.m_flow_borehole is None - or (self.m_flow_borehole.ndim==1 and np.allclose(self.m_flow_borehole, self.m_flow_borehole[0])) - or (self.m_flow_borehole.ndim==2 and np.all([np.allclose(self.m_flow_borehole[:, i], self.m_flow_borehole[0, i]) for i in range(self.nBoreholes)]))), \ - "Mass flow rates into the network must be equal for all " \ - "boreholes." - # Use the total network mass flow rate. - if (type(self.network.m_flow_network) is np.ndarray and \ - len(self.network.m_flow_network)==len(self.network.b)): - self.network.m_flow_network = \ - self.network.m_flow_network[0]*len(self.network.b) - # Verify that all boreholes have the same piping configuration - # This is best done by comparing the matrix of thermal resistances. - assert np.all( - [np.allclose(self.network.p[0]._Rd, pipe._Rd) - for pipe in self.network.p]), \ - "All boreholes must have the same piping configuration." - return diff --git a/pygfunction/heat_transfer.py b/pygfunction/heat_transfer.py deleted file mode 100644 index 9e5b457f..00000000 --- a/pygfunction/heat_transfer.py +++ /dev/null @@ -1,1516 +0,0 @@ -# -*- coding: utf-8 -*- -import numpy as np -from scipy.integrate import quad, quad_vec -from scipy.special import erfc, erf, roots_legendre - -from .boreholes import Borehole -from .utilities import erfint, exp1, _erf_coeffs - - -def finite_line_source( - time, alpha, borehole1, borehole2, reaSource=True, imgSource=True, - approximation=False, M=11, N=10): - """ - Evaluate the Finite Line Source (FLS) solution. - - This function uses a numerical quadrature to evaluate the one-integral form - of the FLS solution. For vertical boreholes, the FLS solution was proposed - by Claesson and Javed [#FLS-ClaJav2011]_ and extended to boreholes with - different vertical positions by Cimmino and Bernier [#FLS-CimBer2014]_. - The FLS solution is given by: - - .. math:: - h_{1\\rightarrow2}(t) &= \\frac{1}{2H_2} - \\int_{\\frac{1}{\\sqrt{4\\alpha t}}}^{\\infty} - e^{-d_{12}^2s^2}(I_{real}(s)+I_{imag}(s))ds - - - d_{12} &= \\sqrt{(x_1 - x_2)^2 + (y_1 - y_2)^2} - - - I_{real}(s) &= erfint((D_2-D_1+H_2)s) - erfint((D_2-D_1)s) - - &+ erfint((D_2-D_1-H_1)s) - erfint((D_2-D_1+H_2-H_1)s) - - I_{imag}(s) &= erfint((D_2+D_1+H_2)s) - erfint((D_2+D_1)s) - - &+ erfint((D_2+D_1+H_1)s) - erfint((D_2+D_1+H_2+H_1)s) - - - erfint(X) &= \\int_{0}^{X} erf(x) dx - - &= Xerf(X) - \\frac{1}{\\sqrt{\\pi}}(1-e^{-X^2}) - - For inclined boreholes, the FLS solution was proposed by Lazzarotto - [#FLS-Lazzar2016]_ and Lazzarotto and Björk [#FLS-LazBjo2016]_. - The FLS solution is given by: - - .. math:: - h_{1\\rightarrow2}(t) &= \\frac{H_1}{2H_2} - \\int_{\\frac{1}{\\sqrt{4\\alpha t}}}^{\\infty} - \\frac{1}{s} - \\int_{0}^{1} (I_{real}(u, s)+I_{imag}(u, s)) du ds - - - I_{real}(u, s) &= - e^{-((x_1 - x_2)^2 + (y_1 - y_2)^2 + (D_1 - D_2)^2) s^2} - - &\\cdot (erf((u H_1 k_{0,real} + k_{2,real}) s) - - erf((u H_1 k_{0,real} + k_{2,real} - H_2) s)) - - &\\cdot e^{(u^2 H_1^2 (k_{0,real}^2 - 1) - + 2 u H_1 (k_{0,real} k_{2,real} - k_{1,real}) + k_{2,real}^2) s^2} - du ds - - - I_{imag}(u, s) &= - -e^{-((x_1 - x_2)^2 + (y_1 - y_2)^2 + (D_1 + D_2)^2) s^2} - - &\\cdot (erf((u H_1 k_{0,imag} + k_{2,imag}) s) - - erf((u H_1 k_{0,imag} + k_{2,imag} - H_2) s)) - - &\\cdot e^{(u^2 H_1^2 (k_{0,imag}^2 - 1) - + 2 u H_1 (k_{0,imag} k_{2,imag} - k_1) + k_{2,imag}^2) s^2} - du ds - - - k_{0,real} &= - sin(\\beta_1) sin(\\beta_2) cos(\\theta_1 - \\theta_2) - + cos(\\beta_1) cos(\\beta_2) - - - k_{0,imag} &= - sin(\\beta_1) sin(\\beta_2) cos(\\theta_1 - \\theta_2) - - cos(\\beta_1) cos(\\beta_2) - - - k_{1,real} &= sin(\\beta_1) - (cos(\\theta_1) (x_1 - x_2) + sin(\\theta_1) (y_1 - y_2)) - + cos(\\beta_1) (D_1 - D_2) - - - k_{1,imag} &= sin(\\beta_1) - (cos(\\theta_1) (x_1 - x_2) + sin(\\theta_1) (y_1 - y_2)) - + cos(\\beta_1) (D_1 + D_2) - - - k_{2,real} &= sin(\\beta_2) - (cos(\\theta_2) (x_1 - x_2) + sin(\\theta_2) (y_1 - y_2)) - + cos(\\beta_2) (D_1 - D_2) - - - k_{2,imag} &= sin(\\beta_2) - (cos(\\theta_2) (x_1 - x_2) + sin(\\theta_2) (y_1 - y_2)) - - cos(\\beta_2) (D_1 + D_2) - - where :math:`\\beta_1` and :math:`\\beta_2` are the tilt angle of the - boreholes (relative to vertical), and :math:`\\theta_1` and - :math:`\\theta_2` are the orientation of the boreholes (relative to the - x-axis). - - .. Note:: - The reciprocal thermal response factor - :math:`h_{2\\rightarrow1}(t)` can be conveniently calculated by: - - .. math:: - h_{2\\rightarrow1}(t) = \\frac{H_2}{H_1} - h_{1\\rightarrow2}(t) - - Parameters - ---------- - time : float or array, shape (K) - Value of time (in seconds) for which the FLS solution is evaluated. - alpha : float - Soil thermal diffusivity (in m2/s). - borehole1 : Borehole object or list of Borehole objects, length (N) - Borehole object of the borehole extracting heat. - borehole2 : Borehole object or list of Borehole objects, length (M) - Borehole object for which the FLS is evaluated. - reaSource : bool - True if the real part of the FLS solution is to be included. - Default is True. - imgSource : bool, optional - True if the image part of the FLS solution is to be included. - Default is True. - approximation : bool, optional - Set to true to use the approximation of the FLS solution of Cimmino - (2021) [#FLS-Cimmin2021]_. This approximation does not require - the numerical evaluation of any integral. - Default is False. - M : int, optional - Number of Gauss-Legendre sample points for the quadrature over - :math:`u`. This is only used for inclined boreholes. - Default is 11. - N : int, optional - Number of terms in the approximation of the FLS solution. This - parameter is unused if `approximation` is set to False. - Default is 10. Maximum is 25. - - Returns - ------- - h : float or array, shape (M, N, K), (M, N) or (K) - Value of the FLS solution. The average (over the length) temperature - drop on the wall of borehole2 due to heat extracted from borehole1 is: - - .. math:: \\Delta T_{b,2} = T_g - \\frac{Q_1}{2\\pi k_s H_2} h - - Notes - ----- - The function returns a float if time is a float and borehole1 and borehole2 - are Borehole objects. If time is a float and any of borehole1 and borehole2 - are lists, the function returns an array, shape (M, N), If time is an array - and borehole1 and borehole2 are Borehole objects, the function returns an - array, shape (K).If time is an array and any of borehole1 and borehole2 are - are lists, the function returns an array, shape (M, N, K). - - Examples - -------- - >>> b1 = gt.boreholes.Borehole(H=150., D=4., r_b=0.075, x=0., y=0.) - >>> b2 = gt.boreholes.Borehole(H=150., D=4., r_b=0.075, x=5., y=0.) - >>> h = gt.heat_transfer.finite_line_source(4*168*3600., 1.0e-6, b1, b2) - h = 0.0110473635393 - >>> h = gt.heat_transfer.finite_line_source( - 4*168*3600., 1.0e-6, b1, b2, approximation=True, N=10) - h = 0.0110474667731 - >>> b3 = gt.boreholes.Borehole( - H=150., D=4., r_b=0.075, x=5., y=0., tilt=3.1415/15, orientation=0.) - >>> h = gt.heat_transfer.finite_line_source( - 4*168*3600., 1.0e-6, b1, b3, M=21) - h = 0.0002017450051 - - References - ---------- - .. [#FLS-ClaJav2011] Claesson, J., & Javed, S. (2011). An analytical - method to calculate borehole fluid temperatures for time-scales from - minutes to decades. ASHRAE Transactions, 117(2), 279-288. - .. [#FLS-CimBer2014] Cimmino, M., & Bernier, M. (2014). A - semi-analytical method to generate g-functions for geothermal bore - fields. International Journal of Heat and Mass Transfer, 70, 641-650. - .. [#FLS-Cimmin2021] Cimmino, M. (2021). An approximation of the - finite line source solution to model thermal interactions between - geothermal boreholes. International Communications in Heat and Mass - Transfer, 127, 105496. - .. [#FLS-Lazzar2016] Lazzarotto, A. (2016). A methodology for the - calculation of response functions for geothermal fields with - arbitrarily oriented boreholes – Part 1, Renewable Energy, 86, - 1380-1393. - .. [#FLS-LazBjo2016] Lazzarotto, A., & Björk, F. (2016). A methodology for - the calculation of response functions for geothermal fields with - arbitrarily oriented boreholes – Part 2, Renewable Energy, 86, - 1353-1361. - - """ - if isinstance(borehole1, Borehole) and isinstance(borehole2, Borehole): - # Unpack parameters - H1, D1 = borehole1.H, borehole1.D - H2, D2 = borehole2.H, borehole2.D - if borehole1.is_vertical() and borehole2.is_vertical(): - # Boreholes are vertical - dis = borehole1.distance(borehole2) - if time is np.inf: - # Steady-state solution - h = _finite_line_source_steady_state( - dis, H1, D1, H2, D2, reaSource, imgSource) - elif approximation: - # Approximation - h = finite_line_source_approximation( - time, alpha, dis, H1, D1, H2, D2, - reaSource=reaSource, imgSource=imgSource, N=N) - else: - # Integrand of the finite line source solution - f = _finite_line_source_integrand( - dis, H1, D1, H2, D2, reaSource, imgSource) - # Evaluate integral - if isinstance(time, (np.floating, float)): - # Lower bound of integration - a = 1.0 / np.sqrt(4.0*alpha*time) - h = 0.5 / H2 * quad(f, a, np.inf)[0] - else: - # Lower bound of integration - a = 1.0 / np.sqrt(4.0*alpha*time) - # Upper bound of integration - b = np.concatenate(([np.inf], a[:-1])) - h = np.cumsum(np.stack( - [0.5 / H2 * quad(f, a_i, b_i)[0] - for t, a_i, b_i in zip(time, a, b)], - axis=-1), axis=-1) - else: - # At least one borehole is tilted - # Unpack parameters - x1, y1 = borehole1.position() - rb1 = borehole1.r_b - tilt1 = borehole1.tilt - orientation1 = borehole1.orientation - x2, y2 = borehole2.position() - tilt2 = borehole2.tilt - orientation2 = borehole2.orientation - if time is np.inf: - # Steady-state solution - h = _finite_line_source_inclined_steady_state( - rb1, x1, y1, H1, D1, tilt1, orientation1, - x2, y2, H2, D2, tilt2, orientation2, - reaSource, imgSource, M=M) - elif approximation: - # Approximation - h = finite_line_source_inclined_approximation( - time, alpha, rb1, x1, y1, H1, D1, tilt1, orientation1, - x2, y2, H2, D2, tilt2, orientation2, - reaSource=reaSource, imgSource=imgSource, M=M, N=N) - else: - # Integrand of the inclined finite line source solution - f = _finite_line_source_inclined_integrand( - rb1, x1, y1, H1, D1, tilt1, orientation1, - x2, y2, H2, D2, tilt2, orientation2, - reaSource, imgSource, M) - - # Evaluate integral - if isinstance(time, (np.floating, float)): - # Lower bound of integration - a = 1.0 / np.sqrt(4.0*alpha*time) - h = 0.5 / H2 * quad(f, a, np.inf)[0] - else: - # Lower bound of integration - a = 1.0 / np.sqrt(4.0*alpha*time) - # Upper bound of integration - b = np.concatenate(([np.inf], a[:-1])) - h = np.cumsum(np.stack( - [0.5 / H2 * quad(f, a_i, b_i)[0] - for t, a_i, b_i in zip(time, a, b)], - axis=-1), axis=-1) - - else: - # Unpack parameters - if isinstance(borehole1, Borehole): borehole1 = [borehole1] - if isinstance(borehole2, Borehole): borehole2 = [borehole2] - x1 = np.array([b.x for b in borehole1]) - y1 = np.array([b.y for b in borehole1]) - x2 = np.array([b.x for b in borehole2]) - y2 = np.array([b.y for b in borehole2]) - r_b = np.array([b.r_b for b in borehole1]) - dis = np.maximum( - np.sqrt(np.add.outer(x2, -x1)**2 + np.add.outer(y2, -y1)**2), - r_b) - D1 = np.array([b.D for b in borehole1]).reshape(1, -1) - H1 = np.array([b.H for b in borehole1]).reshape(1, -1) - D2 = np.array([b.D for b in borehole2]).reshape(-1, 1) - H2 = np.array([b.H for b in borehole2]).reshape(-1, 1) - - if (np.all([b.is_vertical() for b in borehole1]) - and np.all([b.is_vertical() for b in borehole2])): - # All boreholes are vertical - if time is np.inf: - # Steady-state solution - h = _finite_line_source_steady_state( - dis, H1, D1, H2, D2, reaSource, imgSource) - elif approximation: - # Approximation - h = finite_line_source_approximation( - time, alpha, dis, H1, D1, H2, D2, - reaSource=reaSource, imgSource=imgSource, N=N) - else: - # Evaluate integral - h = finite_line_source_vectorized( - time, alpha, dis, H1, D1, H2, D2, - reaSource=reaSource, imgSource=imgSource) - else: - # At least one borehole is tilted - # Unpack parameters - x1 = x1.reshape(1, -1) - y1 = y1.reshape(1, -1) - tilt1 = np.array([b.tilt for b in borehole1]).reshape(1, -1) - orientation1 = np.array([b.orientation for b in borehole1]).reshape(1, -1) - x2 = x2.reshape(-1, 1) - y2 = y2.reshape(-1, 1) - tilt2 = np.array([b.tilt for b in borehole2]).reshape(-1, 1) - orientation2 = np.array([b.orientation for b in borehole2]).reshape(-1, 1) - r_b = r_b.reshape(1, -1) - if time is np.inf: - # Steady-state solution - h = _finite_line_source_inclined_steady_state( - r_b, x1, y1, H1, D1, tilt1, orientation1, - x2, y2, H2, D2, tilt2, orientation2, - reaSource, imgSource, M=M) - elif approximation: - # Approximation - h = finite_line_source_inclined_approximation( - time, alpha, r_b, x1, y1, H1, D1, tilt1, orientation1, - x2, y2, H2, D2, tilt2, orientation2, - reaSource=reaSource, imgSource=imgSource, M=M, N=N) - else: - # Evaluate integral - h = finite_line_source_inclined_vectorized( - time, alpha, - r_b, x1, y1, H1, D1, tilt1, orientation1, - x2, y2, H2, D2, tilt2, orientation2, - reaSource=reaSource, imgSource=imgSource, M=M) - return h - - -def finite_line_source_approximation( - time, alpha, dis, H1, D1, H2, D2, reaSource=True, imgSource=True, - N=10): - """ - Evaluate the Finite Line Source (FLS) solution using the approximation - of Cimmino (2021) [#FLSApprox-Cimmin2021]_. - - Parameters - ---------- - time : float or array, shape (K) - Value of time (in seconds) for which the FLS solution is evaluated. - alpha : float - Soil thermal diffusivity (in m2/s). - dis : float or array - Radial distances to evaluate the FLS solution. - H1 : float or array - Lengths of the emitting heat sources. - D1 : float or array - Buried depths of the emitting heat sources. - H2 : float or array - Lengths of the receiving heat sources. - D2 : float or array - Buried depths of the receiving heat sources. - reaSource : bool, optional - True if the real part of the FLS solution is to be included. - Default is True. - imgSource : bool, optional - True if the image part of the FLS solution is to be included. - Default is True. - N : int, optional - Number of terms in the approximation of the FLS solution. This - parameter is unused if `approximation` is set to False. - Default is 10. Maximum is 25. - - Returns - ------- - h : float - Value of the FLS solution. The average (over the length) temperature - drop on the wall of borehole2 due to heat extracted from borehole1 is: - - .. math:: \\Delta T_{b,2} = T_g - \\frac{Q_1}{2\\pi k_s H_2} h - - - References - ---------- - .. [#FLSApprox-Cimmin2021] Cimmino, M. (2021). An approximation of the - finite line source solution to model thermal interactions between - geothermal boreholes. International Communications in Heat and Mass - Transfer, 127, 105496. - - """ - - dis = np.divide.outer(dis, np.sqrt(4*alpha*time)) - H1 = np.divide.outer(H1, np.sqrt(4*alpha*time)) - D1 = np.divide.outer(D1, np.sqrt(4*alpha*time)) - H2 = np.divide.outer(H2, np.sqrt(4*alpha*time)) - D2 = np.divide.outer(D2, np.sqrt(4*alpha*time)) - if reaSource and imgSource: - # Full (real + image) FLS solution - p = np.array([1, -1, 1, -1, 1, -1, 1, -1]) - q = np.abs( - np.stack([D2 - D1 + H2, - D2 - D1, - D2 - D1 - H1, - D2 - D1 + H2 - H1, - D2 + D1 + H2, - D2 + D1, - D2 + D1 + H1, - D2 + D1 + H2 + H1], - axis=-1)) - elif reaSource: - # Real FLS solution - p = np.array([1, -1, 1, -1]) - q = np.abs( - np.stack([D2 - D1 + H2, - D2 - D1, - D2 - D1 - H1, - D2 - D1 + H2 - H1], - axis=-1)) - elif imgSource: - # Image FLS solution - p = np.array([1, -1, 1, -1]) - q = np.abs( - np.stack([D2 + D1 + H2, - D2 + D1, - D2 + D1 + H1, - D2 + D1 + H2 + H1], - axis=-1)) - else: - # No heat source - p = np.zeros(1) - q = np.zeros(1) - # Coefficients of the approximation of the error function - a, b = _erf_coeffs(N) - - dd = dis**2 - qq = q**2 - G1 = np.inner( - p, - q * np.inner( - a, - 0.5* exp1(np.expand_dims(dd, axis=(-1, -2)) + np.multiply.outer(qq, b)))) - x3 = np.sqrt(np.expand_dims(dd, axis=-1) + qq) - G3 = np.inner(p, np.exp(-x3**2) / np.sqrt(np.pi) - x3 * erfc(x3)) - - h = 0.5 / H2 * (G1 + G3) - return h - - -def finite_line_source_inclined_approximation( - time, alpha, - rb1, x1, y1, H1, D1, tilt1, orientation1, - x2, y2, H2, D2, tilt2, orientation2, - reaSource=True, imgSource=True, M=11, N=10): - """ - Evaluate the inclined Finite Line Source (FLS) solution using the - approximation method of Cimmino (2021) [#IncFLSApprox-Cimmin2021]_. - - Parameters - ---------- - time : float or array, shape (K) - Value of time (in seconds) for which the FLS solution is evaluated. - alpha : float - Soil thermal diffusivity (in m2/s). - rb1 : array - Radii of the emitting heat sources. - x1 : float or array - x-Positions of the emitting heat sources. - y1 : float or array - y-Positions of the emitting heat sources. - H1 : float or array - Lengths of the emitting heat sources. - D1 : float or array - Buried depths of the emitting heat sources. - tilt1 : float or array - Angles (in radians) from vertical of the emitting heat sources. - orientation1 : float or array - Directions (in radians) of the tilt the emitting heat sources. - x2 : array - x-Positions of the receiving heat sources. - y2 : array - y-Positions of the receiving heat sources. - H2 : float or array - Lengths of the receiving heat sources. - D2 : float or array - Buried depths of the receiving heat sources. - tilt2 : float or array - Angles (in radians) from vertical of the receiving heat sources. - orientation2 : float or array - Directions (in radians) of the tilt the receiving heat sources. - reaSource : bool, optional - True if the real part of the FLS solution is to be included. - Default is True. - imgSource : bool, optional - True if the image part of the FLS solution is to be included. - Default is true. - M : int, optional - Number of points for the Gauss-Legendre quadrature rule along the - receiving heat sources. - Default is 21. - N : int, optional - Number of terms in the approximation of the FLS solution. - Default is 10. Maximum is 25. - - Returns - ------- - h : float - Value of the FLS solution. The average (over the length) temperature - drop on the wall of borehole2 due to heat extracted from borehole1 is: - - .. math:: \\Delta T_{b,2} = T_g - \\frac{Q_1}{2\\pi k_s H_2} h - - References - ---------- - .. [#IncFLSApprox-Cimmin2021] Cimmino, M. (2021). An approximation of the - finite line source solution to model thermal interactions between - geothermal boreholes. International Communications in Heat and Mass - Transfer, 127, 105496. - - """ - # Expected output shape of h, excluding time - output_shape = np.broadcast_shapes( - *[np.shape(arg) for arg in ( - rb1, x1, y1, H1, D1, tilt1, orientation1, - x2, y2, H2, D2, tilt2, orientation2)]) - # Number of dimensions of the output, excluding time - ouput_ndim = len(output_shape) - # Shape of the time variable - time_shape = np.shape(time) - # Number of dimensions of the time variable - time_ndim = len(time_shape) - # Roots for Gauss-Legendre quadrature - x, w = roots_legendre(M) - u = (0.5 * x + 0.5).reshape((-1, 1) + (1,) * ouput_ndim) - w = w / 2 - # Coefficients of the approximation of the error function - a, b = _erf_coeffs(N) - b = b.reshape((1, -1) + (1,) * ouput_ndim) - # Sines and cosines of tilt (b: beta) and orientation (t: theta) - sb1 = np.sin(tilt1) - sb2 = np.sin(tilt2) - cb1 = np.cos(tilt1) - cb2 = np.cos(tilt2) - st1 = np.sin(orientation1) - st2 = np.sin(orientation2) - ct1 = np.cos(orientation1) - ct2 = np.cos(orientation2) - ct12 = np.cos(orientation1 - orientation2) - # Horizontal distances - dx = x1 - x2 - dy = y1 - y2 - rr = dx**2 + dy**2 # Squared radial distance - # Length ratios - H_ratio = H1 / H2 - H_ratio = np.reshape(H_ratio, np.shape(H_ratio) + (1,) * time_ndim) - # Approximation - ss = 1. / (4 * alpha * time) - if reaSource and imgSource: - # Full (real + image) FLS solution - # Axial distances - dzRea = D1 - D2 - dzImg = D1 + D2 - # FLS-inclined coefficients - kRea_0 = sb1 * sb2 * ct12 + cb1 * cb2 - kImg_0 = sb1 * sb2 * ct12 - cb1 * cb2 - kRea_1 = sb1 * (ct1 * dx + st1 * dy) + cb1 * dzRea - kImg_1 = sb1 * (ct1 * dx + st1 * dy) + cb1 * dzImg - kRea_2 = sb2 * (ct2 * dx + st2 * dy) + cb2 * dzRea - kImg_2 = sb2 * (ct2 * dx + st2 * dy) - cb2 * dzImg - dRea_1 = u * H1 * kRea_0 + kRea_2 - dImg_1 = u * H1 * kImg_0 + kImg_2 - dRea_2 = u * H1 * kRea_0 + kRea_2 - H2 - dImg_2 = u * H1 * kImg_0 + kImg_2 - H2 - cRea = np.maximum( - rr + dzRea**2 - dRea_1**2 + (kRea_1 + u * H1)**2 - kRea_1**2, - rb1**2) - cImg = np.maximum( - rr + dzImg**2 - dImg_1**2 + (kImg_1 + u * H1)**2 - kImg_1**2, - rb1**2) - # Signs for summation - pRea_1 = np.sign(dRea_1) - pRea_1 = np.reshape(pRea_1, np.shape(pRea_1) + (1,) * time_ndim) - pRea_2 = np.sign(dRea_2) - pRea_2 = np.reshape(pRea_2, np.shape(pRea_2) + (1,) * time_ndim) - pImg_1 = np.sign(dImg_1) - pImg_1 = np.reshape(pImg_1, np.shape(pImg_1) + (1,) * time_ndim) - pImg_2 = np.sign(dImg_2) - pImg_2 = np.reshape(pImg_2, np.shape(pImg_2) + (1,) * time_ndim) - # FLS-inclined approximation - h = 0.25 * H_ratio * np.einsum('i,j,ij...', w, a, - (pRea_1 * exp1(np.multiply.outer(cRea + b * dRea_1**2, ss)) \ - - pRea_2 * exp1(np.multiply.outer(cRea + b * dRea_2**2, ss)) \ - - pImg_1 * exp1(np.multiply.outer(cImg + b * dImg_1**2, ss)) \ - + pImg_2 * exp1(np.multiply.outer(cImg + b * dImg_2**2, ss))) ) - elif reaSource: - # Real FLS solution - # Axial distance - dzRea = D1 - D2 - # FLS-inclined coefficients - kRea_0 = sb1 * sb2 * ct12 + cb1 * cb2 - kRea_1 = sb1 * (ct1 * dx + st1 * dy) + cb1 * dzRea - kRea_2 = sb2 * (ct2 * dx + st2 * dy) + cb2 * dzRea - dRea_1 = u * H1 * kRea_0 + kRea_2 - dRea_2 = u * H1 * kRea_0 + kRea_2 - H2 - cRea = np.maximum( - rr + dzRea**2 - dRea_1**2 + (kRea_1 + u * H1)**2 - kRea_1**2, - rb1**2) - # Signs for summation - pRea_1 = np.sign(dRea_1) - pRea_1 = np.reshape(pRea_1, np.shape(pRea_1) + (1,) * time_ndim) - pRea_2 = np.sign(dRea_2) - pRea_2 = np.reshape(pRea_2, np.shape(pRea_2) + (1,) * time_ndim) - # FLS-inclined approximation - h = 0.25 * H_ratio * np.einsum('i,j,ij...', w, a, - (pRea_1 * exp1(np.multiply.outer(cRea + b * dRea_1**2, ss)) \ - - pRea_2 * exp1(np.multiply.outer(cRea + b * dRea_2**2, ss))) ) - elif imgSource: - # Image FLS solution - # Axial distance - dzImg = D1 + D2 - # FLS-inclined coefficients - kImg_0 = sb1 * sb2 * ct12 - cb1 * cb2 - kImg_1 = sb1 * (ct1 * dx + st1 * dy) + cb1 * dzImg - kImg_2 = sb2 * (ct2 * dx + st2 * dy) - cb2 * dzImg - dImg_1 = u * H1 * kImg_0 + kImg_2 - dImg_2 = u * H1 * kImg_0 + kImg_2 - H2 - cImg = np.maximum( - rr + dzImg**2 - dImg_1**2 + (kImg_1 + u * H1)**2 - kImg_1**2, - rb1**2) - # Signs for summation - pImg_1 = np.sign(dImg_1) - pImg_1 = np.reshape(pImg_1, np.shape(pImg_1) + (1,) * time_ndim) - pImg_2 = np.sign(dImg_2) - pImg_2 = np.reshape(pImg_2, np.shape(pImg_2) + (1,) * time_ndim) - # FLS-inclined approximation - h = 0.25 * H_ratio * np.einsum('i,j,ij...', w, a, - (-pImg_1 * exp1(np.multiply.outer(cImg + b * dImg_1**2, ss)) \ - + pImg_2 * exp1(np.multiply.outer(cImg + b * dImg_2**2, ss))) ) - else: - # No heat source - h = np.zeros(output_shape + np.shape(time)) - return h - - -def finite_line_source_vectorized( - time, alpha, dis, H1, D1, H2, D2, reaSource=True, imgSource=True, - approximation=False, N=10): - """ - Evaluate the Finite Line Source (FLS) solution. - - This function uses a numerical quadrature to evaluate the one-integral form - of the FLS solution, as proposed by Claesson and Javed - [#FLSVec-ClaJav2011]_ and extended to boreholes with different vertical - positions by Cimmino and Bernier [#FLSVec-CimBer2014]_. The FLS solution - is given by: - - .. math:: - h_{1\\rightarrow2}(t) &= \\frac{1}{2H_2} - \\int_{\\frac{1}{\\sqrt{4\\alpha t}}}^{\\infty} - e^{-d_{12}^2s^2}(I_{real}(s)+I_{imag}(s))ds - - - I_{real}(s) &= erfint((D_2-D_1+H_2)s) - erfint((D_2-D_1)s) - - &+ erfint((D_2-D_1-H_1)s) - erfint((D_2-D_1+H_2-H_1)s) - - I_{imag}(s) &= erfint((D_2+D_1+H_2)s) - erfint((D_2+D_1)s) - - &+ erfint((D_2+D_1+H_1)s) - erfint((D_2+D_1+H_2+H_1)s) - - - erfint(X) &= \\int_{0}^{X} erf(x) dx - - &= Xerf(X) - \\frac{1}{\\sqrt{\\pi}}(1-e^{-X^2}) - - .. Note:: - The reciprocal thermal response factor - :math:`h_{2\\rightarrow1}(t)` can be conveniently calculated by: - - .. math:: - h_{2\\rightarrow1}(t) = \\frac{H_2}{H_1} - h_{1\\rightarrow2}(t) - - Parameters - ---------- - time : float or array, shape (K) - Value of time (in seconds) for which the FLS solution is evaluated. - alpha : float - Soil thermal diffusivity (in m2/s). - dis : float or array - Radial distances to evaluate the FLS solution. - H1 : float or array - Lengths of the emitting heat sources. - D1 : float or array - Buried depths of the emitting heat sources. - H2 : float or array - Lengths of the receiving heat sources. - D2 : float or array - Buried depths of the receiving heat sources. - reaSource : bool - True if the real part of the FLS solution is to be included. - Default is True. - imgSource : bool - True if the image part of the FLS solution is to be included. - Default is True. - approximation : bool, optional - Set to true to use the approximation of the FLS solution of Cimmino - (2021) [#FLSVec-Cimmin2021]_. This approximation does not require - the numerical evaluation of any integral. - Default is False. - N : int, optional - Number of terms in the approximation of the FLS solution. This - parameter is unused if `approximation` is set to False. - Default is 10. Maximum is 25. - - Returns - ------- - h : float - Value of the FLS solution. The average (over the length) temperature - drop on the wall of borehole2 due to heat extracted from borehole1 is: - - .. math:: \\Delta T_{b,2} = T_g - \\frac{Q_1}{2\\pi k_s H_2} h - - Notes - ----- - This is a vectorized version of the :func:`finite_line_source` function - using scipy.integrate.quad_vec to speed up calculations. All arrays - (dis, H1, D1, H2, D2) must follow numpy array broadcasting rules. If time - is an array, the integrals for different time values are stacked on the - last axis. - - - References - ---------- - .. [#FLSVec-ClaJav2011] Claesson, J., & Javed, S. (2011). An analytical - method to calculate borehole fluid temperatures for time-scales from - minutes to decades. ASHRAE Transactions, 117(2), 279-288. - .. [#FLSVec-CimBer2014] Cimmino, M., & Bernier, M. (2014). A - semi-analytical method to generate g-functions for geothermal bore - fields. International Journal of Heat and Mass Transfer, 70, 641-650. - .. [#FLSVec-Cimmin2021] Cimmino, M. (2021). An approximation of the - finite line source solution to model thermal interactions between - geothermal boreholes. International Communications in Heat and Mass - Transfer, 127, 105496. - - """ - if not approximation: - # Integrand of the finite line source solution - f = _finite_line_source_integrand( - dis, H1, D1, H2, D2, reaSource, imgSource) - - # Evaluate integral - if isinstance(time, (np.floating, float)): - # Lower bound of integration - a = 1.0 / np.sqrt(4.0*alpha*time) - h = 0.5 / H2 * quad_vec(f, a, np.inf)[0] - else: - # Lower bound of integration - a = 1.0 / np.sqrt(4.0*alpha*time) - # Upper bound of integration - b = np.concatenate(([np.inf], a[:-1])) - h = np.cumsum(np.stack( - [0.5 / H2 * quad_vec(f, a_i, b_i)[0] - for t, a_i, b_i in zip(time, a, b)], - axis=-1), axis=-1) - else: - h = finite_line_source_approximation( - time, alpha, dis, H1, D1, H2, D2, reaSource=reaSource, - imgSource=imgSource, N=N) - return h - - -def finite_line_source_equivalent_boreholes_vectorized( - time, alpha, dis, wDis, H1, D1, H2, D2, N2, reaSource=True, imgSource=True): - """ - Evaluate the equivalent Finite Line Source (FLS) solution. - - This function uses a numerical quadrature to evaluate the one-integral form - of the FLS solution, as proposed by Prieto and Cimmino - [#eqFLSVec-PriCim2021]_. The equivalent FLS solution is given by: - - .. math:: - h_{1\\rightarrow2}(t) &= \\frac{1}{2 H_2 N_{b,2}} - \\int_{\\frac{1}{\\sqrt{4\\alpha t}}}^{\\infty} - \\sum_{G_1} \\sum_{G_2} - e^{-d_{12}^2s^2}(I_{real}(s)+I_{imag}(s))ds - - - I_{real}(s) &= erfint((D_2-D_1+H_2)s) - erfint((D_2-D_1)s) - - &+ erfint((D_2-D_1-H_1)s) - erfint((D_2-D_1+H_2-H_1)s) - - I_{imag}(s) &= erfint((D_2+D_1+H_2)s) - erfint((D_2+D_1)s) - - &+ erfint((D_2+D_1+H_1)s) - erfint((D_2+D_1+H_2+H_1)s) - - - erfint(X) &= \\int_{0}^{X} erf(x) dx - - &= Xerf(X) - \\frac{1}{\\sqrt{\\pi}}(1-e^{-X^2}) - - .. Note:: - The reciprocal thermal response factor - :math:`h_{2\\rightarrow1}(t)` can be conveniently calculated by: - - .. math:: - h_{2\\rightarrow1}(t) = \\frac{H_2 N_{b,2}}{H_1 N_{b,1}} - h_{1\\rightarrow2}(t) - - Parameters - ---------- - time : float or array, shape (K) - Value of time (in seconds) for which the FLS solution is evaluated. - alpha : float - Soil thermal diffusivity (in m2/s). - dis : array - Unique radial distances to evaluate the FLS solution. - wDis : array - Number of instances of each unique radial distances. - H1 : float or array - Lengths of the emitting heat sources. - D1 : float or array - Buried depths of the emitting heat sources. - H2 : float or array - Lengths of the receiving heat sources. - D2 : float or array - Buried depths of the receiving heat sources. - N2 : float or array, - Number of segments represented by the receiving heat sources. - reaSource : bool - True if the real part of the FLS solution is to be included. - Default is True. - imgSource : bool - True if the image part of the FLS solution is to be included. - Default is True. - - Returns - ------- - h : float - Value of the FLS solution. The average (over the length) temperature - drop on the wall of borehole2 due to heat extracted from borehole1 is: - - .. math:: \\Delta T_{b,2} = T_g - \\frac{Q_1}{2\\pi k_s H_2} h - - Notes - ----- - This is a vectorized version of the :func:`finite_line_source` function - using scipy.integrate.quad_vec to speed up calculations. All arrays - (dis, H1, D1, H2, D2) must follow numpy array broadcasting rules. If time - is an array, the integrals for different time values are stacked on the - last axis. - - - References - ---------- - .. [#eqFLSVec-PriCim2021] Prieto, C., & Cimmino, M. - (2021). Thermal interactions in large irregular fields of geothermal - boreholes: the method of equivalent borehole. Journal of Building - Performance Simulation, 14 (4), 446-460. - - """ - # Integrand of the finite line source solution - f = _finite_line_source_equivalent_boreholes_integrand( - dis, wDis, H1, D1, H2, D2, N2, reaSource, imgSource) - - # Evaluate integral - if isinstance(time, (np.floating, float)): - # Lower bound of integration - a = 1.0 / np.sqrt(4.0*alpha*time) - h = 0.5 / (N2*H2) * quad_vec(f, a, np.inf)[0] - else: - # Lower bound of integration - a = 1.0 / np.sqrt(4.0*alpha*time) - # Upper bound of integration - b = np.concatenate(([np.inf], a[:-1])) - h = np.cumsum(np.stack( - [0.5 / (N2*H2) * quad_vec(f, a_i, b_i)[0] - for t, a_i, b_i in zip(time, a, b)], - axis=-1), axis=-1) - return h - - -def finite_line_source_inclined_vectorized( - time, alpha, - rb1, x1, y1, H1, D1, tilt1, orientation1, - x2, y2, H2, D2, tilt2, orientation2, - reaSource=True, imgSource=True, M=11, approximation=False, N=10): - """ - Evaluate the inclined Finite Line Source (FLS) solution. - - This function uses a numerical quadrature to evaluate the inclined FLS - solution, as proposed by Lazzarotto [#incFLSVec-Lazzar2016]_. - The inclined FLS solution is given by: - - .. math:: - h_{1\\rightarrow2}(t) &= \\frac{H_1}{2H_2} - \\int_{\\frac{1}{\\sqrt{4\\alpha t}}}^{\\infty} - \\frac{1}{s} - \\int_{0}^{1} (I_{real}(u, s)+I_{imag}(u, s)) du ds - - - I_{real}(u, s) &= - e^{-((x_1 - x_2)^2 + (y_1 - y_2)^2 + (D_1 - D_2)^2) s^2} - - &\\cdot (erf((u H_1 k_{0,real} + k_{2,real}) s) - - erf((u H_1 k_{0,real} + k_{2,real} - H_2) s)) - - &\\cdot e^{(u^2 H_1^2 (k_{0,real}^2 - 1) - + 2 u H_1 (k_{0,real} k_{2,real} - k_{1,real}) + k_{2,real}^2) s^2} - du ds - - - I_{imag}(u, s) &= - -e^{-((x_1 - x_2)^2 + (y_1 - y_2)^2 + (D_1 + D_2)^2) s^2} - - &\\cdot (erf((u H_1 k_{0,imag} + k_{2,imag}) s) - - erf((u H_1 k_{0,imag} + k_{2,imag} - H_2) s)) - - &\\cdot e^{(u^2 H_1^2 (k_{0,imag}^2 - 1) - + 2 u H_1 (k_{0,imag} k_{2,imag} - k_1) + k_{2,imag}^2) s^2} - du ds - - - k_{0,real} &= - sin(\\beta_1) sin(\\beta_2) cos(\\theta_1 - \\theta_2) - + cos(\\beta_1) cos(\\beta_2) - - - k_{0,imag} &= - sin(\\beta_1) sin(\\beta_2) cos(\\theta_1 - \\theta_2) - - cos(\\beta_1) cos(\\beta_2) - - - k_{1,real} &= sin(\\beta_1) - (cos(\\theta_1) (x_1 - x_2) + sin(\\theta_1) (y_1 - y_2)) - + cos(\\beta_1) (D_1 - D_2) - - - k_{1,imag} &= sin(\\beta_1) - (cos(\\theta_1) (x_1 - x_2) + sin(\\theta_1) (y_1 - y_2)) - + cos(\\beta_1) (D_1 + D_2) - - - k_{2,real} &= sin(\\beta_2) - (cos(\\theta_2) (x_1 - x_2) + sin(\\theta_2) (y_1 - y_2)) - + cos(\\beta_2) (D_1 - D_2) - - - k_{2,imag} &= sin(\\beta_2) - (cos(\\theta_2) (x_1 - x_2) + sin(\\theta_2) (y_1 - y_2)) - - cos(\\beta_2) (D_1 + D_2) - - where :math:`\\beta_1` and :math:`\\beta_2` are the tilt angle of the - boreholes (relative to vertical), and :math:`\\theta_1` and - :math:`\\theta_2` are the orientation of the boreholes (relative to the - x-axis). - - .. Note:: - The reciprocal thermal response factor - :math:`h_{2\\rightarrow1}(t)` can be conveniently calculated by: - - .. math:: - h_{2\\rightarrow1}(t) = \\frac{H_2}{H_1} - h_{1\\rightarrow2}(t) - - Parameters - ---------- - time : float or array, shape (K) - Value of time (in seconds) for which the FLS solution is evaluated. - alpha : float - Soil thermal diffusivity (in m2/s). - rb1 : array - Radii of the emitting heat sources. - x1 : float or array - x-Positions of the emitting heat sources. - y1 : float or array - y-Positions of the emitting heat sources. - H1 : float or array - Lengths of the emitting heat sources. - D1 : float or array - Buried depths of the emitting heat sources. - tilt1 : float or array - Angles (in radians) from vertical of the emitting heat sources. - orientation1 : float or array - Directions (in radians) of the tilt the emitting heat sources. - x2 : array - x-Positions of the receiving heat sources. - y2 : array - y-Positions of the receiving heat sources. - H2 : float or array - Lengths of the receiving heat sources. - D2 : float or array - Buried depths of the receiving heat sources. - tilt2 : float or array - Angles (in radians) from vertical of the receiving heat sources. - orientation2 : float or array - Directions (in radians) of the tilt the receiving heat sources. - reaSource : bool, optional - True if the real part of the FLS solution is to be included. - Default is True. - imgSource : bool, optional - True if the image part of the FLS solution is to be included. - Default is true. - M : int, optional - Number of points for the Gauss-Legendre quadrature rule along the - receiving heat sources. - Default is 21. - approximation : bool, optional - Set to true to use the approximation of the FLS solution of Cimmino - (2021) [#FLSVec-Cimmin2021]_. This approximation does not require - the numerical evaluation of any integral. - Default is False. - N : int, optional - Number of terms in the approximation of the FLS solution. This - parameter is unused if `approximation` is set to False. - Default is 10. Maximum is 25. - - Returns - ------- - f : callable - Integrand of the finite line source solution. Can be vector-valued. - - Notes - ----- - This is a vectorized version of the :func:`finite_line_source` function - using scipy.integrate.quad_vec to speed up calculations. All arrays - (x1, y1, H1, D1, tilt1, orientation1, x2, y2, H2, D2, tilt2, - orientation2) must follow numpy array broadcasting rules. - - References - ---------- - .. [#incFLSVec-Lazzar2016] Lazzarotto, A. (2016). A methodology for the - calculation of response functions for geothermal fields with - arbitrarily oriented boreholes – Part 1, Renewable Energy, 86, - 1380-1393. - - """ - if not approximation: - # Integrand of the inclined finite line source solution - f = _finite_line_source_inclined_integrand( - rb1, x1, y1, H1, D1, tilt1, orientation1, - x2, y2, H2, D2, tilt2, orientation2, - reaSource, imgSource, M) - - # Evaluate integral - if isinstance(time, (np.floating, float)): - # Lower bound of integration - a = 1.0 / np.sqrt(4.0*alpha*time) - h = 0.5 / H2 * quad_vec(f, a, np.inf, epsabs=1e-4, epsrel=1e-6)[0] - else: - # Lower bound of integration - a = 1.0 / np.sqrt(4.0*alpha*time) - # Upper bound of integration - b = np.concatenate(([np.inf], a[:-1])) - h = np.cumsum( - np.stack( - [0.5 / H2 * quad_vec( - f, a_i, b_i, epsabs=1e-4, epsrel=1e-6)[0] - for i, (a_i, b_i) in enumerate(zip(a, b))], - axis=-1), - axis=-1) - else: - h = finite_line_source_inclined_approximation( - time, alpha, rb1, x1, y1, H1, D1, tilt1, orientation1, - x2, y2, H2, D2, tilt2, orientation2, - reaSource=reaSource, imgSource=imgSource, M=M, N=N) - return h - - -def _finite_line_source_integrand(dis, H1, D1, H2, D2, reaSource, imgSource): - """ - Integrand of the finite line source solution. - - Parameters - ---------- - dis : float or array - Radial distances to evaluate the FLS solution. - H1 : float or array - Lengths of the emitting heat sources. - D1 : float or array - Buried depths of the emitting heat sources. - H2 : float or array - Lengths of the receiving heat sources. - D2 : float or array - Buried depths of the receiving heat sources. - reaSource : bool - True if the real part of the FLS solution is to be included. - imgSource : bool - True if the image part of the FLS solution is to be included. - - Returns - ------- - f : callable - Integrand of the finite line source solution. Can be vector-valued. - - Notes - ----- - All arrays (dis, H1, D1, H2, D2) must follow numpy array broadcasting - rules. - - """ - if reaSource and imgSource: - # Full (real + image) FLS solution - p = np.array([1, -1, 1, -1, 1, -1, 1, -1]) - q = np.stack([D2 - D1 + H2, - D2 - D1, - D2 - D1 - H1, - D2 - D1 + H2 - H1, - D2 + D1 + H2, - D2 + D1, - D2 + D1 + H1, - D2 + D1 + H2 + H1], - axis=-1) - f = lambda s: s**-2 * np.exp(-dis**2*s**2) * np.inner(p, erfint(q*s)) - elif reaSource: - # Real FLS solution - p = np.array([1, -1, 1, -1]) - q = np.stack([D2 - D1 + H2, - D2 - D1, - D2 - D1 - H1, - D2 - D1 + H2 - H1], - axis=-1) - f = lambda s: s**-2 * np.exp(-dis**2*s**2) * np.inner(p, erfint(q*s)) - elif imgSource: - # Image FLS solution - p = np.array([1, -1, 1, -1]) - q = np.stack([D2 + D1 + H2, - D2 + D1, - D2 + D1 + H1, - D2 + D1 + H2 + H1], - axis=-1) - f = lambda s: s**-2 * np.exp(-dis**2*s**2) * np.inner(p, erfint(q*s)) - else: - # No heat source - f = lambda s: np.zeros(np.broadcast_shapes( - *[np.shape(arg) for arg in (dis, H1, D1, H2, D2)])) - return f - - -def _finite_line_source_inclined_integrand( - rb1, x1, y1, H1, D1, tilt1, orientation1, x2, y2, H2, D2, tilt2, orientation2, - reaSource, imgSource, M): - """ - Integrand of the inclined Finite Line Source (FLS) solution. - - Parameters - ---------- - rb1 : array - Radii of the emitting heat sources. - x1 : float or array - x-Positions of the emitting heat sources. - y1 : float or array - y-Positions of the emitting heat sources. - H1 : float or array - Lengths of the emitting heat sources. - D1 : float or array - Buried depths of the emitting heat sources. - tilt1 : float or array - Angles (in radians) from vertical of the emitting heat sources. - orientation1 : float or array - Directions (in radians) of the tilt the emitting heat sources. - x2 : array - x-Positions of the receiving heat sources. - y2 : array - y-Positions of the receiving heat sources. - H2 : float or array - Lengths of the receiving heat sources. - D2 : float or array - Buried depths of the receiving heat sources. - tilt2 : float or array - Angles (in radians) from vertical of the receiving heat sources. - orientation2 : float or array - Directions (in radians) of the tilt the receiving heat sources. - reaSource : bool - True if the real part of the FLS solution is to be included. - Default is True. - imgSource : bool - True if the image part of the FLS solution is to be included. - M : int - Number of points for the Gauss-Legendre quadrature rule along the - receiving heat sources. - - Returns - ------- - f : callable - Integrand of the finite line source solution. Can be vector-valued. - - Notes - ----- - All arrays (x1, y1, H1, D1, tilt1, orientation1, x2, y2, H2, D2, tilt2, - orientation2) must follow numpy array broadcasting rules. - - """ - output_shape = np.broadcast_shapes( - *[np.shape(arg) for arg in ( - rb1, x1, y1, H1, D1, tilt1, orientation1, - x2, y2, H2, D2, tilt2, orientation2)]) - # Roots - x, w = roots_legendre(M) - u = (0.5 * x + 0.5).reshape((-1,) + (1,) * len(output_shape)) - w = w / 2 - # Params - sb1 = np.sin(tilt1) - sb2 = np.sin(tilt2) - cb1 = np.cos(tilt1) - cb2 = np.cos(tilt2) - dx = x1 - x2 - dy = y1 - y2 - if reaSource and imgSource: - # Full (real + image) FLS solution - dzRea = D1 - D2 - dzImg = D1 + D2 - rr = np.maximum(dx**2 + dy**2, rb1**2) - kRea_0 = sb1 * sb2 * np.cos(orientation1 - orientation2) + cb1 * cb2 - kImg_0 = sb1 * sb2 * np.cos(orientation1 - orientation2) - cb1 * cb2 - kRea_1 = sb1 * (np.cos(orientation1) * dx + np.sin(orientation1) * dy) + cb1 * dzRea - kImg_1 = sb1 * (np.cos(orientation1) * dx + np.sin(orientation1) * dy) + cb1 * dzImg - kRea_2 = sb2 * (np.cos(orientation2) * dx + np.sin(orientation2) * dy) + cb2 * dzRea - kImg_2 = sb2 * (np.cos(orientation2) * dx + np.sin(orientation2) * dy) - cb2 * dzImg - f = lambda s: \ - ((H1 / s * ( - np.exp(-(rr + dzRea**2) * s**2 + s**2 * (u**2 * H1**2 * (kRea_0**2 - 1) + 2 * u * H1 * (kRea_0 * kRea_2 - kRea_1) + kRea_2**2)) \ - * (erf((u * H1 * kRea_0 + kRea_2) * s) - erf((u * H1 * kRea_0 + kRea_2 - H2) * s)) - - np.exp(-(rr + dzImg**2) * s**2 + s**2 * (u**2 * H1**2 * (kImg_0**2 - 1) + 2 * u * H1 * (kImg_0 * kImg_2 - kImg_1) + kImg_2**2)) \ - * (erf((u * H1 * kImg_0 + kImg_2) * s) - erf((u * H1 * kImg_0 + kImg_2 - H2) * s)))).T @ w).T - elif reaSource: - # Real FLS solution - dzRea = D1 - D2 - rr = np.maximum(dx**2 + dy**2, rb1**2) - kRea_0 = sb1 * sb2 * np.cos(orientation1 - orientation2) + cb1 * cb2 - kRea_1 = sb1 * (np.cos(orientation1) * dx + np.sin(orientation1) * dy) + cb1 * dzRea - kRea_2 = sb2 * (np.cos(orientation2) * dx + np.sin(orientation2) * dy) + cb2 * dzRea - f = lambda s: \ - ((H1 / s * np.exp(-(rr + dzRea**2) * s**2 + s**2 * (u**2 * H1**2 * (kRea_0**2 - 1) + 2 * u * H1 * (kRea_0 * kRea_2 - kRea_1) + kRea_2**2)) \ - * (erf((u * H1 * kRea_0 + kRea_2) * s) - erf((u * H1 * kRea_0 + kRea_2 - H2) * s))).T @ w).T - elif imgSource: - # Image FLS solution - dzImg = D1 + D2 - kImg_0 = sb1 * sb2 * np.cos(orientation1 - orientation2) - cb1 * cb2 - kImg_1 = sb1 * (np.cos(orientation1) * dx + np.sin(orientation1) * dy) + cb1 * dzImg - kImg_2 = sb2 * (np.cos(orientation2) * dx + np.sin(orientation2) * dy) - cb2 * dzImg - f = lambda s: \ - -((H1 / s * np.exp(-(dx**2 + dy**2 + dzImg**2) * s**2 + s**2 * (u**2 * H1**2 * (kImg_0**2 - 1) + 2 * u * H1 * (kImg_0 * kImg_2 - kImg_1) + kImg_2**2)) \ - * (erf((u * H1 * kImg_0 + kImg_2) * s) - erf((u * H1 * kImg_0 + kImg_2 - H2) * s))).T @ w).T - else: - # No heat source - f = lambda s: np.zeros(output_shape) - return f - - -def _finite_line_source_equivalent_boreholes_integrand(dis, wDis, H1, D1, H2, D2, N2, reaSource, imgSource): - """ - Integrand of the finite line source solution. - - Parameters - ---------- - dis : array - Unique radial distances to evaluate the FLS solution. - wDis : array - Number of instances of each unique radial distances. - H1 : float or array - Lengths of the emitting heat sources. - D1 : float or array - Buried depths of the emitting heat sources. - H2 : float or array - Lengths of the receiving heat sources. - D2 : float or array - Buried depths of the receiving heat sources. - N2 : float or array, - Number of segments represented by the receiving heat sources. - reaSource : bool - True if the real part of the FLS solution is to be included. - imgSource : bool - True if the image part of the FLS solution is to be included. - - Returns - ------- - f : callable - Integrand of the finite line source solution. Can be vector-valued. - - Notes - ----- - All arrays (dis, H1, D1, H2, D2) must follow numpy array broadcasting - rules. - - """ - if reaSource and imgSource: - # Full (real + image) FLS solution - p = np.array([1, -1, 1, -1, 1, -1, 1, -1]) - q = np.stack([D2 - D1 + H2, - D2 - D1, - D2 - D1 - H1, - D2 - D1 + H2 - H1, - D2 + D1 + H2, - D2 + D1, - D2 + D1 + H1, - D2 + D1 + H2 + H1], - axis=-1) - f = lambda s: s**-2 * (np.exp(-dis**2*s**2) @ wDis).T * np.inner(p, erfint(q*s)) - elif reaSource: - # Real FLS solution - p = np.array([1, -1, 1, -1]) - q = np.stack([D2 - D1 + H2, - D2 - D1, - D2 - D1 - H1, - D2 - D1 + H2 - H1], - axis=-1) - f = lambda s: s**-2 * (np.exp(-dis**2*s**2) @ wDis).T * np.inner(p, erfint(q*s)) - elif imgSource: - # Image FLS solution - p = np.array([1, -1, 1, -1]) - q = np.stack([D2 + D1 + H2, - D2 + D1, - D2 + D1 + H1, - D2 + D1 + H2 + H1], - axis=-1) - f = lambda s: s**-2 * (np.exp(-dis**2*s**2) @ wDis).T * np.inner(p, erfint(q*s)) - else: - # No heat source - f = lambda s: np.zeros(np.broadcast_shapes( - *[np.shape(arg) for arg in (H1, D1, H2, D2, N2)])) - return f - - -def _finite_line_source_steady_state(dis, H1, D1, H2, D2, reaSource, imgSource): - """ - Steady-state finite line source solution. - - Parameters - ---------- - dis : float or array - Radial distances to evaluate the FLS solution. - H1 : float or array - Lengths of the emitting heat sources. - D1 : float or array - Buried depths of the emitting heat sources. - H2 : float or array - Lengths of the receiving heat sources. - D2 : float or array - Buried depths of the receiving heat sources. - reaSource : bool - True if the real part of the FLS solution is to be included. - imgSource : bool - True if the image part of the FLS solution is to be included. - - Returns - ------- - h : Steady-state finite line source solution. - - Notes - ----- - All arrays (dis, H1, D1, H2, D2) must follow numpy array broadcasting - rules. - - """ - # Steady-state solution - if reaSource and imgSource: - # Full (real + image) FLS solution - p = np.array([1, -1, 1, -1, 1, -1, 1, -1]) - q = np.stack([D2 - D1 + H2, - D2 - D1, - D2 - D1 - H1, - D2 - D1 + H2 - H1, - D2 + D1 + H2, - D2 + D1, - D2 + D1 + H1, - D2 + D1 + H2 + H1], - axis=-1) - dis = np.expand_dims(dis, axis=-1) - qpd = np.sqrt(q**2 + dis**2) - h = 0.5 / H2 * np.inner(p, q * np.log(q + qpd) - qpd) - elif reaSource: - # Real FLS solution - p = np.array([1, -1, 1, -1]) - q = np.stack([D2 - D1 + H2, - D2 - D1, - D2 - D1 - H1, - D2 - D1 + H2 - H1,], - axis=-1) - dis = np.expand_dims(dis, axis=-1) - qpd = np.sqrt(q**2 + dis**2) - h = 0.5 / H2 * np.inner(p, q * np.log(q + qpd) - qpd) - elif imgSource: - # Image FLS solution - p = np.array([1, -1, 1, -1]) - q = np.stack([D2 + D1 + H2, - D2 + D1, - D2 + D1 + H1, - D2 + D1 + H2 + H1], - axis=-1) - dis = np.expand_dims(dis, axis=-1) - qpd = np.sqrt(q**2 + dis**2) - h = 0.5 / H2 * np.inner(p, q * np.log(q + qpd) - qpd) - else: - # No heat source - h = np.zeros(np.broadcast_shapes( - *[np.shape(arg) for arg in (dis, H1, D1, H2, D2)])) - return h - - -def _finite_line_source_inclined_steady_state( - rb1, x1, y1, H1, D1, tilt1, orientation1, x2, y2, H2, D2, tilt2, - orientation2, reaSource, imgSource, M=11): - """ - Steady-state inclined Finite Line Source (FLS) solution. - - Parameters - ---------- - rb1 : array - Radii of the emitting heat sources. - x1 : float or array - x-Positions of the emitting heat sources. - y1 : float or array - y-Positions of the emitting heat sources. - H1 : float or array - Lengths of the emitting heat sources. - D1 : float or array - Buried depths of the emitting heat sources. - tilt1 : float or array - Angles (in radians) from vertical of the emitting heat sources. - orientation1 : float or array - Directions (in radians) of the tilt the emitting heat sources. - x2 : array - x-Positions of the receiving heat sources. - y2 : array - y-Positions of the receiving heat sources. - H2 : float or array - Lengths of the receiving heat sources. - D2 : float or array - Buried depths of the receiving heat sources. - tilt2 : float or array - Angles (in radians) from vertical of the receiving heat sources. - orientation2 : float or array - Directions (in radians) of the tilt the receiving heat sources. - reaSource : bool - True if the real part of the FLS solution is to be included. - Default is True. - imgSource : bool - True if the image part of the FLS solution is to be included. - M : int - Number of points for the Gauss-Legendre quadrature rule along the - receiving heat sources. - Default is 11. - - Returns - ------- - h : Steady-state inclined finite line source solution. - - Notes - ----- - All arrays (dis, H1, D1, H2, D2) must follow numpy array broadcasting - rules. - - """ - output_shape = np.broadcast_shapes( - *[np.shape(arg) for arg in ( - rb1, x1, y1, H1, D1, tilt1, orientation1, - x2, y2, H2, D2, tilt2, orientation2)]) - # Roots - x, w = roots_legendre(M) - u = (0.5 * x + 0.5).reshape((-1,) + (1,) * len(output_shape)) - w = w / 2 - # Params - sb1 = np.sin(tilt1) - sb2 = np.sin(tilt2) - cb1 = np.cos(tilt1) - cb2 = np.cos(tilt2) - dx = x1 - x2 - dy = y1 - y2 - # Steady-state solution - if reaSource and imgSource: - # Full (real + image) FLS solution - dzRea = D1 - D2 - dzImg = D1 + D2 - rr = np.maximum(dx**2 + dy**2, rb1**2) - kRea_0 = sb1 * sb2 * np.cos(orientation1 - orientation2) + cb1 * cb2 - kImg_0 = sb1 * sb2 * np.cos(orientation1 - orientation2) - cb1 * cb2 - kRea_1 = sb1 * (np.cos(orientation1) * dx + np.sin(orientation1) * dy) + cb1 * dzRea - kImg_1 = sb1 * (np.cos(orientation1) * dx + np.sin(orientation1) * dy) + cb1 * dzImg - kRea_2 = sb2 * (np.cos(orientation2) * dx + np.sin(orientation2) * dy) + cb2 * dzRea - kImg_2 = sb2 * (np.cos(orientation2) * dx + np.sin(orientation2) * dy) - cb2 * dzImg - h = 0.5 * H1 / H2 * (( - np.log((2 * H2 * np.sqrt(rr + dzRea**2 + u**2*H1**2 + H2**2 + 2*u*H1*kRea_1 - 2*H2*kRea_2 - 2*u*H1*H2*kRea_0) + 2*H2**2 - 2*H2*kRea_2 - 2*u*H1*H2*kRea_0) / ((2 * H2 * np.sqrt(rr + dzRea**2 + u**2*H1**2 + 2*u*H1*kRea_1) - 2*H2*kRea_2 - 2*u*H1*H2*kRea_0))) - - np.log((2 * H2 * np.sqrt(dx**2 + dy**2 + dzImg**2 + u**2*H1**2 + H2**2 + 2*u*H1*kImg_1 - 2*H2*kImg_2 - 2*u*H1*H2*kImg_0) + 2*H2**2 - 2*H2*kImg_2 - 2*u*H1*H2*kImg_0) / ((2 * H2 * np.sqrt(dx**2 + dy**2 + dzImg**2 + u**2*H1**2 + 2*u*H1*kImg_1) - 2*H2*kImg_2 - 2*u*H1*H2*kImg_0))) - ).T @ w).T - elif reaSource: - # Real FLS solution - dzRea = D1 - D2 - rr = np.maximum(dx**2 + dy**2, rb1**2) - kRea_0 = sb1 * sb2 * np.cos(orientation1 - orientation2) + cb1 * cb2 - kRea_1 = sb1 * (np.cos(orientation1) * dx + np.sin(orientation1) * dy) + cb1 * dzRea - kRea_2 = sb2 * (np.cos(orientation2) * dx + np.sin(orientation2) * dy) + cb2 * dzRea - h = 0.5 * H1 / H2 * (( - np.log((2 * H2 * np.sqrt(rr + dzRea**2 + u**2*H1**2 + H2**2 + 2*u*H1*kRea_1 - 2*H2*kRea_2 - 2*u*H1*H2*kRea_0) + 2*H2**2 - 2*H2*kRea_2 - 2*u*H1*H2*kRea_0) / ((2 * H2 * np.sqrt(rr + dzRea**2 + u**2*H1**2 + 2*u*H1*kRea_1) - 2*H2*kRea_2 - 2*u*H1*H2*kRea_0))) - ).T @ w).T - elif imgSource: - # Image FLS solution - dzImg = D1 + D2 - kImg_0 = sb1 * sb2 * np.cos(orientation1 - orientation2) - cb1 * cb2 - kImg_1 = sb1 * (np.cos(orientation1) * dx + np.sin(orientation1) * dy) + cb1 * dzImg - kImg_2 = sb2 * (np.cos(orientation2) * dx + np.sin(orientation2) * dy) - cb2 * dzImg - h = 0.5 * H1 / H2 * (( - - np.log((2 * H2 * np.sqrt(dx**2 + dy**2 + dzImg**2 + u**2*H1**2 + H2**2 + 2*u*H1*kImg_1 - 2*H2*kImg_2 - 2*u*H1*H2*kImg_0) + 2*H2**2 - 2*H2*kImg_2 - 2*u*H1*H2*kImg_0) / ((2 * H2 * np.sqrt(dx**2 + dy**2 + dzImg**2 + u**2*H1**2 + 2*u*H1*kImg_1) - 2*H2*kImg_2 - 2*u*H1*H2*kImg_0))) - ).T @ w).T - else: - # No heat source - h = np.zeros(output_shape) - return h diff --git a/pygfunction/heat_transfer/__init__.py b/pygfunction/heat_transfer/__init__.py new file mode 100644 index 00000000..bcefffce --- /dev/null +++ b/pygfunction/heat_transfer/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +from .finite_line_source import finite_line_source, finite_line_source_equivalent_boreholes_vectorized +from .finite_line_source_vertical import finite_line_source_vertical +from .finite_line_source_inclined import finite_line_source_inclined diff --git a/pygfunction/heat_transfer/finite_line_source.py b/pygfunction/heat_transfer/finite_line_source.py new file mode 100644 index 00000000..2b9f38d9 --- /dev/null +++ b/pygfunction/heat_transfer/finite_line_source.py @@ -0,0 +1,537 @@ +# -*- coding: utf-8 -*- +from typing import Union, List, Tuple + +import numpy as np +import numpy.typing as npt +from scipy.integrate import quad_vec + +from ..borefield import Borefield +from ..boreholes import Borehole +from .finite_line_source_vertical import finite_line_source_vertical +from .finite_line_source_inclined import finite_line_source_inclined +from ..utilities import erfint + + +def finite_line_source( + time: npt.ArrayLike, + alpha: float, + borefield_j: Union[Borehole, Borefield, List[Borehole]], + borefield_i: Union[Borehole, Borefield, List[Borehole]], + outer: bool = True, + reaSource: bool = True, + imgSource: bool = True, + approximation: bool = False, + M: int = 11, + N: int = 10 + ) -> np.ndarray: + """ + Evaluate the Finite Line Source (FLS) solution. + + This function uses a numerical quadrature to evaluate the one-integral form + of the FLS solution. For vertical boreholes, the FLS solution was proposed + by Claesson and Javed [#FLS-ClaJav2011]_ and extended to boreholes with + different vertical positions by Cimmino and Bernier [#FLS-CimBer2014]_. + The FLS solution is given by: + + .. math:: + h_{ij}(t) &= \\frac{1}{2H_i} + \\int_{\\frac{1}{\\sqrt{4\\alpha t}}}^{\\infty} + e^{-d_{ij}^2s^2}(I_{real}(s)+I_{imag}(s))ds + + + d_{ij} &= \\sqrt{(x_i - x_j)^2 + (y_i - y_j)^2} + + + I_{real}(s) &= erfint((D_i-D_j+H_i)s) - erfint((D_i-D_j)s) + + &+ erfint((D_i-D_j-H_j)s) - erfint((D_i-D_j+H_i-H_j)s) + + I_{imag}(s) &= erfint((D_i+D_j+H_i)s) - erfint((D_i+D_j)s) + + &+ erfint((D_i+D_j+H_j)s) - erfint((D_i+D_j+H_i+H_j)s) + + + erfint(X) &= \\int_{0}^{X} erf(x) dx + + &= Xerf(X) - \\frac{1}{\\sqrt{\\pi}}(1-e^{-X^2}) + + For inclined boreholes, the FLS solution was proposed by Lazzarotto + [#FLS-Lazzar2016]_ and Lazzarotto and Björk [#FLS-LazBjo2016]_. + The FLS solution is given by: + + .. math:: + h_{1\\rightarrow2}(t) &= \\frac{H_j}{2H_i} + \\int_{\\frac{1}{\\sqrt{4\\alpha t}}}^{\\infty} + \\frac{1}{s} + \\int_{0}^{1} (I_{real}(u, s)+I_{imag}(u, s)) du ds + + + I_{real}(u, s) &= + e^{-((x_i - x_j)^2 + (y_i - y_j)^2 + (D_i - D_j)^2) s^2} + + &\\cdot (erf((u H_j k_{0,real} + k_{2,real}) s) + - erf((u H_j k_{0,real} + k_{2,real} - H_i) s)) + + &\\cdot e^{(u^2 H_j^2 (k_{0,real}^2 - 1) + + 2 u H_j (k_{0,real} k_{2,real} - k_{1,real}) + k_{2,real}^2) s^2} + du ds + + + I_{imag}(u, s) &= + -e^{-((x_i - x_j)^2 + (y_i - y_j)^2 + (D_i + D_j)^2) s^2} + + &\\cdot (erf((u H_j k_{0,imag} + k_{2,imag}) s) + - erf((u H_j k_{0,imag} + k_{2,imag} - H_i) s)) + + &\\cdot e^{(u^2 H_j^2 (k_{0,imag}^2 - 1) + + 2 u H_j (k_{0,imag} k_{2,imag} - k_1) + k_{2,imag}^2) s^2} + du ds + + + k_{0,real} &= + sin(\\beta_j) sin(\\beta_i) cos(\\theta_j - \\theta_i) + + cos(\\beta_j) cos(\\beta_i) + + + k_{0,imag} &= + sin(\\beta_j) sin(\\beta_i) cos(\\theta_j - \\theta_i) + - cos(\\beta_j) cos(\\beta_i) + + + k_{1,real} &= sin(\\beta_j) + (cos(\\theta_j) (x_j - x_i) + sin(\\theta_j) (y_j - y_i)) + + cos(\\beta_j) (D_j - D_i) + + + k_{1,imag} &= sin(\\beta_j) + (cos(\\theta_j) (x_j - x_i) + sin(\\theta_j) (y_j - y_i)) + + cos(\\beta_j) (D_i + D_j) + + + k_{2,real} &= sin(\\beta_i) + (cos(\\theta_i) (x_j - x_i) + sin(\\theta_i) (y_j - y_i)) + + cos(\\beta_i) (D_j - D_i) + + + k_{2,imag} &= sin(\\beta_i) + (cos(\\theta_i) (x_j - x_i) + sin(\\theta_i) (y_j - y_i)) + - cos(\\beta_i) (D_i + D_j) + + where :math:`\\beta_j` and :math:`\\beta_i` are the tilt angle of the + boreholes (relative to vertical), and :math:`\\theta_j` and + :math:`\\theta_i` are the orientation of the boreholes (relative to the + x-axis). + + .. Note:: + The reciprocal thermal response factor + :math:`h_{ji}(t)` can be conveniently calculated by: + + .. math:: + h_{ji}(t) = \\frac{H_i}{H_j} + h_{ij}(t) + + Parameters + ---------- + time : float or (nTimes,) array + Value of time (in seconds) for which the FLS solution is evaluated. + alpha : float + Soil thermal diffusivity (in m2/s). + borefield_j : Borehole or Borefield object + Borehole or Borefield object of the boreholes extracting heat. + borefield_i : Borehole or Borefield object + Borehole or Borefield object object for which the FLS is evaluated. + reaSource : bool + True if the real part of the FLS solution is to be included. + Default is True. + imgSource : bool, optional + True if the image part of the FLS solution is to be included. + Default is True. + outer : bool, optional + True if the finite line source is to be evaluated for all boreholes_j + onto all boreholes_i to return a (nBoreholes_i, nBoreholes_j, nTime,) + array. If false, the finite line source is evaluated pairwise between + boreholes_j and boreholes_i. The numbers of boreholes should be the + same (i.e. nBoreholes_j == nBoreholes_i) and a (nBoreholes, nTime,) + array is returned. + Default is True. + approximation : bool, optional + Set to true to use the approximation of the FLS solution of Cimmino + (2021) [#FLS-Cimmin2021]_. This approximation does not require + the numerical evaluation of any integral. + Default is False. + M : int, optional + Number of Gauss-Legendre sample points for the quadrature over + :math:`u`. This is only used for inclined boreholes. + Default is 11. + N : int, optional + Number of terms in the approximation of the FLS solution. This + parameter is unused if `approximation` is set to False. + Default is 10. Maximum is 25. + + Returns + ------- + h : float or array, shape (nBoreholes_i, nBoreholes_j, nTime,), (nBoreholes, nTime,) or (nTime,) + Value of the FLS solution. The average (over the length) temperature + drop on the wall of borehole_i due to heat extracted from borehole_j + is: + + .. math:: \\Delta T_{b,i} = T_g - \\frac{Q_j}{2\\pi k_s H_j} h + + Notes + ----- + The function returns a float if time is a float and both of borehole_j and + borehole_i are Borehole objects. If time is a float and any of borehole_j + and borehole_i are Borefield objects, the function returns an array. If + time is an array and both of borehole_j and borehole_i are Borehole + objects, the function returns an 1d array, shape (nTime,). If time is an + array and any of borehole_j and borehole_i are are Borefield objects, the + function returns an array. + + Examples + -------- + >>> b1 = gt.boreholes.Borehole(H=150., D=4., r_b=0.075, x=0., y=0.) + >>> b2 = gt.boreholes.Borehole(H=150., D=4., r_b=0.075, x=5., y=0.) + >>> h = gt.heat_transfer.finite_line_source(4*168*3600., 1.0e-6, b1, b2) + h = 0.0110473635393 + >>> h = gt.heat_transfer.finite_line_source( + 4*168*3600., 1.0e-6, b1, b2, approximation=True, N=10) + h = 0.0110474667731 + >>> b3 = gt.boreholes.Borehole( + H=150., D=4., r_b=0.075, x=5., y=0., tilt=3.1415/15, orientation=0.) + >>> h = gt.heat_transfer.finite_line_source( + 4*168*3600., 1.0e-6, b1, b3, M=21) + h = 0.0002017450051 + + References + ---------- + .. [#FLS-ClaJav2011] Claesson, J., & Javed, S. (2011). An analytical + method to calculate borehole fluid temperatures for time-scales from + minutes to decades. ASHRAE Transactions, 117(2), 279-288. + .. [#FLS-CimBer2014] Cimmino, M., & Bernier, M. (2014). A + semi-analytical method to generate g-functions for geothermal bore + fields. International Journal of Heat and Mass Transfer, 70, 641-650. + .. [#FLS-Cimmin2021] Cimmino, M. (2021). An approximation of the + finite line source solution to model thermal interactions between + geothermal boreholes. International Communications in Heat and Mass + Transfer, 127, 105496. + .. [#FLS-Lazzar2016] Lazzarotto, A. (2016). A methodology for the + calculation of response functions for geothermal fields with + arbitrarily oriented boreholes – Part 1, Renewable Energy, 86, + 1380-1393. + .. [#FLS-LazBjo2016] Lazzarotto, A., & Björk, F. (2016). A methodology for + the calculation of response functions for geothermal fields with + arbitrarily oriented boreholes – Part 2, Renewable Energy, 86, + 1353-1361. + + """ + if np.any(borefield_j.is_tilted) or np.any(borefield_i.is_tilted): + # Inclined boreholes + h = finite_line_source_inclined( + time, alpha, borefield_j, borefield_i, reaSource=reaSource, + imgSource=imgSource, outer=outer, M=M, N=N) + else: + # Vertical boreholes + h = finite_line_source_vertical( + time, alpha, borefield_j, borefield_i, + reaSource=reaSource, imgSource=imgSource, outer=outer, N=N) + return h + + +def finite_line_source_equivalent_boreholes_vectorized( + time, alpha, dis, wDis, H1, D1, H2, D2, N2, reaSource=True, imgSource=True): + """ + Evaluate the equivalent Finite Line Source (FLS) solution. + + This function uses a numerical quadrature to evaluate the one-integral form + of the FLS solution, as proposed by Prieto and Cimmino + [#eqFLSVec-PriCim2021]_. The equivalent FLS solution is given by: + + .. math:: + h_{1\\rightarrow2}(t) &= \\frac{1}{2 H_2 N_{b,2}} + \\int_{\\frac{1}{\\sqrt{4\\alpha t}}}^{\\infty} + \\sum_{G_1} \\sum_{G_2} + e^{-d_{12}^2s^2}(I_{real}(s)+I_{imag}(s))ds + + + I_{real}(s) &= erfint((D_2-D_1+H_2)s) - erfint((D_2-D_1)s) + + &+ erfint((D_2-D_1-H_1)s) - erfint((D_2-D_1+H_2-H_1)s) + + I_{imag}(s) &= erfint((D_2+D_1+H_2)s) - erfint((D_2+D_1)s) + + &+ erfint((D_2+D_1+H_1)s) - erfint((D_2+D_1+H_2+H_1)s) + + + erfint(X) &= \\int_{0}^{X} erf(x) dx + + &= Xerf(X) - \\frac{1}{\\sqrt{\\pi}}(1-e^{-X^2}) + + .. Note:: + The reciprocal thermal response factor + :math:`h_{2\\rightarrow1}(t)` can be conveniently calculated by: + + .. math:: + h_{2\\rightarrow1}(t) = \\frac{H_2 N_{b,2}}{H_1 N_{b,1}} + h_{1\\rightarrow2}(t) + + Parameters + ---------- + time : float or array, shape (K) + Value of time (in seconds) for which the FLS solution is evaluated. + alpha : float + Soil thermal diffusivity (in m2/s). + dis : array + Unique radial distances to evaluate the FLS solution. + wDis : array + Number of instances of each unique radial distances. + H1 : float or array + Lengths of the emitting heat sources. + D1 : float or array + Buried depths of the emitting heat sources. + H2 : float or array + Lengths of the receiving heat sources. + D2 : float or array + Buried depths of the receiving heat sources. + N2 : float or array, + Number of segments represented by the receiving heat sources. + reaSource : bool + True if the real part of the FLS solution is to be included. + Default is True. + imgSource : bool + True if the image part of the FLS solution is to be included. + Default is True. + + Returns + ------- + h : float + Value of the FLS solution. The average (over the length) temperature + drop on the wall of borehole2 due to heat extracted from borehole1 is: + + .. math:: \\Delta T_{b,2} = T_g - \\frac{Q_1}{2\\pi k_s H_2} h + + Notes + ----- + This is a vectorized version of the :func:`finite_line_source` function + using scipy.integrate.quad_vec to speed up calculations. All arrays + (dis, H1, D1, H2, D2) must follow numpy array broadcasting rules. If time + is an array, the integrals for different time values are stacked on the + last axis. + + + References + ---------- + .. [#eqFLSVec-PriCim2021] Prieto, C., & Cimmino, M. + (2021). Thermal interactions in large irregular fields of geothermal + boreholes: the method of equivalent borehole. Journal of Building + Performance Simulation, 14 (4), 446-460. + + """ + # Integrand of the finite line source solution + f = _finite_line_source_equivalent_boreholes_integrand( + dis, wDis, H1, D1, H2, D2, N2, reaSource, imgSource) + + # Evaluate integral + if isinstance(time, (np.floating, float)): + # Lower bound of integration + a = 1.0 / np.sqrt(4.0*alpha*time) + h = 0.5 / (N2*H2) * quad_vec(f, a, np.inf)[0] + else: + # Lower bound of integration + a = 1.0 / np.sqrt(4.0*alpha*time) + # Upper bound of integration + b = np.concatenate(([np.inf], a[:-1])) + h = np.cumsum(np.stack( + [0.5 / (N2*H2) * quad_vec(f, a_i, b_i)[0] + for t, a_i, b_i in zip(time, a, b)], + axis=-1), axis=-1) + return h + + +def _finite_line_source_equivalent_boreholes_integrand(dis, wDis, H1, D1, H2, D2, N2, reaSource, imgSource): + """ + Integrand of the finite line source solution. + + Parameters + ---------- + dis : array + Unique radial distances to evaluate the FLS solution. + wDis : array + Number of instances of each unique radial distances. + H1 : float or array + Lengths of the emitting heat sources. + D1 : float or array + Buried depths of the emitting heat sources. + H2 : float or array + Lengths of the receiving heat sources. + D2 : float or array + Buried depths of the receiving heat sources. + N2 : float or array, + Number of segments represented by the receiving heat sources. + reaSource : bool + True if the real part of the FLS solution is to be included. + imgSource : bool + True if the image part of the FLS solution is to be included. + + Returns + ------- + f : callable + Integrand of the finite line source solution. Can be vector-valued. + + """ + if reaSource and imgSource: + # Full (real + image) FLS solution + p = np.array([1, -1, 1, -1, 1, -1, 1, -1]) + q = np.stack([D2 - D1 + H2, + D2 - D1, + D2 - D1 - H1, + D2 - D1 + H2 - H1, + D2 + D1 + H2, + D2 + D1, + D2 + D1 + H1, + D2 + D1 + H2 + H1], + axis=-1) + f = lambda s: s**-2 * (np.exp(-dis**2*s**2) @ wDis).T * np.inner(p, erfint(q*s)) + elif reaSource: + # Real FLS solution + p = np.array([1, -1, 1, -1]) + q = np.stack([D2 - D1 + H2, + D2 - D1, + D2 - D1 - H1, + D2 - D1 + H2 - H1], + axis=-1) + f = lambda s: s**-2 * (np.exp(-dis**2*s**2) @ wDis).T * np.inner(p, erfint(q*s)) + elif imgSource: + # Image FLS solution + p = np.array([1, -1, 1, -1]) + q = np.stack([D2 + D1 + H2, + D2 + D1, + D2 + D1 + H1, + D2 + D1 + H2 + H1], + axis=-1) + f = lambda s: s**-2 * (np.exp(-dis**2*s**2) @ wDis).T * np.inner(p, erfint(q*s)) + else: + # No heat source + f = lambda s: np.zeros(np.broadcast_shapes( + *[np.shape(arg) for arg in (H1, D1, H2, D2, N2)])) + return f + + +def _finite_line_source_coefficients( + borefield_j: Borefield, + borefield_i: Borefield, + reaSource: bool = True, + imgSource: bool = True, + outer: bool = True + ) -> Tuple[np.ndarray, np.ndarray]: + """ + Coefficients for the finite line source solutions of vertical boreholes. + + Parameters + ---------- + borefield_j : Borehole or Borefield object + Borehole or Borefield object of the boreholes extracting heat. + borefield_i : Borehole or Borefield object + Borehole or Borefield object object for which the FLS is evaluated. + reaSource : bool + True if the real part of the FLS solution is to be included. + Default is True. + imgSource : bool, optional + True if the image part of the FLS solution is to be included. + Default is True. + outer : bool, optional + True if the finite line source is to be evaluated for all boreholes_j + onto all boreholes_i to return a (nBoreholes_i, nBoreholes_j, nTime,) + array. If false, the finite line source is evaluated pairwise between + boreholes_j and boreholes_i. The numbers of boreholes should be the + same (i.e. nBoreholes_j == nBoreholes_i) and a (nBoreholes, nTime,) + array is returned. + Default is True. + + Returns + ------- + p : array + Weights for the superposition of terms in the integrand of the finite + line source solution. + q : array + Arguments of the integral of the error function in the integrand of the + finite line source solution. + + """ + H_j = borefield_j.H + D_j = borefield_j.D + H_i = borefield_i.H + D_i = borefield_i.D + + if reaSource and imgSource: + # Full (real + image) FLS solution + p = np.array([1, -1, 1, -1, 1, -1, 1, -1]) + if outer: + q = np.stack( + [np.subtract.outer(D_i + H_i, D_j), + np.subtract.outer(D_i, D_j), + np.subtract.outer(D_i, D_j + H_j), + np.subtract.outer(D_i + H_i, D_j + H_j), + np.add.outer(D_i + H_i, D_j), + np.add.outer(D_i, D_j), + np.add.outer(D_i, D_j + H_j), + np.add.outer(D_i + H_i, D_j + H_j), + ], + axis=0) + else: + q = np.stack( + [D_i + H_i - D_j, + D_i - D_j, + D_i - (D_j + H_j), + D_i + H_i - (D_j + H_j), + D_i + H_i + D_j, + D_i + D_j, + D_i + D_j + H_j, + D_i + H_i + D_j + H_j, + ], + axis=0) + elif reaSource: + # Real FLS solution + p = np.array([1, -1, 1, -1]) + if outer: + q = np.stack( + [np.subtract.outer(D_i + H_i, D_j), + np.subtract.outer(D_i, D_j), + np.subtract.outer(D_i, D_j + H_j), + np.subtract.outer(D_i + H_i, D_j + H_j), + ], + axis=0) + else: + q = np.stack( + [D_i + H_i - D_j, + D_i - D_j, + D_i - (D_j + H_j), + D_i + H_i - (D_j + H_j), + ], + axis=0) + elif imgSource: + # Image FLS solution + p = np.array([1, -1, 1, -1]) + if outer: + q = np.stack( + [np.add.outer(D_i + H_i, D_j), + np.add.outer(D_i, D_j), + np.add.outer(D_i, D_j + H_j), + np.add.outer(D_i + H_i, D_j + H_j), + ], + axis=0) + else: + q = np.stack( + [D_i + H_i + D_j, + D_i + D_j, + D_i + D_j + H_j, + D_i + H_i + D_j + H_j, + ], + axis=0) + else: + # No heat source + p = np.zeros(0) + if outer: + q = np.zeros((0, len(borefield_i), len(borefield_j))) + else: + q = np.zeros((0, len(borefield_i))) + return p, q diff --git a/pygfunction/heat_transfer/finite_line_source_inclined.py b/pygfunction/heat_transfer/finite_line_source_inclined.py new file mode 100644 index 00000000..67b9c4c5 --- /dev/null +++ b/pygfunction/heat_transfer/finite_line_source_inclined.py @@ -0,0 +1,695 @@ +# -*- coding: utf-8 -*- +from collections.abc import Callable +from typing import Union, List, Tuple + +import numpy as np +import numpy.typing as npt +from scipy.integrate import quad_vec +from scipy.special import erf, roots_legendre + +from ..borefield import Borefield +from ..boreholes import Borehole +from ..utilities import exp1, _erf_coeffs + + +def finite_line_source_inclined( + time: npt.ArrayLike, + alpha: float, + borefield_j: Union[Borehole, Borefield, List[Borehole]], + borefield_i: Union[Borehole, Borefield, List[Borehole]], + outer: bool = True, + reaSource: bool = True, + imgSource: bool = True, + approximation: bool = False, + M: int = 11, + N: int = 10 + ) -> np.ndarray: + """ + Evaluate the Finite Line Source (FLS) solution for inclined boreholes. + + This function uses a numerical quadrature to evaluate the one-integral form + of the FLS solution. For inclined boreholes, the FLS solution was proposed + by Lazzarotto [#FLSi-Lazzar2016]_ and Lazzarotto and Björk + [#FLSi-LazBjo2016]_. The FLS solution is given by: + + .. math:: + h_{1\\rightarrow2}(t) &= \\frac{H_j}{2H_i} + \\int_{\\frac{1}{\\sqrt{4\\alpha t}}}^{\\infty} + \\frac{1}{s} + \\int_{0}^{1} (I_{real}(u, s)+I_{imag}(u, s)) du ds + + + I_{real}(u, s) &= + e^{-((x_i - x_j)^2 + (y_i - y_j)^2 + (D_i - D_j)^2) s^2} + + &\\cdot (erf((u H_j k_{0,real} + k_{2,real}) s) + - erf((u H_j k_{0,real} + k_{2,real} - H_i) s)) + + &\\cdot e^{(u^2 H_j^2 (k_{0,real}^2 - 1) + + 2 u H_j (k_{0,real} k_{2,real} - k_{1,real}) + k_{2,real}^2) s^2} + du ds + + + I_{imag}(u, s) &= + -e^{-((x_i - x_j)^2 + (y_i - y_j)^2 + (D_i + D_j)^2) s^2} + + &\\cdot (erf((u H_j k_{0,imag} + k_{2,imag}) s) + - erf((u H_j k_{0,imag} + k_{2,imag} - H_i) s)) + + &\\cdot e^{(u^2 H_j^2 (k_{0,imag}^2 - 1) + + 2 u H_j (k_{0,imag} k_{2,imag} - k_1) + k_{2,imag}^2) s^2} + du ds + + + k_{0,real} &= + sin(\\beta_j) sin(\\beta_i) cos(\\theta_j - \\theta_i) + + cos(\\beta_j) cos(\\beta_i) + + + k_{0,imag} &= + sin(\\beta_j) sin(\\beta_i) cos(\\theta_j - \\theta_i) + - cos(\\beta_j) cos(\\beta_i) + + + k_{1,real} &= sin(\\beta_j) + (cos(\\theta_j) (x_j - x_i) + sin(\\theta_j) (y_j - y_i)) + + cos(\\beta_j) (D_j - D_i) + + + k_{1,imag} &= sin(\\beta_j) + (cos(\\theta_j) (x_j - x_i) + sin(\\theta_j) (y_j - y_i)) + + cos(\\beta_j) (D_i + D_j) + + + k_{2,real} &= sin(\\beta_i) + (cos(\\theta_i) (x_j - x_i) + sin(\\theta_i) (y_j - y_i)) + + cos(\\beta_i) (D_j - D_i) + + + k_{2,imag} &= sin(\\beta_i) + (cos(\\theta_i) (x_j - x_i) + sin(\\theta_i) (y_j - y_i)) + - cos(\\beta_i) (D_i + D_j) + + where :math:`\\beta_j` and :math:`\\beta_i` are the tilt angle of the + boreholes (relative to vertical), and :math:`\\theta_j` and + :math:`\\theta_i` are the orientation of the boreholes (relative to the + x-axis). + + .. Note:: + The reciprocal thermal response factor + :math:`h_{ji}(t)` can be conveniently calculated by: + + .. math:: + h_{ji}(t) = \\frac{H_i}{H_j} + h_{ij}(t) + + Parameters + ---------- + time : float or (nTimes,) array + Value of time (in seconds) for which the FLS solution is evaluated. + alpha : float + Soil thermal diffusivity (in m2/s). + borefield_j : Borehole or Borefield object + Borehole or Borefield object of the boreholes extracting heat. + borefield_i : Borehole or Borefield object + Borehole or Borefield object object for which the FLS is evaluated. + reaSource : bool + True if the real part of the FLS solution is to be included. + Default is True. + imgSource : bool, optional + True if the image part of the FLS solution is to be included. + Default is True. + outer : bool, optional + True if the finite line source is to be evaluated for all boreholes_j + onto all boreholes_i to return a (nBoreholes_i, nBoreholes_j, nTime,) + array. If false, the finite line source is evaluated pairwise between + boreholes_j and boreholes_i. The numbers of boreholes should be the + same (i.e. nBoreholes_j == nBoreholes_i) and a (nBoreholes, nTime,) + array is returned. + Default is True. + approximation : bool, optional + Set to true to use the approximation of the FLS solution of Cimmino + (2021) [#FLSi-Cimmin2021]_. This approximation does not require + the numerical evaluation of any integral. + Default is False. + M : int, optional + Number of Gauss-Legendre sample points for the quadrature over + :math:`u`. This is only used for inclined boreholes. + Default is 11. + N : int, optional + Number of terms in the approximation of the FLS solution. This + parameter is unused if `approximation` is set to False. + Default is 10. Maximum is 25. + + Returns + ------- + h : float or array, shape (nBoreholes_i, nBoreholes_j, nTime,), (nBoreholes, nTime,) or (nTime,) + Value of the FLS solution. The average (over the length) temperature + drop on the wall of borehole_i due to heat extracted from borehole_j + is: + + .. math:: \\Delta T_{b,i} = T_g - \\frac{Q_j}{2\\pi k_s H_j} h + + Notes + ----- + The function returns a float if time is a float and both of borehole_j and + borehole_i are Borehole objects. If time is a float and any of borehole_j + and borehole_i are Borefield objects, the function returns an array. If + time is an array and both of borehole_j and borehole_i are Borehole + objects, the function returns an 1d array, shape (nTime,). If time is an + array and any of borehole_j and borehole_i are are Borefield objects, the + function returns an array. + + Examples + -------- + >>> b1 = gt.boreholes.Borehole(H=150., D=4., r_b=0.075, x=0., y=0.) + >>> b2 = gt.boreholes.Borehole(H=150., D=4., r_b=0.075, x=5., y=0.) + >>> h = gt.heat_transfer.finite_line_source(4*168*3600., 1.0e-6, b1, b2) + h = 0.0110473635393 + >>> h = gt.heat_transfer.finite_line_source( + 4*168*3600., 1.0e-6, b1, b2, approximation=True, N=10) + h = 0.0110474667731 + >>> b3 = gt.boreholes.Borehole( + H=150., D=4., r_b=0.075, x=5., y=0., tilt=3.1415/15, orientation=0.) + >>> h = gt.heat_transfer.finite_line_source( + 4*168*3600., 1.0e-6, b1, b3, M=21) + h = 0.0002017450051 + + References + ---------- + .. [#FLSi-Cimmin2021] Cimmino, M. (2021). An approximation of the + finite line source solution to model thermal interactions between + geothermal boreholes. International Communications in Heat and Mass + Transfer, 127, 105496. + .. [#FLSi-Lazzar2016] Lazzarotto, A. (2016). A methodology for the + calculation of response functions for geothermal fields with + arbitrarily oriented boreholes – Part 1, Renewable Energy, 86, + 1380-1393. + .. [#FLSi-LazBjo2016] Lazzarotto, A., & Björk, F. (2016). A methodology for + the calculation of response functions for geothermal fields with + arbitrarily oriented boreholes – Part 2, Renewable Energy, 86, + 1353-1361. + + """ + # Check if both bore fields are Borehole objects + single_pair = ( + isinstance(borefield_j, Borehole) + and isinstance(borefield_i, Borehole) + ) + # Convert bore fields to Borefield objects + if isinstance(borefield_j, Borehole) or isinstance(borefield_j, list): + borefield_j = Borefield.from_boreholes(borefield_j) + if isinstance(borefield_i, Borehole) or isinstance(borefield_i, list): + borefield_i = Borefield.from_boreholes(borefield_i) + + # Convert time to array if it is a list + if isinstance(time, list): + time = np.array(time) + + # Evaluate the finite line source solution + if time is np.inf: + # Steady-state finite line source solution + h = _finite_line_source_inclined_steady_state( + borefield_j, borefield_i, reaSource=reaSource, + imgSource=imgSource, outer=outer, M=M) + elif approximation: + # Approximation of the finite line source solution + h = _finite_line_source_inclined_approximation( + time, alpha, borefield_j, borefield_i, reaSource=reaSource, + imgSource=imgSource, outer=outer, M=M, N=N) + else: + # Integrand of the finite line source solution + f = _finite_line_source_inclined_integrand( + borefield_j, borefield_i, reaSource=reaSource, + imgSource=imgSource, outer=outer, M=M) + # Evaluate integral + if isinstance(time, (np.floating, float)): + # Lower bound of integration + a = 1. / np.sqrt(4 * alpha * time) + h = (0.5 / borefield_i.H * quad_vec(f, a, np.inf, epsabs=1e-4, epsrel=1e-6)[0].T).T + else: + # Lower bound of integration + a = 1.0 / np.sqrt(4.0 * alpha * time) + # Upper bound of integration + b = np.concatenate(([np.inf], a[:-1])) + h = np.cumsum(np.stack( + [(0.5 / borefield_i.H * quad_vec(f, a_i, b_i, epsabs=1e-4, epsrel=1e-6)[0].T).T + for t, a_i, b_i in zip(time, a, b)], + axis=-1), axis=-1) + + # Return a 1d array if only Borehole objects were provided + if single_pair: + if outer: + h = h[0, 0, ...] + else: + h = h[0, ...] + # Return a float if time is also a float + if isinstance(time, float): + h = float(h) + return h + + +def _finite_line_source_inclined_approximation( + time: npt.ArrayLike, + alpha: float, + borefield_j: Borefield, + borefield_i: Borefield, + reaSource: bool = True, + imgSource: bool = True, + outer: bool = True, + M: int = 11, + N: int = 10 + ) -> np.ndarray: + """ + Evaluate the inclined Finite Line Source (FLS) solution using the + approximation method of Cimmino (2021) [#IncFLSApprox-Cimmin2021]_. + + Parameters + ---------- + time : float or (nTimes,) array + Value of time (in seconds) for which the FLS solution is evaluated. + alpha : float + Soil thermal diffusivity (in m2/s). + borefield_j : Borehole or Borefield object + Borehole or Borefield object of the boreholes extracting heat. + borefield_i : Borehole or Borefield object + Borehole or Borefield object object for which the FLS is evaluated. + reaSource : bool + True if the real part of the FLS solution is to be included. + Default is True. + imgSource : bool, optional + True if the image part of the FLS solution is to be included. + Default is True. + outer : bool, optional + True if the finite line source is to be evaluated for all boreholes_j + onto all boreholes_i to return a (nBoreholes_i, nBoreholes_j, nTime,) + array. If false, the finite line source is evaluated pairwise between + boreholes_j and boreholes_i. The numbers of boreholes should be the + same (i.e. nBoreholes_j == nBoreholes_i) and a (nBoreholes, nTime,) + array is returned. + Default is True. + M : int, optional + Number of Gauss-Legendre sample points for the quadrature over + :math:`u`. This is only used for inclined boreholes. + Default is 11. + N : int, optional + Number of terms in the approximation of the FLS solution. This + parameter is unused if `approximation` is set to False. + Default is 10. Maximum is 25. + + Returns + ------- + h : float or array, shape (nBoreholes_i, nBoreholes_j, nTime,), (nBoreholes, nTime,) or (nTime,) + Value of the FLS solution. The average (over the length) temperature + drop on the wall of borehole_i due to heat extracted from borehole_j + is: + + .. math:: \\Delta T_{b,i} = T_g - \\frac{Q_j}{2\\pi k_s H_j} h + + References + ---------- + .. [#IncFLSApprox-Cimmin2021] Cimmino, M. (2021). An approximation of the + finite line source solution to model thermal interactions between + geothermal boreholes. International Communications in Heat and Mass + Transfer, 127, 105496. + + """ + # Evaluate coefficients of the FLS solution + p, q, k = _finite_line_source_inclined_coefficients( + borefield_j, borefield_i, reaSource=reaSource, imgSource=imgSource, + outer=outer) + + # Coefficients of the approximation of the error function + a, b = _erf_coeffs(N) + + # Roots for Gauss-Legendre quadrature + x, w = roots_legendre(M) + u = 0.5 * x + 0.5 + w = w / 2 + + # Extract lengths and reshape if outer == True + H_j = borefield_j.H + H_i = borefield_i.H + if outer: + H_i = H_i[..., np.newaxis] + H_ratio = np.divide.outer(borefield_j.H, borefield_i.H).T + else: + H_ratio = borefield_j.H / borefield_i.H + + # Additional coefficients for the approximation of the FLS solution + s = 1. / (4 * alpha * time) + d = [ + k[2] + np.multiply.outer(u, H_j * k[0]), + k[2] - H_i + np.multiply.outer(u, H_j * k[0]), + ] + c = np.maximum( + (q - d[0]**2 - k[1]**2) + + (k[1] + np.multiply.outer(u, np.ones_like(k[1]) * H_j))**2, + borefield_j.r_b**2) + + # Approximation of the FLS solution + h = 0.25 * H_ratio * (( + np.sign( + np.multiply.outer(d[0], np.ones_like(s)) + ) * exp1( + np.multiply.outer(c + np.multiply.outer(b, d[0]**2), s)) + - np.sign( + np.multiply.outer(d[1], np.ones_like(s)) + ) * exp1(np.multiply.outer(c + np.multiply.outer(b, d[1]**2), s)) + ).T @ a @ w @ p).T + return h + + +def _finite_line_source_inclined_integrand( + borefield_j: Borefield, + borefield_i: Borefield, + reaSource: bool = True, + imgSource: bool = True, + outer: bool = True, + M: int = 11 + ) -> Callable: + """ + Integrand of the inclined Finite Line Source (FLS) solution. + + Parameters + ---------- + borefield_j : Borehole or Borefield object + Borehole or Borefield object of the boreholes extracting heat. + borefield_i : Borehole or Borefield object + Borehole or Borefield object object for which the FLS is evaluated. + reaSource : bool + True if the real part of the FLS solution is to be included. + Default is True. + imgSource : bool, optional + True if the image part of the FLS solution is to be included. + Default is True. + outer : bool, optional + True if the finite line source is to be evaluated for all boreholes_j + onto all boreholes_i to return a (nBoreholes_i, nBoreholes_j, nTime,) + array. If false, the finite line source is evaluated pairwise between + boreholes_j and boreholes_i. The numbers of boreholes should be the + same (i.e. nBoreholes_j == nBoreholes_i) and a (nBoreholes, nTime,) + array is returned. + Default is True. + M : int, optional + Number of Gauss-Legendre sample points for the quadrature over + :math:`u`. This is only used for inclined boreholes. + Default is 11. + + Returns + ------- + f : callable + Integrand of the finite line source solution. Can be vector-valued. + + """ + # Evaluate coefficients of the FLS solution + p, q, k = _finite_line_source_inclined_coefficients( + borefield_j, borefield_i, reaSource=reaSource, imgSource=imgSource, + outer=outer) + + # Roots for Gauss-Legendre quadrature + x, w = roots_legendre(M) + u = 0.5 * x + 0.5 + w = w / 2 + + # Extract lengths and reshape if outer == True + H_j = borefield_j.H + H_i = borefield_i.H + if outer: + H_i = H_i[..., np.newaxis] + + # Integrand of the inclined finite line source solution + f = lambda s: \ + H_j / s * ((( + np.exp( + s**2 * ( + -q + + np.multiply.outer(u**2, H_j**2 * (k[0]**2 - 1)) + + 2 * np.multiply.outer(u, H_j * (k[0] * k[2] - k[1])) + + k[2]**2 + ) + ) \ + * ( + erf( + s * ( + np.multiply.outer(u, H_j * k[0]) + + k[2] + ) + ) + - erf( + s * ( + np.multiply.outer(u, H_j * k[0]) + + k[2] + - H_i + ) + ) + ) + ).T @ w) @ p).T + return f + + +def _finite_line_source_inclined_steady_state( + borefield_j: Borefield, + borefield_i: Borefield, + reaSource: bool = True, + imgSource: bool = True, + outer: bool = True, + M: int = 11 + ) -> np.ndarray: + """ + Steady-state inclined Finite Line Source (FLS) solution. + + Parameters + ---------- + borefield_j : Borehole or Borefield object + Borehole or Borefield object of the boreholes extracting heat. + borefield_i : Borehole or Borefield object + Borehole or Borefield object object for which the FLS is evaluated. + reaSource : bool + True if the real part of the FLS solution is to be included. + Default is True. + imgSource : bool, optional + True if the image part of the FLS solution is to be included. + Default is True. + outer : bool, optional + True if the finite line source is to be evaluated for all boreholes_j + onto all boreholes_i to return a (nBoreholes_i, nBoreholes_j, nTime,) + array. If false, the finite line source is evaluated pairwise between + boreholes_j and boreholes_i. The numbers of boreholes should be the + same (i.e. nBoreholes_j == nBoreholes_i) and a (nBoreholes, nTime,) + array is returned. + Default is True. + M : int, optional + Number of Gauss-Legendre sample points for the quadrature over + :math:`u`. This is only used for inclined boreholes. + Default is 11. + + Returns + ------- + h : float or array, shape (nBoreholes_i, nBoreholes_j) or (nBoreholes,) + Value of the steady-state FLS solution. The average (over the length) + temperature drop on the wall of borehole_i due to heat extracted from + borehole_j is: + + .. math:: \\Delta T_{b,i} = T_g - \\frac{Q_j}{2\\pi k_s H_j} h + + """ + # Evaluate coefficients of the FLS solution + p, q, k = _finite_line_source_inclined_coefficients( + borefield_j, borefield_i, reaSource=reaSource, imgSource=imgSource, + outer=outer) + + # Roots for Gauss-Legendre quadrature + x, w = roots_legendre(M) + u = 0.5 * x + 0.5 + w = w / 2 + + # Extract lengths and reshape if outer == True + H_j = borefield_j.H + H_i = borefield_i.H + if outer: + H_i = H_i[..., np.newaxis] + H_ratio = np.divide.outer(borefield_j.H, borefield_i.H).T + else: + H_ratio = borefield_j.H / borefield_i.H + + # Steady-state inclined finite line source solution + h = 0.5 * H_ratio * ( + np.log( + ( + 2 * H_i * np.sqrt( + q + + np.multiply.outer(u**2, np.ones_like(k[0]) * H_j**2) + + H_i**2 + + 2 * np.multiply.outer(u, H_j * k[1]) + - 2 * H_i * k[2] + - 2 * H_i * np.multiply.outer(u, H_j * k[0]) + ) + + 2 * H_i**2 + - 2 * H_i * k[2] + - 2 * H_i * np.multiply.outer(u, H_j * k[0]) + ) / ( + 2 * H_i * np.sqrt( + q + + np.multiply.outer(u**2, np.ones_like(k[0]) * H_j**2) + + 2 * np.multiply.outer(u, H_j * k[1]) + ) + - 2 * H_i * k[2] + - 2 * H_i * np.multiply.outer(u, H_j * k[0]) + ) + ).T @ w @ p).T + return h + + +def _finite_line_source_inclined_coefficients( + borefield_j: Borefield, + borefield_i: Borefield, + reaSource: bool = True, + imgSource: bool = True, + outer: bool = True + ) -> Tuple[np.ndarray, np.ndarray]: + """ + Coefficients for the finite line source solutions of inclined boreholes. + + Parameters + ---------- + borefield_j : Borehole or Borefield object + Borehole or Borefield object of the boreholes extracting heat. + borefield_i : Borehole or Borefield object + Borehole or Borefield object object for which the FLS is evaluated. + reaSource : bool + True if the real part of the FLS solution is to be included. + Default is True. + imgSource : bool, optional + True if the image part of the FLS solution is to be included. + Default is True. + outer : bool, optional + True if the finite line source is to be evaluated for all boreholes_j + onto all boreholes_i to return a (nBoreholes_i, nBoreholes_j, nTime,) + array. If false, the finite line source is evaluated pairwise between + boreholes_j and boreholes_i. The numbers of boreholes should be the + same (i.e. nBoreholes_j == nBoreholes_i) and a (nBoreholes, nTime,) + array is returned. + Default is True. + + Returns + ------- + p : array + Weights for the superposition of terms in the integrand of the finite + line source solution. + q : array + Squared distance between top edges of line sources. + k : array + Terms used in arguments of exponential and error functions in the + integrand of the inclined finite line source solution. + + """ + # Sines and cosines of tilt (b: beta) and orientation (t: theta) + sb_j = np.sin(borefield_j.tilt) + sb_i = np.sin(borefield_i.tilt) + cb_j = np.cos(borefield_j.tilt) + cb_i = np.cos(borefield_i.tilt) + st_j = np.sin(borefield_j.orientation) + st_i = np.sin(borefield_i.orientation) + ct_j = np.cos(borefield_j.orientation) + ct_i = np.cos(borefield_i.orientation) + # Horizontal distances between boreholes + dis_ij = borefield_j.distance(borefield_i, outer=outer) + if outer: + dx = np.subtract.outer(borefield_j.x, borefield_i.x).T + dy = np.subtract.outer(borefield_j.y, borefield_i.y).T + ct_ij = np.cos( + np.subtract.outer( + borefield_j.orientation, + borefield_i.orientation) + ).T + else: + dx = borefield_j.x - borefield_i.x + dy = borefield_j.y - borefield_i.y + ct_ij = np.cos(borefield_j.orientation - borefield_i.orientation) + + if reaSource and imgSource: + # Full (real + image) FLS solution + p = np.array([1, -1]) + if outer: + dzRea = np.subtract.outer(borefield_j.D, borefield_i.D).T + dzImg = np.add.outer(borefield_i.D, borefield_j.D) + k = [ + np.stack( + [np.multiply.outer(sb_i, sb_j) * ct_ij + np.multiply.outer(cb_i, cb_j), + np.multiply.outer(sb_i, sb_j) * ct_ij - np.multiply.outer(cb_i, cb_j)], + axis=0), + np.stack( + [sb_j * (ct_j * dx + st_j * dy) + cb_j * dzRea, + sb_j * (ct_j * dx + st_j * dy) + cb_j * dzImg], + axis=0), + np.stack( + [(sb_i * (ct_i * dx.T + st_i * dy.T) + cb_i * dzRea.T).T, + (sb_i * (ct_i * dx.T + st_i * dy.T) - cb_i * dzImg.T).T], + axis=0), + ] + else: + dzRea = borefield_j.D - borefield_i.D + dzImg = borefield_j.D + borefield_i.D + k = [ + np.stack( + [sb_j * sb_i * ct_ij + cb_j * cb_i, + sb_j * sb_i * ct_ij - cb_j * cb_i], + axis=0), + np.stack( + [sb_j * (ct_j * dx + st_j * dy) + cb_j * dzRea, + sb_j * (ct_j * dx + st_j * dy) + cb_j * dzImg], + axis=0), + np.stack( + [sb_i * (ct_i * dx + st_i * dy) + cb_i * dzRea, + sb_i * (ct_i * dx + st_i * dy) - cb_i * dzImg], + axis=0), + ] + q = dis_ij**2 + np.stack( + [dzRea, dzImg], + axis=0)**2 + elif reaSource: + # Real FLS solution + p = np.array([1]) + if outer: + dzRea = np.subtract.outer(borefield_j.D, borefield_i.D).T + k = [ + (np.multiply.outer(sb_i, sb_j) * ct_ij + np.multiply.outer(cb_i, cb_j))[np.newaxis, ...], + (sb_j * (ct_j * dx + st_j * dy) + cb_j * dzRea)[np.newaxis, ...], + (sb_i * (ct_i * dx.T + st_i * dy.T) + cb_i * dzRea.T).T[np.newaxis, ...], + ] + else: + dzRea = borefield_j.D - borefield_i.D + k = [ + (sb_j * sb_i * ct_ij + cb_j * cb_i)[np.newaxis, ...], + (sb_j * (ct_j * dx + st_j * dy) + cb_j * dzRea)[np.newaxis, ...], + (sb_i * (ct_i * dx + st_i * dy) + cb_i * dzRea)[np.newaxis, ...], + ] + q = (dis_ij**2 + dzRea**2)[np.newaxis, ...] + elif imgSource: + # Image FLS solution + p = np.array([-1]) + if outer: + dzImg = np.add.outer(borefield_i.D, borefield_j.D) + k = [ + (np.multiply.outer(sb_i, sb_j) * ct_ij - np.multiply.outer(cb_i, cb_j))[np.newaxis, ...], + (sb_j * (ct_j * dx + st_j * dy) + cb_j * dzImg)[np.newaxis, ...], + (sb_i * (ct_i * dx.T + st_i * dy.T) - cb_i * dzImg.T).T[np.newaxis, ...], + ] + else: + dzImg = borefield_j.D + borefield_i.D + k = [ + (sb_j * sb_i * ct_ij - cb_j * cb_i)[np.newaxis, ...], + (sb_j * (ct_j * dx + st_j * dy) + cb_j * dzImg)[np.newaxis, ...], + (sb_i * (ct_i * dx + st_i * dy) - cb_i * dzImg)[np.newaxis, ...], + ] + q = (dis_ij**2 + dzImg**2)[np.newaxis, ...] + else: + # No heat source + p = np.zeros(0) + if outer: + q = np.zeros((0, len(borefield_i), len(borefield_j))) + else: + q = np.zeros((0, len(borefield_i))) + k = [q, q, q] + return p, q, k diff --git a/pygfunction/heat_transfer/finite_line_source_vertical.py b/pygfunction/heat_transfer/finite_line_source_vertical.py new file mode 100644 index 00000000..d27e6a77 --- /dev/null +++ b/pygfunction/heat_transfer/finite_line_source_vertical.py @@ -0,0 +1,579 @@ +# -*- coding: utf-8 -*- +from collections.abc import Callable +from typing import Union, List, Tuple + +import numpy as np +import numpy.typing as npt +from scipy.integrate import quad_vec +from scipy.special import erfc + +from ..borefield import Borefield +from ..boreholes import Borehole +from ..utilities import erfint, exp1, _erf_coeffs + + +def finite_line_source_vertical( + time: npt.ArrayLike, + alpha: float, + borefield_j: Union[Borehole, Borefield, List[Borehole]], + borefield_i: Union[Borehole, Borefield, List[Borehole]], + distances: Union[None, npt.ArrayLike] = None, + outer: bool = True, + reaSource: bool = True, + imgSource: bool = True, + approximation: bool = False, + N: int = 10 + ) -> np.ndarray: + """ + Evaluate the Finite Line Source (FLS) solution for vertical boreholes. + + This function uses a numerical quadrature to evaluate the one-integral form + of the FLS solution. For vertical boreholes, the FLS solution was proposed + by Claesson and Javed [#FLSv-ClaJav2011]_ and extended to boreholes with + different vertical positions by Cimmino and Bernier [#FLSv-CimBer2014]_. + The FLS solution is given by: + + .. math:: + h_{ij}(t) &= \\frac{1}{2H_i} + \\int_{\\frac{1}{\\sqrt{4\\alpha t}}}^{\\infty} + e^{-d_{ij}^2s^2}(I_{real}(s)+I_{imag}(s))ds + + + d_{ij} &= \\sqrt{(x_i - x_j)^2 + (y_i - y_j)^2} + + + I_{real}(s) &= erfint((D_i-D_j+H_i)s) - erfint((D_i-D_j)s) + + &+ erfint((D_i-D_j-H_j)s) - erfint((D_i-D_j+H_i-H_j)s) + + I_{imag}(s) &= erfint((D_i+D_j+H_i)s) - erfint((D_i+D_j)s) + + &+ erfint((D_i+D_j+H_j)s) - erfint((D_i+D_j+H_i+H_j)s) + + + erfint(X) &= \\int_{0}^{X} erf(x) dx + + &= Xerf(X) - \\frac{1}{\\sqrt{\\pi}}(1-e^{-X^2}) + + .. Note:: + The reciprocal thermal response factor + :math:`h_{ji}(t)` can be conveniently calculated by: + + .. math:: + h_{ji}(t) = \\frac{H_i}{H_j} + h_{ij}(t) + + Parameters + ---------- + time : float or (nTime,) array + Value of time (in seconds) for which the FLS solution is evaluated. + alpha : float + Soil thermal diffusivity (in m2/s). + borefield_j : Borehole or Borefield object + Borehole or Borefield object of the boreholes extracting heat. + borefield_i : Borehole or Borefield object + Borehole or Borefield object object for which the FLS is evaluated. + distances : float or (nDistances,) array, optional + If None, distances are evaluated from distances between boreholes in + borefield_j and borefield_i. If not None, distances between boreholes + in boreholes_j and boreholes_i are overwritten and an array of shape + (nBoreholes_i, nBoreholes_j, nDistances, nTime,) (if outer == True) + or (nBoreholes, nDistances, nTime,) (if outer == False) is returned. + Default is None. + reaSource : bool + True if the real part of the FLS solution is to be included. + Default is True. + imgSource : bool, optional + True if the image part of the FLS solution is to be included. + Default is True. + outer : bool, optional + True if the finite line source is to be evaluated for all boreholes_j + onto all boreholes_i to return a (nBoreholes_i, nBoreholes_j, nTime,) + array. If false, the finite line source is evaluated pairwise between + boreholes_j and boreholes_i. The numbers of boreholes should be the + same (i.e. nBoreholes_j == nBoreholes_i) and a (nBoreholes, nTime,) + array is returned. + Default is True. + approximation : bool, optional + Set to true to use the approximation of the FLS solution of Cimmino + (2021) [#FLSv-Cimmin2021]_. This approximation does not require + the numerical evaluation of any integral. + Default is False. + N : int, optional + Number of terms in the approximation of the FLS solution. This + parameter is unused if `approximation` is set to False. + Default is 10. Maximum is 25. + + Returns + ------- + h : float or array, shape (nBoreholes_i, nBoreholes_j, nTime,), (nBoreholes, nTime,) or (nTime,) + Value of the FLS solution. The average (over the length) temperature + drop on the wall of borehole_i due to heat extracted from borehole_j + is: + + .. math:: \\Delta T_{b,i} = T_g - \\frac{Q_j}{2\\pi k_s H_j} h + + Notes + ----- + The function returns a float if time is a float and both of borehole_j and + borehole_i are Borehole objects. If time is a float and any of borehole_j + and borehole_i are Borefield objects, the function returns an array. If + time is an array and both of borehole_j and borehole_i are Borehole + objects, the function returns an 1d array, shape (nTime,). If time is an + array and any of borehole_j and borehole_i are are Borefield objects, the + function returns an array. + + Examples + -------- + >>> b1 = gt.boreholes.Borehole(H=150., D=4., r_b=0.075, x=0., y=0.) + >>> b2 = gt.boreholes.Borehole(H=150., D=4., r_b=0.075, x=5., y=0.) + >>> h = gt.heat_transfer.finite_line_source(4*168*3600., 1.0e-6, b1, b2) + h = 0.0110473635393 + >>> h = gt.heat_transfer.finite_line_source( + 4*168*3600., 1.0e-6, b1, b2, approximation=True, N=10) + h = 0.0110474667731 + >>> b3 = gt.boreholes.Borehole( + H=150., D=4., r_b=0.075, x=5., y=0., tilt=3.1415/15, orientation=0.) + >>> h = gt.heat_transfer.finite_line_source( + 4*168*3600., 1.0e-6, b1, b3, M=21) + h = 0.0002017450051 + + References + ---------- + .. [#FLSv-ClaJav2011] Claesson, J., & Javed, S. (2011). An analytical + method to calculate borehole fluid temperatures for time-scales from + minutes to decades. ASHRAE Transactions, 117(2), 279-288. + .. [#FLSv-CimBer2014] Cimmino, M., & Bernier, M. (2014). A + semi-analytical method to generate g-functions for geothermal bore + fields. International Journal of Heat and Mass Transfer, 70, 641-650. + .. [#FLSv-Cimmin2021] Cimmino, M. (2021). An approximation of the + finite line source solution to model thermal interactions between + geothermal boreholes. International Communications in Heat and Mass + Transfer, 127, 105496. + + """ + # Check if both bore fields are Borehole objects + single_pair = ( + isinstance(borefield_j, Borehole) + and isinstance(borefield_i, Borehole) + ) + # Convert bore fields to Borefield objects + if isinstance(borefield_j, Borehole) or isinstance(borefield_j, list): + borefield_j = Borefield.from_boreholes(borefield_j) + if isinstance(borefield_i, Borehole) or isinstance(borefield_i, list): + borefield_i = Borefield.from_boreholes(borefield_i) + + # Convert time to array if it is a list + if isinstance(time, list): + time = np.array(time) + + # Evaluate the finite line source solution + if time is np.inf: + # Steady-state finite line source solution + h = _finite_line_source_steady_state( + borefield_j, borefield_i, distances=distances, outer=outer, + reaSource=reaSource, imgSource=imgSource) + elif approximation: + # Approximation of the finite line source solution + h = _finite_line_source_approximation( + time, alpha, borefield_j, borefield_i, distances=distances, + outer=outer, reaSource=reaSource, imgSource=imgSource, N=N) + else: + # Integrand of the finite line source solution + f = _finite_line_source_integrand( + borefield_j, borefield_i, distances=distances, outer=outer, + reaSource=reaSource, imgSource=imgSource) + # Evaluate integral + if isinstance(time, (np.floating, float)): + # Lower bound of integration + a = 1. / np.sqrt(4 * alpha * time) + h = (0.5 / borefield_i.H * quad_vec(f, a, np.inf, epsabs=1e-4, epsrel=1e-6)[0].T).T + else: + # Lower bound of integration + a = 1.0 / np.sqrt(4.0 * alpha * time) + # Upper bound of integration + b = np.concatenate(([np.inf], a[:-1])) + h = np.cumsum(np.stack( + [(0.5 / borefield_i.H * quad_vec(f, a_i, b_i, epsabs=1e-4, epsrel=1e-6)[0].T).T + for t, a_i, b_i in zip(time, a, b)], + axis=-1), axis=-1) + + # Return a 1d array if only Borehole objects were provided + if single_pair: + if outer: + h = h[0, 0, ...] + else: + h = h[0, ...] + # Return a float if time is also a float + if isinstance(time, float): + h = float(h) + return h + + +def _finite_line_source_approximation( + time: npt.ArrayLike, + alpha: float, + borefield_j: Borefield, + borefield_i: Borefield, + distances: Union[None, npt.ArrayLike] = None, + reaSource: bool = True, + imgSource: bool = True, + outer: bool = True, + N: int = 10 + ) -> np.ndarray: + """ + Evaluate the Finite Line Source (FLS) solution using the approximation + of Cimmino (2021) [#FLSApprox-Cimmin2021]_. + + Parameters + ---------- + time : float or (nTimes,) array + Value of time (in seconds) for which the FLS solution is evaluated. + alpha : float + Soil thermal diffusivity (in m2/s). + borefield_j : Borehole or Borefield object + Borehole or Borefield object of the boreholes extracting heat. + borefield_i : Borehole or Borefield object + Borehole or Borefield object object for which the FLS is evaluated. + distances : float or (nDistances,) array, optional + If None, distances are evaluated from distances between boreholes in + borefield_j and borefield_i. If not None, distances between boreholes + in boreholes_j and boreholes_i are overwritten and an array of shape + (nBoreholes_i, nBoreholes_j, nDistances, nTime,) (if outer == True) + or (nBoreholes, nDistances, nTime,) (if outer == False) is returned. + reaSource : bool + True if the real part of the FLS solution is to be included. + Default is True. + imgSource : bool, optional + True if the image part of the FLS solution is to be included. + Default is True. + outer : bool, optional + True if the finite line source is to be evaluated for all boreholes_j + onto all boreholes_i to return a (nBoreholes_i, nBoreholes_j, nTime,) + array. If false, the finite line source is evaluated pairwise between + boreholes_j and boreholes_i. The numbers of boreholes should be the + same (i.e. nBoreholes_j == nBoreholes_i) and a (nBoreholes, nTime,) + array is returned. + Default is True. + N : int, optional + Number of terms in the approximation of the FLS solution. This + parameter is unused if `approximation` is set to False. + Default is 10. Maximum is 25. + + Returns + ------- + h : float or array, shape (nBoreholes_i, nBoreholes_j, nTime,), (nBoreholes, nTime,) or (nTime,) + Value of the FLS solution. The average (over the length) temperature + drop on the wall of borehole_i due to heat extracted from borehole_j + is: + + .. math:: \\Delta T_{b,i} = T_g - \\frac{Q_j}{2\\pi k_s H_j} h + + References + ---------- + .. [#FLSApprox-Cimmin2021] Cimmino, M. (2021). An approximation of the + finite line source solution to model thermal interactions between + geothermal boreholes. International Communications in Heat and Mass + Transfer, 127, 105496. + + """ + # Evaluate coefficients of the FLS solution + p, q = _finite_line_source_coefficients( + borefield_j, borefield_i, reaSource=reaSource, imgSource=imgSource, + outer=outer) + # The approximation of the error function is only valid for positive + # arguments and f (= x * erf(x)) is an even function + q = np.abs(q) + q2 = q**2 + + # Coefficients of the approximation of the error function + a, b = _erf_coeffs(N) + + # Distances + if distances is None: + dis = borefield_j.distance(borefield_i, outer=outer) + dis2 = dis**2 + sqrt_dis2_plus_q2 = np.sqrt(dis2 + q2) + dis2_plus_b_q2 = dis2 + np.multiply.outer(b, q2) + else: + dis = distances + if isinstance(dis, list): + dis = np.atleast_1d(dis) + dis2 = dis**2 + sqrt_dis2_plus_q2 = np.sqrt(np.add.outer(q2, dis2)) + dis2_plus_b_q2 = np.add.outer( + np.multiply.outer(b, q2), + dis2) + + # Temporal terms + four_alpha_time = 4 * alpha * time + sqrt_four_alpha_time = np.sqrt(four_alpha_time) + + # Term G1 of Cimmino (2021) + G1 = 0.5 * (q.T * ( + exp1( + np.divide.outer( + dis2_plus_b_q2, + four_alpha_time + ) + ).T @ a) @ p).T / sqrt_four_alpha_time + + # Term G3 of Cimmino (2021) + x3 = np.divide.outer(sqrt_dis2_plus_q2, sqrt_four_alpha_time) + G3 = ((np.exp(-x3**2) / np.sqrt(np.pi) - x3 * erfc(x3)).T @ p).T + + # Approximation of the FLS solution + h = 0.5 * ((G1 + G3).T / borefield_i.H).T * sqrt_four_alpha_time + return h + + +def _finite_line_source_integrand( + borefield_j: Borefield, + borefield_i: Borefield, + distances: Union[None, npt.ArrayLike] = None, + reaSource: bool = True, + imgSource: bool = True, + outer: bool = True + ) -> Callable: + """ + Integrand of the finite line source solution. + + Parameters + ---------- + borefield_j : Borehole or Borefield object + Borehole or Borefield object of the boreholes extracting heat. + borefield_i : Borehole or Borefield object + Borehole or Borefield object object for which the FLS is evaluated. + distances : float or (nDistances,) array, optional + If None, distances are evaluated from distances between boreholes in + borefield_j and borefield_i. If not None, distances between boreholes + in boreholes_j and boreholes_i are overwritten and an array of shape + (nBoreholes_i, nBoreholes_j, nDistances, nTime,) (if outer == True) + or (nBoreholes, nDistances, nTime,) (if outer == False) is returned. + reaSource : bool + True if the real part of the FLS solution is to be included. + Default is True. + imgSource : bool, optional + True if the image part of the FLS solution is to be included. + Default is True. + outer : bool, optional + True if the finite line source is to be evaluated for all boreholes_j + onto all boreholes_i to return a (nBoreholes_i, nBoreholes_j, nTime,) + array. If false, the finite line source is evaluated pairwise between + boreholes_j and boreholes_i. The numbers of boreholes should be the + same (i.e. nBoreholes_j == nBoreholes_i) and a (nBoreholes, nTime,) + array is returned. + Default is True. + + Returns + ------- + f : callable + Integrand of the finite line source solution. Can be vector-valued. + + """ + # Evaluate coefficients of the FLS solution + p, q = _finite_line_source_coefficients( + borefield_j, borefield_i, reaSource=reaSource, imgSource=imgSource, + outer=outer) + # Integrand of the finite line source solution + if distances is None: + dis = borefield_j.distance(borefield_i, outer=outer) + f = lambda s: s**-2 * np.exp(-dis**2 * s**2) * (erfint(q * s).T @ p).T + else: + dis = distances + if isinstance(dis, list): + dis = np.atleast_1d(dis) + f = lambda s: s**-2 * np.multiply.outer((erfint(q * s).T @ p).T, np.exp(-dis**2 * s**2)) + return f + + +def _finite_line_source_steady_state( + borefield_j: Borefield, + borefield_i: Borefield, + distances: Union[None, npt.ArrayLike] = None, + reaSource: bool = True, + imgSource: bool = True, + outer: bool = True + ) -> np.ndarray: + """ + Steady-state finite line source solution. + + Parameters + ---------- + borefield_j : Borehole or Borefield object + Borehole or Borefield object of the boreholes extracting heat. + borefield_i : Borehole or Borefield object + Borehole or Borefield object object for which the FLS is evaluated. + distances : float or (nDistances,) array, optional + If None, distances are evaluated from distances between boreholes in + borefield_j and borefield_i. If not None, distances between boreholes + in boreholes_j and boreholes_i are overwritten and an array of shape + (nBoreholes_i, nBoreholes_j, nDistances, nTime,) (if outer == True) + or (nBoreholes, nDistances, nTime,) (if outer == False) is returned. + reaSource : bool + True if the real part of the FLS solution is to be included. + Default is True. + imgSource : bool, optional + True if the image part of the FLS solution is to be included. + Default is True. + outer : bool, optional + True if the finite line source is to be evaluated for all boreholes_j + onto all boreholes_i to return a (nBoreholes_i, nBoreholes_j, nTime,) + array. If false, the finite line source is evaluated pairwise between + boreholes_j and boreholes_i. The numbers of boreholes should be the + same (i.e. nBoreholes_j == nBoreholes_i) and a (nBoreholes, nTime,) + array is returned. + Default is True. + + Returns + ------- + h : float or array, shape (nBoreholes_i, nBoreholes_j) or (nBoreholes,) + Value of the steady-state FLS solution. The average (over the length) + temperature drop on the wall of borehole_i due to heat extracted from + borehole_j is: + + .. math:: \\Delta T_{b,i} = T_g - \\frac{Q_j}{2\\pi k_s H_j} h + + """ + # Evaluate coefficients of the FLS solution + p, q = _finite_line_source_coefficients( + borefield_j, borefield_i, reaSource=reaSource, imgSource=imgSource, + outer=outer) + + # Extract lengths and reshape if outer == True + H_i = borefield_i.H + + # Steady-state finite line source solution + if distances is None: + dis = borefield_j.distance(borefield_i, outer=outer) + q2_plus_dis2 = np.sqrt(q**2 + dis**2) + else: + dis = distances + if isinstance(dis, list): + dis = np.atleast_1d(dis) + q2_plus_dis2 = np.sqrt(np.add.outer(q**2, dis**2)) + if isinstance(dis, np.ndarray): + q = q[..., np.newaxis] + h = 0.5 * (((q * np.log(q + q2_plus_dis2) - q2_plus_dis2).T @ p) / H_i).T + return h + + +def _finite_line_source_coefficients( + borefield_j: Borefield, + borefield_i: Borefield, + reaSource: bool = True, + imgSource: bool = True, + outer: bool = True + ) -> Tuple[np.ndarray, np.ndarray]: + """ + Coefficients for the finite line source solutions of vertical boreholes. + + Parameters + ---------- + borefield_j : Borehole or Borefield object + Borehole or Borefield object of the boreholes extracting heat. + borefield_i : Borehole or Borefield object + Borehole or Borefield object object for which the FLS is evaluated. + reaSource : bool + True if the real part of the FLS solution is to be included. + Default is True. + imgSource : bool, optional + True if the image part of the FLS solution is to be included. + Default is True. + outer : bool, optional + True if the finite line source is to be evaluated for all boreholes_j + onto all boreholes_i to return a (nBoreholes_i, nBoreholes_j, nTime,) + array. If false, the finite line source is evaluated pairwise between + boreholes_j and boreholes_i. The numbers of boreholes should be the + same (i.e. nBoreholes_j == nBoreholes_i) and a (nBoreholes, nTime,) + array is returned. + Default is True. + + Returns + ------- + p : array + Weights for the superposition of terms in the integrand of the finite + line source solution. + q : array + Arguments of the integral of the error function in the integrand of the + finite line source solution. + + """ + H_j = borefield_j.H + D_j = borefield_j.D + H_i = borefield_i.H + D_i = borefield_i.D + + if reaSource and imgSource: + # Full (real + image) FLS solution + p = np.array([1, -1, 1, -1, 1, -1, 1, -1]) + if outer: + q = np.stack( + [np.subtract.outer(D_i + H_i, D_j), + np.subtract.outer(D_i, D_j), + np.subtract.outer(D_i, D_j + H_j), + np.subtract.outer(D_i + H_i, D_j + H_j), + np.add.outer(D_i + H_i, D_j), + np.add.outer(D_i, D_j), + np.add.outer(D_i, D_j + H_j), + np.add.outer(D_i + H_i, D_j + H_j), + ], + axis=0) + else: + q = np.stack( + [D_i + H_i - D_j, + D_i - D_j, + D_i - (D_j + H_j), + D_i + H_i - (D_j + H_j), + D_i + H_i + D_j, + D_i + D_j, + D_i + D_j + H_j, + D_i + H_i + D_j + H_j, + ], + axis=0) + elif reaSource: + # Real FLS solution + p = np.array([1, -1, 1, -1]) + if outer: + q = np.stack( + [np.subtract.outer(D_i + H_i, D_j), + np.subtract.outer(D_i, D_j), + np.subtract.outer(D_i, D_j + H_j), + np.subtract.outer(D_i + H_i, D_j + H_j), + ], + axis=0) + else: + q = np.stack( + [D_i + H_i - D_j, + D_i - D_j, + D_i - (D_j + H_j), + D_i + H_i - (D_j + H_j), + ], + axis=0) + elif imgSource: + # Image FLS solution + p = np.array([1, -1, 1, -1]) + if outer: + q = np.stack( + [np.add.outer(D_i + H_i, D_j), + np.add.outer(D_i, D_j), + np.add.outer(D_i, D_j + H_j), + np.add.outer(D_i + H_i, D_j + H_j), + ], + axis=0) + else: + q = np.stack( + [D_i + H_i + D_j, + D_i + D_j, + D_i + D_j + H_j, + D_i + H_i + D_j + H_j, + ], + axis=0) + else: + # No heat source + p = np.zeros(0) + if outer: + q = np.zeros((0, len(borefield_i), len(borefield_j))) + else: + q = np.zeros((0, len(borefield_i))) + return p, q diff --git a/pygfunction/networks.py b/pygfunction/networks.py index 9fa5884c..8bce3671 100644 --- a/pygfunction/networks.py +++ b/pygfunction/networks.py @@ -1132,6 +1132,9 @@ def _format_inputs(self, m_flow_network, cp_f, nSegments, segment_ratios): # Format segment ratios if segment_ratios is None: self._segment_ratios = [None] * self.nBoreholes + elif callable(segment_ratios): + self._segment_ratios = [ + segment_ratios(nSegments_i) for nSegments_i in self.nSegments] elif isinstance(segment_ratios, np.ndarray): self._segment_ratios = [segment_ratios] * self.nBoreholes elif isinstance(segment_ratios, list): @@ -1493,6 +1496,9 @@ def _format_inputs(self, m_flow_network, cp_f, nSegments, segment_ratios): # Format segment ratios if segment_ratios is None: self._segment_ratios = [None] * self.nBoreholes + elif callable(segment_ratios): + self._segment_ratios = [ + segment_ratios(nSegments_i) for nSegments_i in self.nSegments] elif isinstance(segment_ratios, np.ndarray): self._segment_ratios = [segment_ratios] * self.nBoreholes elif isinstance(segment_ratios, list): diff --git a/pygfunction/solvers/__init__.py b/pygfunction/solvers/__init__.py new file mode 100644 index 00000000..2691d74e --- /dev/null +++ b/pygfunction/solvers/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- +from .base_solver import _BaseSolver +from .detailed import Detailed +from .similarities import Similarities +from .equivalent import Equivalent diff --git a/pygfunction/solvers/base_solver.py b/pygfunction/solvers/base_solver.py new file mode 100644 index 00000000..5577bea0 --- /dev/null +++ b/pygfunction/solvers/base_solver.py @@ -0,0 +1,584 @@ +# -*- coding: utf-8 -*- +from collections.abc import Callable +from typing import Union, List +from time import perf_counter + +import numpy as np +import numpy.typing as npt +from scipy.interpolate import interp1d as interp1d + +from ..borefield import Borefield +from ..networks import Network, network_thermal_resistance +from .. import utilities + + +class _BaseSolver(object): + """ + Template for solver classes. + + Solver classes inherit from this class. + + Attributes + ---------- + borefield : Borefield object + The bore field. + network : network object + The network. + time : float or array + Values of time (in seconds) for which the g-function is evaluated. + boundary_condition : str + Boundary condition for the evaluation of the g-function. Should be one + of + + - 'UHTR' : + Uniform heat transfer rate. + - 'UBWT' : + Uniform borehole wall temperature. + - 'MIFT' : + Mixed inlet fluid temperatures. + + nSegments : int or list, optional + Number of line segments used per borehole, or list of number of + line segments used for each borehole. + Default is 8. + segment_ratios : array, list of arrays, or callable, optional + Ratio of the borehole length represented by each segment. The + sum of ratios must be equal to 1. The shape of the array is of + (nSegments,) or list of (nSegments[i],). If segment_ratios==None, + segments of equal lengths are considered. If a callable is provided, it + must return an array of size (nSegments,) when provided with nSegments + (of type int) as an argument, or an array of size (nSegments[i],) when + provided with an element of nSegments (of type list). + Default is :func:`utilities.segment_ratios`. + m_flow_borehole : (nInlets,) array or (nMassFlow, nInlets,) array, optional + Fluid mass flow rate into each circuit of the network. If a + (nMassFlow, nInlets,) array is supplied, the + (nMassFlow, nMassFlow,) variable mass flow rate g-functions + will be evaluated using the method of Cimmino (2024) + [#gFunction-CimBer2024]_. Only required for the 'MIFT' boundary + condition. Only one of 'm_flow_borehole' and 'm_flow_network' can be + provided. + Default is None. + m_flow_network : float or (nMassFlow,) array, optional + Fluid mass flow rate into the network of boreholes. If an array + is supplied, the (nMassFlow, nMassFlow,) variable mass flow + rate g-functions will be evaluated using the method of Cimmino + (2024) [#gFunction-CimBer2024]_. Only required for the 'MIFT' boundary + condition. Only one of 'm_flow_borehole' and 'm_flow_network' can be + provided. + Default is None. + cp_f : float, optional + Fluid specific isobaric heat capacity (in J/kg.degC). Only required + for the 'MIFT' boundary condition. + Default is None. + approximate_FLS : bool, optional + Set to true to use the approximation of the FLS solution of Cimmino + (2021). This approximation does not require the numerical evaluation of + any integral. When using the 'equivalent' solver, the approximation is + only applied to the thermal response at the borehole radius. Thermal + interaction between boreholes is evaluated using the FLS solution. + Default is False. + nFLS : int, optional + Number of terms in the approximation of the FLS solution. This + parameter is unused if `approximate_FLS` is set to False. + Default is 10. Maximum is 25. + mQuad : int, optional + Number of Gauss-Legendre sample points for the integral over :math:`u` + in the inclined FLS solution. + Default is 11. + linear_threshold : float, optional + Threshold time (in seconds) under which the g-function is + linearized. The g-function value is then interpolated between 0 + and its value at the threshold. If linear_threshold==None, the + g-function is linearized for times + `t < r_b**2 / (25 * self.alpha)`. + Default is None. + disp : bool, optional + Set to true to print progression messages. + Default is False. + profiles : bool, optional + Set to true to keep in memory the temperatures and heat extraction + rates. + Default is False. + kind : string, optional + Interpolation method used for segment-to-segment thermal response + factors. See documentation for scipy.interpolate.interp1d. + Default is 'linear'. + dtype : numpy dtype, optional + numpy data type used for matrices and vectors. Should be one of + numpy.single or numpy.double. + Default is numpy.double. + + """ + def __init__( + self, borefield: Borefield, network: Union[Network, None], + time: npt.ArrayLike, boundary_condition: str, + m_flow_borehole: Union[npt.ArrayLike, None] = None, + m_flow_network: Union[npt.ArrayLike, None] = None, + cp_f: Union[float, None] = None, nSegments: int = 8, + segment_ratios: Union[npt.ArrayLike, List[npt.ArrayLike], Callable[[int], npt.ArrayLike]] = utilities.segment_ratios, + approximate_FLS: bool = False, mQuad: int = 11, nFLS: int = 10, + linear_threshold: Union[float, None] = None, disp: bool = False, + profiles: bool = False, kind: str = 'linear', + dtype: npt.DTypeLike = np.double, **other_options): + # Input attributes + self.borefield = borefield + self.network = network + self.time = np.atleast_1d(time) + self.boundary_condition = boundary_condition + self.m_flow_borehole = m_flow_borehole + self.m_flow_network = m_flow_network + self.cp_f = cp_f + self.nSegments = nSegments + self.segment_ratios = segment_ratios + self.approximate_FLS = approximate_FLS + self.mQuad = mQuad + self.nFLS = nFLS + self.linear_threshold = linear_threshold + self.disp = disp + self.profiles = profiles + self.kind = kind + self.dtype = dtype + + # Check the validity of inputs + self._check_inputs() + # Initialize the solver with solver-specific options + self.nSources = self.initialize(**other_options) + + self.nMassFlow = 0 + if self.m_flow_borehole is not None: + if not self.m_flow_borehole.ndim == 1: + self.nMassFlow = np.size(self.m_flow_borehole, axis=0) + self.m_flow_borehole = np.atleast_2d(self.m_flow_borehole) + self.m_flow = self.m_flow_borehole + if self.m_flow_network is not None: + if not isinstance(self.m_flow_network, (np.floating, float)): + self.nMassFlow = len(self.m_flow_network) + self.m_flow_network = np.atleast_1d(self.m_flow_network) + self.m_flow = self.m_flow_network + + return + + def initialize(self, *kwargs) -> int: + """ + Perform any calculation required at the initialization of the solver + and returns the number of finite line heat sources in the borefield. + + Raises + ------ + NotImplementedError + + Returns + ------- + nSources : int + Number of finite line heat sources in the borefield used to + initialize the matrix of segment-to-segment thermal response + factors (of size: nSources x nSources). + + """ + raise NotImplementedError( + 'initialize class method not implemented, this method should ' + 'return the number of finite line heat sources in the borefield ' + 'used to initialize the matrix of segment-to-segment thermal ' + 'response factors (of size: nSources x nSources)') + return 0 + + def solve(self, time: npt.ArrayLike, alpha: float) -> npt.ArrayLike: + """ + Build and solve the system of equations. + + Parameters + ---------- + time : float or array + Values of time (in seconds) for which the g-function is evaluated. + alpha : float + Soil thermal diffusivity (in m2/s). + + Returns + ------- + gFunc : float or array + Values of the g-function + + """ + # Number of time values + scalar_time = isinstance(time, (float, np.floating, int, np.integer)) + self.time = np.atleast_1d(time) + nTimes = len(self.time) + # Evaluate threshold time for g-function linearization + if self.linear_threshold is None: + time_threshold = np.max(self.borefield.r_b)**2 / (25 * alpha) + else: + time_threshold = self.linear_threshold + # Find the number of g-function values to be linearized + p_long = np.searchsorted(self.time, time_threshold, side='right') + p0 = np.maximum(0, p_long - 1, dtype=int) + if p_long > 0: + time_long = np.concatenate([[time_threshold], self.time[p_long:]]) + else: + time_long = self.time + nTimes_long = len(time_long) + # Calculate segment to segment thermal response factors + h_ij = self.thermal_response_factors(time_long, alpha, kind=self.kind) + # Segment lengths + H_b = self.segment_lengths + H_tot = np.sum(H_b) + if self.disp: print('Building and solving the system of equations ...', + end='') + # Initialize chrono + tic = perf_counter() + + if self.boundary_condition == 'UHTR': + # Initialize g-function + gFunc = np.zeros(nTimes) + # Initialize segment heat extraction rates + Q_b = np.broadcast_to(1., (self.nSources, nTimes)) + # Initialize borehole wall temperatures + T_b = np.zeros((self.nSources, nTimes), dtype=self.dtype) + + # Evaluate the g-function with uniform heat extraction along + # boreholes + T_b[:, p0:] = np.sum(h_ij.y[:, :, 1:], axis=1) + gFunc[p0:] = (T_b[:, p0:].T @ H_b).T / H_tot + # Linearize g-function for times under threshold + if p_long > 0: + gFunc[:p_long] = gFunc[p_long - 1] * self.time[:p_long] / time_threshold + T_b[:, :p_long] = T_b[:, p_long - 1:p_long] * self.time[:p_long] / time_threshold + + elif self.boundary_condition == 'UBWT': + # Initialize g-function + gFunc = np.zeros(nTimes) + # Initialize segment heat extraction rates + Q_b = np.zeros((self.nSources, nTimes), dtype=self.dtype) + T_b = np.zeros(nTimes, dtype=self.dtype) + + # Build and solve the system of equations at all times + dt = np.concatenate( + [time_long[0:1], time_long[1:] - time_long[:-1]]) + for p in range(nTimes_long): + # Thermal response factors evaluated at t=dt + h_dt = h_ij(dt[p]) + # Reconstructed load history + Q_reconstructed = self.load_history_reconstruction( + time_long[0:p + 1], Q_b[:, p0:p + p0 + 1]) + # Borehole wall temperature for zero heat extraction at + # current step + T_b0 = self.temporal_superposition( + h_ij.y[:, :, 1:], Q_reconstructed) + + # Evaluate the g-function with uniform borehole wall + # temperature + # --------------------------------------------------------- + # Build a system of equation [A]*[X] = [B] for the + # evaluation of the g-function. [A] is a coefficient + # matrix, [X] = [Q_b,T_b] is a state space vector of the + # borehole heat extraction rates and borehole wall + # temperature (equal for all segments), [B] is a + # coefficient vector. + # + # Spatial superposition: [T_b] = [T_b0] + [h_ij_dt]*[Q_b] + # Energy conservation: sum([Q_b*Hb]) = sum([Hb]) + # --------------------------------------------------------- + A = np.block([[h_dt, -np.ones((self.nSources, 1), + dtype=self.dtype)], + [H_b, 0.]]) + B = np.hstack((-T_b0, H_tot)) + # Solve the system of equations + X = np.linalg.solve(A, B) + # Store calculated heat extraction rates + Q_b[:, p + p0] = X[0:self.nSources] + # The borehole wall temperatures are equal for all segments + T_b[p + p0] = X[-1] + gFunc[p + p0] = T_b[p + p0] + + # Linearize g-function for times under threshold + if p_long > 0: + gFunc[:p_long] = gFunc[p_long - 1] * self.time[:p_long] / time_threshold + Q_b[:,:p_long] = 1 + (Q_b[:, p_long - 1:p_long] - 1) * self.time[:p_long] / time_threshold + T_b[:p_long] = T_b[p_long - 1] * self.time[:p_long] / time_threshold + + # Broadcast T_b to expected size + T_b = np.broadcast_to(T_b, (self.nSources, nTimes)) + + elif self.boundary_condition == 'MIFT': + if self.nMassFlow == 0: + # Initialize g-function + gFunc = np.zeros((1, 1, nTimes)) + # Initialize segment heat extraction rates + Q_b = np.zeros((1, self.nSources, nTimes), dtype=self.dtype) + T_b = np.zeros((1, self.nSources, nTimes), dtype=self.dtype) + else: + # Initialize g-function + gFunc = np.zeros((self.nMassFlow, self.nMassFlow, nTimes)) + # Initialize segment heat extraction rates + Q_b = np.zeros( + (self.nMassFlow, self.nSources, nTimes), dtype=self.dtype) + T_b = np.zeros( + (self.nMassFlow, self.nSources, nTimes), dtype=self.dtype) + + for j in range(np.maximum(self.nMassFlow, 1)): + # Build and solve the system of equations at all times + a_in_j, a_b_j = self.network.coefficients_borehole_heat_extraction_rate( + self.m_flow[j], + self.cp_f, + self.nSegments, + segment_ratios=self.segment_ratios) + k_s = self.network.p[0].k_s + for p in range(nTimes_long): + # Current thermal response factor matrix + if p > 0: + dt = time_long[p] - time_long[p-1] + else: + dt = time_long[p] + # Thermal response factors evaluated at t=dt + h_dt = h_ij(dt) + # Reconstructed load history + Q_reconstructed = self.load_history_reconstruction( + time_long[0:p+1], Q_b[j,:,p0:p+p0+1]) + # Borehole wall temperature for zero heat extraction at + # current step + T_b0 = self.temporal_superposition( + h_ij.y[:,:,1:], Q_reconstructed) + + # Evaluate the g-function with mixed inlet fluid + # temperatures + # --------------------------------------------------------- + # Build a system of equation [A]*[X] = [B] for the + # evaluation of the g-function. [A] is a coefficient + # matrix, [X] = [Q_b,T_b,Tf_in] is a state space vector of + # the borehole heat extraction rates, borehole wall + # temperatures and inlet fluid temperature (into the bore + # field), [B] is a coefficient vector. + # + # Spatial superposition: [T_b] = [T_b0] + [h_ij_dt]*[Q_b] + # Heat transfer inside boreholes: + # [Q_{b,i}] = [a_in]*[T_{f,in}] + [a_{b,i}]*[T_{b,i}] + # Energy conservation: sum([Q_b*H_b]) = sum([H_b]) + # --------------------------------------------------------- + A = np.block( + [[h_dt, + -np.eye(self.nSources, dtype=self.dtype), + np.zeros((self.nSources, 1), dtype=self.dtype)], + [np.eye(self.nSources, dtype=self.dtype), + a_b_j / (2. * np.pi * k_s * np.atleast_2d(self.segments.H).T), + a_in_j / (2. * np.pi * k_s * np.atleast_2d(self.segments.H).T)], + [H_b, np.zeros(self.nSources + 1, dtype=self.dtype)]]) + B = np.hstack( + (-T_b0, + np.zeros(self.nSources, dtype=self.dtype), + H_tot)) + # Solve the system of equations + X = np.linalg.solve(A, B) + # Store calculated heat extraction rates + Q_b[j, :, p+p0] = X[0:self.nSources] + T_b[j, :, p+p0] = X[self.nSources:2 * self.nSources] + # Inlet fluid temperature + T_f_in = X[-1] + # The gFunction is equal to the effective borehole wall + # temperature + # Outlet fluid temperature + T_f_out = T_f_in - 2 * np.pi * k_s * H_tot / ( + np.sum(np.abs(self.m_flow[j]) * self.cp_f)) + # Average fluid temperature + T_f = 0.5 * (T_f_in + T_f_out) + # Borefield thermal resistance + R_field = network_thermal_resistance( + self.network, self.m_flow[j], self.cp_f) + # Effective borehole wall temperature + T_b_eff = T_f - 2 * np.pi * k_s * R_field + gFunc[j, j, p + p0] = T_b_eff + + for i in range(np.maximum(self.nMassFlow, 1)): + for j in range(np.maximum(self.nMassFlow, 1)): + if not i == j: + # Inlet fluid temperature + a_in, a_b = self.network.coefficients_network_heat_extraction_rate( + self.m_flow[i], + self.cp_f, + self.nSegments, + segment_ratios=self.segment_ratios) + T_f_in = (-2 * np.pi * k_s * H_tot - a_b @ T_b[j, :, p0:]) / a_in + # The gFunction is equal to the effective borehole wall + # temperature + # Outlet fluid temperature + T_f_out = T_f_in - 2 * np.pi * k_s * H_tot / np.sum(np.abs(self.m_flow[i]) * self.cp_f) + # Borefield thermal resistance + R_field = network_thermal_resistance( + self.network, self.m_flow[i], self.cp_f) + # Effective borehole wall temperature + T_b_eff = 0.5 * (T_f_in + T_f_out) - 2 * np.pi * k_s * R_field + gFunc[i, j, p0:] = T_b_eff + + # Linearize g-function for times under threshold + if p_long > 0: + gFunc[:, :, :p_long] = gFunc[:, :, p_long-1] * self.time[:p_long] / time_threshold + Q_b[:, :, :p_long] = 1 + (Q_b[:, :, p_long-1:p_long] - 1) * self.time[:p_long] / time_threshold + T_b[:, :, :p_long] = T_b[:, :, p_long - 1:p_long] * self.time[:p_long] / time_threshold + if self.nMassFlow == 0: + gFunc = gFunc[0, 0, :] + Q_b = Q_b[0, :, :] + T_b = T_b[0, :, :] + + # Store temperature and heat extraction rate profiles + if self.profiles: + self.Q_b = Q_b + self.T_b = T_b + toc = perf_counter() + if self.disp: print(f' {toc - tic:.3f} sec') + return gFunc + + @property + def segment_lengths(self) -> np.ndarray: + """ + Return the length of all segments in the bore field. + + The segments lengths are used for the energy balance in the calculation + of the g-function. + + Returns + ------- + H : array + Array of segment lengths (in m). + + """ + return self.segments.H + + @staticmethod + def temporal_superposition( + h_ij: np.ndarray, Q_reconstructed: np.ndarray) -> np.ndarray: + """ + Temporal superposition for inequal time steps. + + Parameters + ---------- + h_ij : array + Values of the segment-to-segment thermal response factor increments + at the given time step. + Q_reconstructed : array + Reconstructed heat extraction rates of all segments at all times. + + Returns + ------- + T_b0 : array + Current values of borehole wall temperatures assuming no heat + extraction during current time step. + + """ + # Number of time steps + nTimes = Q_reconstructed.shape[1] + # Spatial and temporal superpositions + dQ = np.concatenate( + (Q_reconstructed[:, 0:1], + Q_reconstructed[:, 1:] - Q_reconstructed[:, 0:-1]), + axis=1)[:,::-1] + # Borehole wall temperature + T_b0 = np.einsum('ijk,jk', h_ij[:,:,:nTimes], dQ) + + return T_b0 + + @staticmethod + def load_history_reconstruction( + time: np.ndarray, Q_b: np.ndarray) -> np.ndarray: + """ + Reconstructs the load history. + + This function calculates an equivalent load history for an inverted + order of time step sizes. + + Parameters + ---------- + time : array + Values of time (in seconds) in the load history. + Q_b : array + Heat extraction rates (in Watts) of all segments at all times. + + Returns + ------- + Q_reconstructed : array + Reconstructed load history. + + """ + # Number of heat sources + nSources = Q_b.shape[0] + # Time step sizes + dt = np.hstack((time[0], time[1:] - time[:-1])) + # Time vector + t = np.hstack((0., time, time[-1] + time[0])) + # Inverted time step sizes + dt_reconstructed = dt[::-1] + # Reconstructed time vector + t_reconstructed = np.hstack((0., np.cumsum(dt_reconstructed))) + # Accumulated heat extracted + f = np.hstack( + (np.zeros((nSources, 1)), + np.cumsum(Q_b*dt, axis=1))) + f = np.hstack((f, f[:, -1:])) + # Create interpolation object for accumulated heat extracted + sf = interp1d(t, f, kind='linear', axis=1) + # Reconstructed load history + Q_reconstructed = ( + sf(t_reconstructed[1:]) - sf(t_reconstructed[:-1]) + ) / dt_reconstructed + + return Q_reconstructed + + def _check_inputs(self): + """ + This method ensures that the instances filled in the Solver object + are what is expected. + + """ + assert isinstance(self.borefield, Borefield), \ + "The borefield is not a valid 'Borefield' object." + assert len(self.borefield) > 0, \ + "The number of boreholes must be 1 or greater." + assert self.network is None or isinstance(self.network, Network), \ + "The network is not a valid 'Network' object." + if self.boundary_condition == 'MIFT': + assert not (self.m_flow_network is None and self.m_flow_borehole is None), \ + "The mass flow rate 'm_flow_borehole' or 'm_flow_network' must " \ + "be provided when using the 'MIFT' boundary condition." + assert not (self.m_flow_network is not None and self.m_flow_borehole is not None), \ + "Only one of 'm_flow_borehole' or 'm_flow_network' can " \ + "be provided when using the 'MIFT' boundary condition." + assert not self.cp_f is None, \ + "The heat capacity 'cp_f' must " \ + "be provided when using the 'MIFT' boundary condition." + assert not (isinstance(self.m_flow_borehole, np.ndarray) and not np.size(self.m_flow_borehole, axis=1)==self.network.nInlets), \ + "The number of mass flow rates in 'm_flow_borehole' must " \ + "correspond to the number of circuits in the network." + assert isinstance(self.time, np.ndarray), \ + "Time should be an array." + assert (isinstance(self.nSegments, (int, np.integer)) + and self.nSegments >= 1) \ + or (isinstance(self.nSegments, (list, np.ndarray)) + and len(self.nSegments) == len(self.borefield) + and np.min(self.nSegments) >=1), \ + "The argument for number of segments `nSegments` should be " \ + "of type int or a list of integers. If passed as a list, the " \ + "length of the list should be equal to the number of boreholes" \ + "in the borefield. nSegments >= 1 is/are required." + acceptable_boundary_conditions = ['UHTR', 'UBWT', 'MIFT'] + assert (isinstance(self.boundary_condition, str) + and self.boundary_condition in acceptable_boundary_conditions), \ + f"Boundary condition '{self.boundary_condition}' is not an " \ + f"acceptable boundary condition. \n" \ + f"Please provide one of the following inputs : " \ + f"{acceptable_boundary_conditions}" + assert isinstance(self.approximate_FLS, bool), \ + "The option 'approximate_FLS' should be set to True or False." + assert isinstance(self.nFLS, int) and 1 <= self.nFLS <= 25, \ + "The option 'nFLS' should be a positive int and lower or equal " \ + "to 25." + assert isinstance(self.disp, bool), \ + "The option 'disp' should be set to True or False." + assert isinstance(self.profiles, bool), \ + "The option 'profiles' should be set to True or False." + assert isinstance(self.kind, str), \ + "The option 'kind' should be set to a valid interpolation kind " \ + "in accordance with scipy.interpolate.interp1d options." + acceptable_dtypes = (np.single, np.double) + assert np.any([self.dtype is dtype for dtype in acceptable_dtypes]), \ + f"Data type '{self.dtype}' is not an acceptable data type. \n" \ + f"Please provide one of the following inputs : {acceptable_dtypes}" + + return diff --git a/pygfunction/solvers/detailed.py b/pygfunction/solvers/detailed.py new file mode 100644 index 00000000..d685d7b7 --- /dev/null +++ b/pygfunction/solvers/detailed.py @@ -0,0 +1,282 @@ +# -*- coding: utf-8 -*- +from time import perf_counter + +import numpy as np +import numpy.typing as npt +from scipy.interpolate import interp1d as interp1d + +from .base_solver import _BaseSolver +from ..heat_transfer import finite_line_source + + +class Detailed(_BaseSolver): + """ + Detailed solver for the evaluation of the g-function. + + This solver superimposes the finite line source (FLS) solution to + estimate the g-function of a geothermal bore field. Each borehole is + modeled as a series of finite line source segments, as proposed in + [#Detailed-CimBer2014]_. + + Parameters + ---------- + borefield : Borefield object + The bore field. + network : network object + The network. + time : float or array + Values of time (in seconds) for which the g-function is evaluated. + boundary_condition : str + Boundary condition for the evaluation of the g-function. Should be one + of + + - 'UHTR' : + **Uniform heat transfer rate**. This is corresponds to boundary + condition *BC-I* as defined by Cimmino and Bernier (2014) + [#Detailed-CimBer2014]_. + - 'UBWT' : + **Uniform borehole wall temperature**. This is corresponds to + boundary condition *BC-III* as defined by Cimmino and Bernier + (2014) [#Detailed-CimBer2014]_. + - 'MIFT' : + **Mixed inlet fluid temperatures**. This boundary condition was + introduced by Cimmino (2015) [#gFunction-Cimmin2015]_ for + parallel-connected boreholes and extended to mixed + configurations by Cimmino (2019) [#Detailed-Cimmin2019]_. + + nSegments : int or list, optional + Number of line segments used per borehole, or list of number of + line segments used for each borehole. + Default is 8. + segment_ratios : array, list of arrays, or callable, optional + Ratio of the borehole length represented by each segment. The + sum of ratios must be equal to 1. The shape of the array is of + (nSegments,) or list of (nSegments[i],). If segment_ratios==None, + segments of equal lengths are considered. If a callable is provided, it + must return an array of size (nSegments,) when provided with nSegments + (of type int) as an argument, or an array of size (nSegments[i],) when + provided with an element of nSegments (of type list). + Default is :func:`utilities.segment_ratios`. + m_flow_borehole : (nInlets,) array or (nMassFlow, nInlets,) array, optional + Fluid mass flow rate into each circuit of the network. If a + (nMassFlow, nInlets,) array is supplied, the + (nMassFlow, nMassFlow,) variable mass flow rate g-functions + will be evaluated using the method of Cimmino (2024) + [#gFunction-CimBer2024]_. Only required for the 'MIFT' boundary + condition. Only one of 'm_flow_borehole' and 'm_flow_network' can be + provided. + Default is None. + m_flow_network : float or (nMassFlow,) array, optional + Fluid mass flow rate into the network of boreholes. If an array + is supplied, the (nMassFlow, nMassFlow,) variable mass flow + rate g-functions will be evaluated using the method of Cimmino + (2024) [#gFunction-CimBer2024]_. Only required for the 'MIFT' boundary + condition. Only one of 'm_flow_borehole' and 'm_flow_network' can be + provided. + Default is None. + cp_f : float, optional + Fluid specific isobaric heat capacity (in J/kg.degC). Only required + for the 'MIFT' boundary condition. + Default is None. + approximate_FLS : bool, optional + Set to true to use the approximation of the FLS solution of Cimmino + (2021) [#Detailed-Cimmin2021]_. This approximation does not require the + numerical evaluation of any integral. + Default is False. + nFLS : int, optional + Number of terms in the approximation of the FLS solution. This + parameter is unused if `approximate_FLS` is set to False. + Default is 10. Maximum is 25. + mQuad : int, optional + Number of Gauss-Legendre sample points for the integral over :math:`u` + in the inclined FLS solution. + Default is 11. + linear_threshold : float, optional + Threshold time (in seconds) under which the g-function is + linearized. The g-function value is then interpolated between 0 + and its value at the threshold. If linear_threshold==None, the + g-function is linearized for times + `t < r_b**2 / (25 * self.alpha)`. + Default is None. + disp : bool, optional + Set to true to print progression messages. + Default is False. + profiles : bool, optional + Set to true to keep in memory the temperatures and heat extraction + rates. + Default is False. + kind : string, optional + Interpolation method used for segment-to-segment thermal response + factors. See documentation for scipy.interpolate.interp1d. + Default is 'linear'. + dtype : numpy dtype, optional + numpy data type used for matrices and vectors. Should be one of + numpy.single or numpy.double. + Default is numpy.double. + + References + ---------- + .. [#Detailed-CimBer2014] Cimmino, M., & Bernier, M. (2014). A + semi-analytical method to generate g-functions for geothermal bore + fields. International Journal of Heat and Mass Transfer, 70, 641-650. + .. [#Detailed-Cimmin2019] Cimmino, M. (2019). Semi-analytical method for + g-function calculation of bore fields with series- and + parallel-connected boreholes. Science and Technology for the Built + Environment, 25 (8), 1007-1022. + .. [#Detailed-Cimmin2021] Cimmino, M. (2021). An approximation of the + finite line source solution to model thermal interactions between + geothermal boreholes. International Communications in Heat and Mass + Transfer, 127, 105496. + + """ + def initialize(self, **kwargs) -> int: + """ + Split boreholes into segments. + + Returns + ------- + nSources : int + Number of finite line heat sources in the borefield used to + initialize the matrix of segment-to-segment thermal response + factors (of size: nSources x nSources). + + """ + # Split boreholes into segments + self.segments = self.borefield.segments( + self.nSegments, self.segment_ratios) + nSources = len(self.segments) + self._i1Segments = np.cumsum( + np.broadcast_to(self.nSegments, len(self.borefield)), + dtype=int) + self._i0Segments = np.concatenate( + ([0], self._i1Segments[:-1]), + dtype=int) + return nSources + + def thermal_response_factors( + self, time: npt.ArrayLike, alpha: float, kind: str = 'linear' + ) -> interp1d: + """ + Evaluate the segment-to-segment thermal response factors for all pairs + of segments in the borefield at all time steps using the finite line + source solution. + + This method returns a scipy.interpolate.interp1d object of the matrix + of thermal response factors, containing a copy of the matrix accessible + by h_ij.y[:nSources,:nSources,:nt+1]. The first index along the + third axis corresponds to time t=0. The interp1d object can be used to + obtain thermal response factors at any intermediate time by + h_ij(t)[:nSources,:nSources]. + + Attributes + ---------- + time : float or array + Values of time (in seconds) for which the g-function is evaluated. + alpha : float + Soil thermal diffusivity (in m2/s). + kind : string, optional + Interpolation method used for segment-to-segment thermal response + factors. See documentation for scipy.interpolate.interp1d. + Default is 'linear'. + + Returns + ------- + h_ij : interp1d + interp1d object (scipy.interpolate) of the matrix of + segment-to-segment thermal response factors. + + """ + if self.disp: + print('Calculating segment to segment response factors ...', + end='') + # Number of time values + nt = len(np.atleast_1d(time)) + # Initialize chrono + tic = perf_counter() + # Initialize segment-to-segment response factors + h_ij = np.zeros((self.nSources, self.nSources, nt+1), dtype=self.dtype) + nBoreholes = len(self.borefield) + segment_lengths = self.segment_lengths + + # --------------------------------------------------------------------- + # Segment-to-segment thermal response factors for same-borehole + # thermal interactions + # --------------------------------------------------------------------- + h, i, j = \ + self._thermal_response_factors_borehole_to_self(time, alpha) + # Broadcast values to h_ij matrix + h_ij[i, j, 1:] = h + # --------------------------------------------------------------------- + # Segment-to-segment thermal response factors for + # borehole-to-borehole thermal interactions + # --------------------------------------------------------------------- + i1 = self._i1Segments + i0 = self._i0Segments + for i, (_i0, _i1) in enumerate(zip(i0, i1)): + # Segments of the receiving borehole + b2 = self.segments[_i0:_i1] + if i+1 < nBoreholes: + # Segments of the emitting borehole + b1 = self.segments[_i1:] + h = finite_line_source( + time, alpha, b1, b2, approximation=self.approximate_FLS, + N=self.nFLS, M=self.mQuad) + # Broadcast values to h_ij matrix + h_ij[_i0:_i1, _i1:, 1:] = h + h_ij[_i1:, _i0:_i1, 1:] = \ + np.swapaxes(h, 0, 1) * np.divide.outer( + segment_lengths[_i0:_i1], + segment_lengths[_i1:]).T[:, :, np.newaxis] + + # Return 2d array if time is a scalar + if np.isscalar(time): + h_ij = h_ij[:,:,1] + + # Interp1d object for thermal response factors + h_ij = interp1d(np.hstack((0., time)), h_ij, + kind=kind, copy=True, axis=2) + toc = perf_counter() + if self.disp: print(f' {toc - tic:.3f} sec') + + return h_ij + + def _thermal_response_factors_borehole_to_self( + self, time: npt.ArrayLike, alpha: float) -> np.ndarray: + """ + Evaluate the segment-to-segment thermal response factors for all pairs + of segments between each borehole and itself. + + Attributes + ---------- + time : float or array + Values of time (in seconds) for which the g-function is evaluated. + alpha : float + Soil thermal diffusivity (in m2/s). + + Returns + ------- + h : array + Finite line source solution. + i_segment : list + Indices of the emitting segments in the bore field. + j_segment : list + Indices of the receiving segments in the bore field. + """ + # Indices of the thermal response factors into h_ij + nBoreholes = len(self.borefield) + nSegments = np.broadcast_to(self.nSegments, nBoreholes) + i1 = self._i1Segments + i0 = self._i0Segments + i = np.concatenate( + [np.tile(np.arange(_j0, _j1), nSeg) + for _j0, _j1, nSeg in zip(i0, i1, nSegments) + ]) + j = np.repeat( + np.arange(self.nSources), + np.repeat(nSegments, self.nSegments)) + segments_i = self.segments[i] + segments_j = self.segments[j] + h = finite_line_source( + time, alpha, segments_j, segments_i, outer=False, + approximation=self.approximate_FLS, M=self.mQuad, N=self.nFLS) + return h, i, j diff --git a/pygfunction/solvers/equivalent.py b/pygfunction/solvers/equivalent.py new file mode 100644 index 00000000..4009c0ff --- /dev/null +++ b/pygfunction/solvers/equivalent.py @@ -0,0 +1,873 @@ +# -*- coding: utf-8 -*- +from time import perf_counter +from typing import Tuple, List + +import numpy as np +import numpy.typing as npt +from scipy.cluster.hierarchy import cut_tree, dendrogram, linkage +from scipy.interpolate import interp1d as interp1d + +from .base_solver import _BaseSolver +from ..borefield import Borefield, _EquivalentBorefield +from ..boreholes import Borehole, _EquivalentBorehole +from ..heat_transfer import finite_line_source, \ + finite_line_source_equivalent_boreholes_vectorized +from ..networks import _EquivalentNetwork + + +class Equivalent(_BaseSolver): + """ + Equivalent solver for the evaluation of the g-function. + + This solver uses hierarchical agglomerative clustering to identify groups + of boreholes that are expected to have similar borehole wall temperatures + and heat extraction rates, as proposed by Prieto and Cimmino (2021) + [#Equivalent-PriCim2021]_. Each group of boreholes is represented by a + single equivalent borehole. The FLS solution is adapted to evaluate + thermal interactions between groups of boreholes. This greatly reduces + the number of evaluations of the FLS solution and the size of the system of + equations to evaluate the g-function. + + Parameters + ---------- + borefield : Borefield object + The bore field. + network : network object + Model of the network. + time : float or array + Values of time (in seconds) for which the g-function is evaluated. + boundary_condition : str + Boundary condition for the evaluation of the g-function. Should be one + of + + - 'UHTR' : + **Uniform heat transfer rate**. This is corresponds to boundary + condition *BC-I* as defined by Cimmino and Bernier (2014) + [#Equivalent-CimBer2014]_. + - 'UBWT' : + **Uniform borehole wall temperature**. This is corresponds to + boundary condition *BC-III* as defined by Cimmino and Bernier + (2014) [#Equivalent-CimBer2014]_. + - 'MIFT' : + **Mixed inlet fluid temperatures**. This boundary condition was + introduced by Cimmino (2015) [#Equivalent-Cimmin2015]_ for + parallel-connected boreholes and extended to mixed + configurations by Cimmino (2019) [#Equivalent-Cimmin2019]_. + + nSegments : int or list, optional + Number of line segments used per borehole, or list of number of + line segments used for each borehole. + Default is 8. + segment_ratios : array, list of arrays, or callable, optional + Ratio of the borehole length represented by each segment. The + sum of ratios must be equal to 1. The shape of the array is of + (nSegments,) or list of (nSegments[i],). If segment_ratios==None, + segments of equal lengths are considered. If a callable is provided, it + must return an array of size (nSegments,) when provided with nSegments + (of type int) as an argument, or an array of size (nSegments[i],) when + provided with an element of nSegments (of type list). + Default is :func:`utilities.segment_ratios`. + m_flow_borehole : (nInlets,) array or (nMassFlow, nInlets,) array, optional + Fluid mass flow rate into each circuit of the network. If a + (nMassFlow, nInlets,) array is supplied, the + (nMassFlow, nMassFlow,) variable mass flow rate g-functions + will be evaluated using the method of Cimmino (2024) + [#gFunction-CimBer2024]_. Only required for the 'MIFT' boundary + condition. Only one of 'm_flow_borehole' and 'm_flow_network' can be + provided. + Default is None. + m_flow_network : float or (nMassFlow,) array, optional + Fluid mass flow rate into the network of boreholes. If an array + is supplied, the (nMassFlow, nMassFlow,) variable mass flow + rate g-functions will be evaluated using the method of Cimmino + (2024) [#gFunction-CimBer2024]_. Only required for the 'MIFT' boundary + condition. Only one of 'm_flow_borehole' and 'm_flow_network' can be + provided. + Default is None. + cp_f : float, optional + Fluid specific isobaric heat capacity (in J/kg.degC). Only required + for the 'MIFT' boundary condition. + Default is None. + approximate_FLS : bool, optional + Set to true to use the approximation of the FLS solution of Cimmino + (2021) [#Equivalent-Cimmin2021]_. This approximation does not require + the numerical evaluation of any integral. When using the 'equivalent' + solver, the approximation is only applied to the thermal response at + the borehole radius. Thermal interaction between boreholes is evaluated + using the FLS solution. + Default is False. + nFLS : int, optional + Number of terms in the approximation of the FLS solution. This + parameter is unused if `approximate_FLS` is set to False. + Default is 10. Maximum is 25. + mQuad : int, optional + Number of Gauss-Legendre sample points for the integral over :math:`u` + in the inclined FLS solution. + Default is 11. + linear_threshold : float, optional + Threshold time (in seconds) under which the g-function is + linearized. The g-function value is then interpolated between 0 + and its value at the threshold. If linear_threshold==None, the + g-function is linearized for times + `t < r_b**2 / (25 * self.alpha)`. + Default is None. + disp : bool, optional + Set to true to print progression messages. + Default is False. + profiles : bool, optional + Set to true to keep in memory the temperatures and heat extraction + rates. + Default is False. + kind : string, optional + Interpolation method used for segment-to-segment thermal response + factors. See documentation for scipy.interpolate.interp1d. + Default is 'linear'. + dtype : numpy dtype, optional + numpy data type used for matrices and vectors. Should be one of + numpy.single or numpy.double. + Default is numpy.double. + disTol : float, optional + Relative tolerance on radial distance. Two distances + (d1, d2) between two pairs of boreholes are considered equal if the + difference between the two distances (abs(d1-d2)) is below tolerance. + Default is 0.01. + tol : float, optional + Relative tolerance on length and depth. Two lengths H1, H2 + (or depths D1, D2) are considered equal if abs(H1 - H2)/H2 < tol. + Default is 1.0e-6. + kClusters : int, optional + Increment on the minimum number of equivalent boreholes determined by + cutting the dendrogram of the bore field given by the hierarchical + agglomerative clustering method. Increasing the value of this parameter + increases the accuracy of the method. + Default is 1. + + References + ---------- + .. [#Equivalent-CimBer2014] Cimmino, M., & Bernier, M. (2014). A + semi-analytical method to generate g-functions for geothermal bore + fields. International Journal of Heat and Mass Transfer, 70, 641-650. + .. [#Equivalent-Cimmin2015] Cimmino, M. (2015). The effects of borehole + thermal resistances and fluid flow rate on the g-functions of geothermal + bore fields. International Journal of Heat and Mass Transfer, 91, + 1119-1127. + .. [#Equivalent-Cimmin2018] Cimmino, M. (2018). Fast calculation of the + g-functions of geothermal borehole fields using similarities in the + evaluation of the finite line source solution. Journal of Building + Performance Simulation, 11 (6), 655-668. + .. [#Equivalent-PriCim2021] Prieto, C., & Cimmino, M. + (2021). Thermal interactions in large irregular fields of geothermal + boreholes: the method of equivalent borehole. Journal of Building + Performance Simulation, 14 (4), 446-460. + .. [#Equivalent-Cimmin2021] Cimmino, M. (2021). An approximation of the + finite line source solution to model thermal interactions between + geothermal boreholes. International Communications in Heat and Mass + Transfer, 127, 105496. + + """ + def initialize( + self, disTol: float = 0.01, tol: float = 1.0e-6, + kClusters: int = 1, **kwargs) -> int: + """ + Initialize paramteters. Identify groups for equivalent boreholes. + + Returns + ------- + nSources : int + Number of finite line heat sources in the borefield used to + initialize the matrix of segment-to-segment thermal response + factors (of size: nSources x nSources). + + """ + self.disTol = disTol + self.tol = tol + self.kClusters = kClusters + # Check the validity of inputs + self._check_solver_specific_inputs() + # Initialize groups for equivalent boreholes + nSources = self.find_groups() + # Split boreholes into segments + self.segments = self.borefield.segments(self.nSegments, self.segment_ratios) + nEqBoreholes = self.nEqBoreholes + nSegments = np.broadcast_to(self.nSegments, nEqBoreholes) + self._i1Segments = np.cumsum( + nSegments, + dtype=int) + self._i0Segments = np.concatenate( + ([0], self._i1Segments[:-1]), + dtype=int) + return nSources + + def thermal_response_factors( + self, time: npt.ArrayLike, alpha: float, kind: str = 'linear' + ) -> interp1d: + """ + Evaluate the segment-to-segment thermal response factors for all pairs + of segments in the borefield at all time steps using the finite line + source solution. + + This method returns a scipy.interpolate.interp1d object of the matrix + of thermal response factors, containing a copy of the matrix accessible + by h_ij.y[:nSources,:nSources,:nt+1]. The first index along the + third axis corresponds to time t=0. The interp1d object can be used to + obtain thermal response factors at any intermediate time by + h_ij(t)[:nSources,:nSources]. + + Parameters + ---------- + time : float or array + Values of time (in seconds) for which the g-function is evaluated. + alpha : float + Soil thermal diffusivity (in m2/s). + kind : string, optional + Interpolation method used for segment-to-segment thermal response + factors. See documentation for scipy.interpolate.interp1d. + Default is linear. + + Returns + ------- + h_ij : interp1d + interp1d object (scipy.interpolate) of the matrix of + segment-to-segment thermal response factors. + + """ + if self.disp: + print('Calculating segment to segment response factors ...', + end='') + nEqBoreholes = self.nEqBoreholes + nSegments = np.broadcast_to(self.nSegments, nEqBoreholes) + i1 = self._i1Segments + i0 = self._i0Segments + # Number of time values + nt = len(np.atleast_1d(time)) + # Initialize chrono + tic = perf_counter() + # Initialize segment-to-segment response factors + h_ij = np.zeros((self.nSources, self.nSources, nt+1), dtype=self.dtype) + segment_lengths = self.segment_lengths + + # --------------------------------------------------------------------- + # Segment-to-segment thermal response factors for borehole-to-borehole + # thermal interactions + # --------------------------------------------------------------------- + # Groups correspond to unique pairs of borehole dimensions + for pairs in self.borehole_to_borehole: + i, j = pairs[0] + # Prepare inputs to the FLS function + dis, wDis = self._find_unique_distances(self.dis, pairs) + H1, D1, H2, D2, i_pair, j_pair, k_pair = \ + self._map_axial_segment_pairs(i, j) + H1 = H1.reshape(1, -1) + H2 = H2.reshape(1, -1) + D1 = D1.reshape(1, -1) + D2 = D2.reshape(1, -1) + N2 = np.array( + [[self.borefield[j].nBoreholes for (i, j) in pairs]]).T + # Evaluate FLS at all time steps + h = finite_line_source_equivalent_boreholes_vectorized( + time, alpha, dis, wDis, H1, D1, H2, D2, N2) + # Broadcast values to h_ij matrix + for k, (i, j) in enumerate(pairs): + i_segment = i0[i] + i_pair + j_segment = i0[j] + j_pair + h_ij[j_segment, i_segment, 1:] = h[k, k_pair, :] + if not i == j: + h_ij[i_segment, j_segment, 1:] = (h[k, k_pair, :].T \ + * segment_lengths[j_segment]/segment_lengths[i_segment]).T + + # --------------------------------------------------------------------- + # Segment-to-segment thermal response factors for same-borehole thermal + # interactions + # --------------------------------------------------------------------- + # Groups correspond to unique borehole dimensions + for group in self.borehole_to_self: + # Index of first borehole in group + i = group[0] + # Find segment-to-segment similarities + H1, D1, H2, D2, i_pair, j_pair, k_pair = \ + self._map_axial_segment_pairs(i, i) + # Evaluate FLS at all time steps + dis = self.borefield[i].r_b + borefield_1 = Borefield(H1, D1, dis, np.zeros_like(H1), 0.) + borefield_2 = Borefield(H2, D2, dis, np.zeros_like(H1), 0.) + h = finite_line_source( + time, alpha, borefield_1, borefield_2, outer=False, + approximation=self.approximate_FLS, N=self.nFLS) + # Broadcast values to h_ij matrix + for i in group: + i_segment = i0[i] + i_pair + j_segment = i0[i] + j_pair + h_ij[j_segment, i_segment, 1:] = \ + h_ij[j_segment, i_segment, 1:] + h[k_pair, :] + + # Return 2d array if time is a scalar + if np.isscalar(time): + h_ij = h_ij[:,:,1] + + # Interp1d object for thermal response factors + h_ij = interp1d(np.hstack((0., time)), h_ij, + kind=kind, copy=True, axis=2) + toc = perf_counter() + if self.disp: print(f' {toc - tic:.3f} sec') + + return h_ij + + def find_groups(self, tol: float = 1e-6) -> int: + """ + Identify groups of boreholes that can be represented by a single + equivalent borehole for the calculation of the g-function. + + Hierarchical agglomerative clustering is applied to the superposed + steady-state finite line source solution (i.e. the steady-state + dimensionless borehole wall temperature due to a uniform heat + extraction equal for all boreholes). The number of clusters is + evaluated by cutting the dendrogram at the half-height of the longest + branch and incrementing the number of intercepted branches by the value + of the kClusters parameter. + + Parameters + ---------- + tol : float + Tolerance on the temperature to identify the maxiumum number of + equivalent boreholes. + Default is 1e-6. + + Returns + ------- + nSources : int + Number of heat sources in the bore field. + + """ + if self.disp: print('Identifying equivalent boreholes ...', end='') + # Initialize chrono + tic = perf_counter() + + # Temperature change of individual boreholes + self.nBoreholes = len(self.borefield) + # Equivalent field formed by all boreholes + eqField = _EquivalentBorehole.from_borefield(self.borefield) + if self.nBoreholes > 1: + # Spatial superposition of the steady-state FLS solution + data = np.sum(finite_line_source(np.inf, 1., self.borefield, self.borefield), axis=1).reshape(-1,1) + # Split boreholes into groups of same dimensions + unique_boreholes = self._find_unique_boreholes(self.borefield) + # Initialize empty list of clusters + self.clusters = [] + self.nEqBoreholes = 0 + for group in unique_boreholes: + if len(group) > 1: + # Maximum temperature + maxTemp = np.max(data[group]) + # Hierarchical agglomerative clustering based on temperatures + clusterization = linkage(data[group], method='complete') + dcoord = np.array( + dendrogram(clusterization, no_plot=True)['dcoord']) + # Maximum number of clusters + # Height to cut each tree to obtain the minimum number of clusters + disLeft = dcoord[:,1] - dcoord[:,0] + disRight = dcoord[:,2] - dcoord[:,3] + if np.max(disLeft) >= np.max(disRight): + i = disLeft.argmax() + height = 0.5*(dcoord[i,1] + dcoord[i,0]) + else: + i = disRight.argmax() + height = 0.5*(dcoord[i,2] + dcoord[i,3]) + # Find the number of clusters and increment by kClusters + # Maximum number of clusters + nClustersMax = min(np.sum(dcoord[:,1] > tol*maxTemp) + 1, + len(group)) + # Optimal number of cluster + nClusters = np.max( + cut_tree(clusterization, height=height)) + 1 + nClusters = min(nClusters + self.kClusters, nClustersMax) + # Cut the tree to find the borehole groups + clusters = cut_tree( + clusterization, n_clusters=nClusters) + self.clusters = self.clusters + \ + [label + self.nEqBoreholes for label in clusters] + else: + nClusters = 1 + self.clusters.append(self.nEqBoreholes) + self.nEqBoreholes += nClusters + else: + self.nEqBoreholes = self.nBoreholes + self.clusters = range(self.nBoreholes) + # Overwrite boreholes with equivalent boreholes + self.borefield = _EquivalentBorefield.from_equivalent_boreholes( + [_EquivalentBorehole.from_boreholes( + [borehole + for borehole, cluster in zip(self.borefield, self.clusters) + if cluster==i]) + for i in range(self.nEqBoreholes)]) + self.wBoreholes = np.array([b.nBoreholes for b in self.borefield]) + # Find similar pairs of boreholes + self.borehole_to_self, self.borehole_to_borehole = \ + self._find_axial_borehole_pairs(self.borefield) + # Store unique distances in the bore field + self.dis = eqField.unique_distance(eqField, self.disTol)[0][1:] + + if self.boundary_condition == 'MIFT': + pipes = [self.network.p[self.clusters.index(i)] + for i in range(self.nEqBoreholes)] + self.network = _EquivalentNetwork( + self.borefield, + pipes, + nSegments=self.nSegments, + segment_ratios=self.segment_ratios) + + # Stop chrono + toc = perf_counter() + if self.disp: + print(f' {toc - tic:.3f} sec') + print(f'Calculations will be done using {self.nEqBoreholes} ' + f'equivalent boreholes') + + return self.nSegments * self.nEqBoreholes + + @property + def segment_lengths(self) -> np.ndarray: + """ + Return the length of all segments in the bore field. + + The segments lengths are used for the energy balance in the calculation + of the g-function. For equivalent boreholes, the length of segments + is multiplied by the number of boreholes in the group. + + Returns + ------- + H : array + Array of segment lengths (in m). + + """ + # Borehole lengths + H = np.array([seg.H * seg.nBoreholes + for borehole in self.borefield + for seg in borehole.segments( + self.nSegments, + segment_ratios=self.segment_ratios)], + dtype=self.dtype) + return H + + def _compare_boreholes( + self, borehole1: Borehole, borehole2: Borehole) -> bool: + """ + Compare two boreholes and checks if they have the same dimensions : + H, D, and r_b. + + Parameters + ---------- + borehole1 : Borehole object + First borehole. + borehole2 : Borehole object + Second borehole. + + Returns + ------- + similarity : bool + True if the two boreholes have the same dimensions. + + """ + # Compare lengths (H), buried depth (D) and radius (r_b) + if (abs((borehole1.H - borehole2.H)/borehole1.H) < self.tol and + abs((borehole1.r_b - borehole2.r_b)/borehole1.r_b) < self.tol and + abs((borehole1.D - borehole2.D)/(borehole1.D + 1e-30)) < self.tol): + similarity = True + else: + similarity = False + return similarity + + def _compare_real_pairs( + self, pair1: Tuple[Borehole, Borehole], + pair2: Tuple[Borehole, Borehole]) -> bool: + """ + Compare two pairs of boreholes or segments and return True if the two + pairs have the same FLS solution for real sources. + + Parameters + ---------- + pair1 : Tuple of Borehole objects + First pair of boreholes or segments. + pair2 : Tuple of Borehole objects + Second pair of boreholes or segments. + + Returns + ------- + similarity : bool + True if the two pairs have the same FLS solution. + + """ + deltaD1 = pair1[1].D - pair1[0].D + deltaD2 = pair2[1].D - pair2[0].D + + # Equality of lengths between pairs + cond_H = (abs((pair1[0].H - pair2[0].H)/pair1[0].H) < self.tol + and abs((pair1[1].H - pair2[1].H)/pair1[1].H) < self.tol) + # Equality of lengths in each pair + equal_H = abs((pair1[0].H - pair1[1].H)/pair1[0].H) < self.tol + # Equality of buried depths differences + cond_deltaD = abs(deltaD1 - deltaD2)/abs(deltaD1 + 1e-30) < self.tol + # Equality of buried depths differences if all boreholes have the same + # length + cond_deltaD_equal_H = abs((abs(deltaD1) - abs(deltaD2))/(abs(deltaD1) + 1e-30)) < self.tol + if cond_H and (cond_deltaD or (equal_H and cond_deltaD_equal_H)): + similarity = True + else: + similarity = False + return similarity + + def _compare_image_pairs( + self, pair1: Tuple[Borehole, Borehole], + pair2: Tuple[Borehole, Borehole]) -> bool: + """ + Compare two pairs of boreholes or segments and return True if the two + pairs have the same FLS solution for mirror sources. + + Parameters + ---------- + pair1 : Tuple of Borehole objects + First pair of boreholes or segments. + pair2 : Tuple of Borehole objects + Second pair of boreholes or segments. + + Returns + ------- + similarity : bool + True if the two pairs have the same FLS solution. + + """ + sumD1 = pair1[1].D + pair1[0].D + sumD2 = pair2[1].D + pair2[0].D + + # Equality of lengths between pairs + cond_H = (abs((pair1[0].H - pair2[0].H)/pair1[0].H) < self.tol + and abs((pair1[1].H - pair2[1].H)/pair1[1].H) < self.tol) + # Equality of buried depths sums + cond_sumD = abs((sumD1 - sumD2)/(sumD1 + 1e-30)) < self.tol + if cond_H and cond_sumD: + similarity = True + else: + similarity = False + return similarity + + def _compare_realandimage_pairs( + self, pair1: Tuple[Borehole, Borehole], + pair2: Tuple[Borehole, Borehole]) -> bool: + """ + Compare two pairs of boreholes or segments and return True if the two + pairs have the same FLS solution for both real and mirror sources. + + Parameters + ---------- + pair1 : Tuple of Borehole objects + First pair of boreholes or segments. + pair2 : Tuple of Borehole objects + Second pair of boreholes or segments. + + Returns + ------- + similarity : bool + True if the two pairs have the same FLS solution. + + """ + if (self._compare_real_pairs(pair1, pair2) + and self._compare_image_pairs(pair1, pair2)): + similarity = True + else: + similarity = False + return similarity + + def _find_axial_borehole_pairs( + self, borefield: _EquivalentBorefield) -> Tuple[ + List[List[int]], List[List[Tuple[int, int]]] + ]: + """ + Find axial (i.e. disregarding the radial distance) similarities between + borehole pairs to simplify the evaluation of the FLS solution. + + Parameters + ---------- + borefield : _EquivalentBorefield object + The equivalent bore field. + + Returns + ------- + borehole_to_self : list + Lists of borehole indexes for each unique set of borehole + dimensions (H, D, r_b) in the bore field. + borehole_to_borehole : list + Lists of tuples of borehole indexes for each unique pair of + boreholes that share the same (pairwise) dimensions (H, D). + + """ + # Compare for the full (real + image) FLS solution + compare_pairs = self._compare_realandimage_pairs + + nBoreholes = len(borefield) + borehole_to_self = [] + # Only check for similarities if there is more than one borehole + if nBoreholes > 1: + borehole_to_borehole = [] + for i, borehole_i in enumerate(borefield): + # Compare the borehole to all known unique sets of dimensions + for k, borehole_set in enumerate(borehole_to_self): + m = borehole_set[0] + # Add the borehole to the group if a similar borehole is + # found + if self._compare_boreholes(borehole_i, borefield[m]): + borehole_set.append(i) + break + else: + # If no similar boreholes are known, append the groups + borehole_to_self.append([i]) + # Note : The range is different from similarities since + # an equivalent borehole to itself includes borehole-to- + # borehole thermal interactions + for j, borehole_j in enumerate(borefield[i:], start=i): + pair0 = (borehole_i, borehole_j) # pair + pair1 = (borehole_j, borehole_i) # reciprocal pair + # Compare pairs of boreholes to known unique pairs + for pairs in borehole_to_borehole: + m, n = pairs[0] + pair_ref = (borefield[m], borefield[n]) + # Add the pair (or the reciprocal pair) to a group + # if a similar one is found + if compare_pairs(pair0, pair_ref): + pairs.append((i, j)) + break + elif compare_pairs(pair1, pair_ref): + pairs.append((j, i)) + break + # If no similar pairs are known, append the groups + else: + borehole_to_borehole.append([(i, j)]) + else: + # Outputs for a single borehole + borehole_to_self = [[0]] + borehole_to_borehole = [[(0, 0)]] + return borehole_to_self, borehole_to_borehole + + def _find_unique_boreholes( + self, borefield: Borefield) -> np.ndarray: + """ + Find unique sets of dimensions (h, D, r_b) in the bore field. + + Parameters + ---------- + borefield : Borefield object + The bore field. + + Returns + ------- + unique_boreholes : list + List of list of borehole indices that correspond to unique + borehole dimensions (H, D, r_b). + + """ + unique_boreholes = [] + for i, borehole_1 in enumerate(borefield): + for group in unique_boreholes: + borehole_2 = borefield[group[0]] + # Add the borehole to a group if similar dimensions are found + if self._compare_boreholes(borehole_1, borehole_2): + group.append(i) + break + else: + # If no similar boreholes are known, append the groups + unique_boreholes.append([i]) + + return np.array(unique_boreholes, dtype=int) + + def _find_unique_distances( + self, dis: float, indices: List[Tuple[int, int]]) -> Tuple[ + np.ndarray, np.ndarray + ]: + """ + Find the number of occurences of each unique distances between pairs + of boreholes. + + Parameters + ---------- + dis : array + Array of unique distances (in meters) in the bore field. + indices : list + List of tuples of borehole indices. + + Returns + ------- + dis : array + Array of unique distances (in meters) in the bore field. + wDis : array + Array of number of occurences of each unique distance for each + pair of equivalent boreholes in indices. + + """ + wDis = np.zeros((len(dis), len(indices)), dtype=int) + for k, pair in enumerate(indices): + i, j = pair + b1, b2 = self.borefield[i], self.borefield[j] + # Generate a flattened array of distances between boreholes i and j + if not i == j: + dis_ij = b1.distance(b2).flatten() + else: + # Remove the borehole radius from the distances + dis_ij = b1.distance(b2)[ + ~np.eye(b1.nBoreholes, dtype=bool)].flatten() + wDis_ij = np.zeros(len(dis), dtype=int) + # Get insert positions for the distances + iDis = np.searchsorted(dis, dis_ij, side='left') + # Find indexes where previous index is closer + prev_iDis_is_less = ((iDis == len(dis))|(np.fabs(dis_ij - dis[np.maximum(iDis-1, 0)]) < np.fabs(dis_ij - dis[np.minimum(iDis, len(dis)-1)]))) + iDis[prev_iDis_is_less] -= 1 + np.add.at(wDis_ij, iDis, 1) + wDis[:,k] = wDis_ij + + return dis.reshape((1, -1)), wDis + + def _map_axial_segment_pairs( + self, iBor: int, jBor: int, reaSource: bool = True, + imgSource: bool = True) -> Tuple[ + np.ndarray, np.ndarray, np.ndarray, np.ndarray, + np.ndarray, np.ndarray, np.ndarray + ]: + """ + Find axial (i.e. disregarding the radial distance) similarities between + segment pairs along two boreholes to simplify the evaluation of the + FLS solution. + + The returned H1, D1, H2, and D2 can be used to evaluate the segment-to- + segment response factors using scipy.integrate.quad_vec. + + Parameters + ---------- + iBor : int + Index of the first borehole. + jBor : int + Index of the second borehole. + + Returns + ------- + H1 : array + Length of the emitting segments. + D1 : array + Array of buried depths of the emitting segments. + H2 : float + Length of the receiving segments. + D2 : array + Array of buried depths of the receiving segments. + i_pair : array of int + Indices of the emitting segments along a borehole. + j_pair : array of int + Indices of the receiving segments along a borehole. + k_pair : array of int + Indices of unique segment pairs in the (H1, D1, H2, D2) dimensions + corresponding to all pairs in (i_pair, j_pair). + + """ + # Initialize local variables + borehole1 = self.borefield[iBor] + borehole2 = self.borefield[jBor] + assert reaSource or imgSource, \ + "At least one of reaSource and imgSource must be True." + if reaSource and imgSource: + # Find segment pairs for the full (real + image) FLS solution + compare_pairs = self._compare_realandimage_pairs + elif reaSource: + # Find segment pairs for the real FLS solution + compare_pairs = self._compare_real_pairs + elif imgSource: + # Find segment pairs for the image FLS solution + compare_pairs = self._compare_image_pairs + # Dive both boreholes into segments + segments1 = borehole1.segments( + self.nSegments, segment_ratios=self.segment_ratios) + segments2 = borehole2.segments( + self.nSegments, segment_ratios=self.segment_ratios) + # Prepare lists of segment lengths + H1 = [] + H2 = [] + # Prepare lists of segment buried depths + D1 = [] + D2 = [] + # All possible pairs (i, j) of indices between segments + i_pair = np.repeat(np.arange(self.nSegments, dtype=int), + self.nSegments) + j_pair = np.tile(np.arange(self.nSegments, dtype=int), + self.nSegments) + # Empty list of indices for unique pairs + k_pair = np.empty(self.nSegments * self.nSegments, + dtype=int) + unique_pairs = [] + nPairs = 0 + + p = 0 + for i, segment_i in enumerate(segments1): + for j, segment_j in enumerate(segments2): + pair = (segment_i, segment_j) + # Compare the segment pairs to all known unique pairs + for k, pair_k in enumerate(unique_pairs): + m, n = pair_k[0], pair_k[1] + pair_ref = (segments1[m], segments2[n]) + # Stop if a similar pair is found and assign the index + if compare_pairs(pair, pair_ref): + k_pair[p] = k + break + # If no similar pair is found : add a new pair, increment the + # number of unique pairs, and extract the associated buried + # depths + else: + k_pair[p] = nPairs + H1.append(segment_i.H) + H2.append(segment_j.H) + D1.append(segment_i.D) + D2.append(segment_j.D) + unique_pairs.append((i, j)) + nPairs += 1 + p += 1 + return np.array(H1), np.array(D1), np.array(H2), np.array(D2), i_pair, j_pair, k_pair + + def _check_solver_specific_inputs(self): + """ + This method ensures that solver specific inputs to the Solver object + are what is expected. + + """ + assert type(self.disTol) is float and self.disTol > 0., \ + "The distance tolerance 'disTol' should be a positive float." + assert type(self.tol) is float and self.tol > 0., \ + "The relative tolerance 'tol' should be a positive float." + assert type(self.kClusters) is int and self.kClusters >= 0, \ + "The precision increment 'kClusters' should be a positive int." + assert (isinstance(self.nSegments, (int, np.integer)) + and self.nSegments >= 1), \ + "The argument for number of segments `nSegments` should be " \ + "of type int." + assert (self.segment_ratios is None + or callable(self.segment_ratios) + or (isinstance(self.segment_ratios, np.ndarray) + and len(self.segment_ratios) == self.nSegments)), \ + "Solver 'equivalent' can only handle identical segment_ratios " \ + "for all boreholes. None or a single array of size " \ + "(nSegments,) must be provided." + assert not np.any([b.is_tilted() for b in self.borefield]), \ + "Solver 'equivalent' can only handle vertical boreholes." + if self.boundary_condition == 'MIFT': + assert np.all(np.array(self.network.c, dtype=int) == -1), \ + "Solver 'equivalent' is only valid for parallel-connected " \ + "boreholes." + assert (self.m_flow_borehole is None + or (self.m_flow_borehole.ndim==1 and np.allclose(self.m_flow_borehole, self.m_flow_borehole[0])) + or (self.m_flow_borehole.ndim==2 and np.all([np.allclose(self.m_flow_borehole[:, i], self.m_flow_borehole[0, i]) for i in range(self.nBoreholes)]))), \ + "Mass flow rates into the network must be equal for all " \ + "boreholes." + # Use the total network mass flow rate. + if (type(self.network.m_flow_network) is np.ndarray and \ + len(self.network.m_flow_network)==len(self.network.b)): + self.network.m_flow_network = \ + self.network.m_flow_network[0]*len(self.network.b) + # Verify that all boreholes have the same piping configuration + # This is best done by comparing the matrix of thermal resistances. + assert np.all( + [np.allclose(self.network.p[0]._Rd, pipe._Rd) + for pipe in self.network.p]), \ + "All boreholes must have the same piping configuration." + return diff --git a/pygfunction/solvers/similarities.py b/pygfunction/solvers/similarities.py new file mode 100644 index 00000000..70ffc08e --- /dev/null +++ b/pygfunction/solvers/similarities.py @@ -0,0 +1,877 @@ +# -*- coding: utf-8 -*- +from collections.abc import Callable +from itertools import combinations_with_replacement +from time import perf_counter +from typing import Tuple, List, Union + +import numpy as np +import numpy.typing as npt +from scipy.interpolate import interp1d as interp1d + +from ..boreholes import Borehole +from ..borefield import Borefield +from .base_solver import _BaseSolver +from ..heat_transfer import finite_line_source, finite_line_source_vertical + + +class Similarities(_BaseSolver): + """ + Similarities solver for the evaluation of the g-function. + + This solver superimposes the finite line source (FLS) solution to + estimate the g-function of a geothermal bore field. Each borehole is + modeled as a series of finite line source segments, as proposed in + [#Similarities-CimBer2014]_. The number of evaluations of the FLS solution + is decreased by identifying similar pairs of boreholes, for which the same + FLS value can be applied [#Similarities-Cimmin2018]_. + + Parameters + ---------- + borefield : Borefield object + The bore field. + network : network object + The network. + time : float or array + Values of time (in seconds) for which the g-function is evaluated. + boundary_condition : str + Boundary condition for the evaluation of the g-function. Should be one + of + + - 'UHTR' : + **Uniform heat transfer rate**. This is corresponds to boundary + condition *BC-I* as defined by Cimmino and Bernier (2014) + [#Similarities-CimBer2014]_. + - 'UBWT' : + **Uniform borehole wall temperature**. This is corresponds to + boundary condition *BC-III* as defined by Cimmino and Bernier + (2014) [#Similarities-CimBer2014]_. + - 'MIFT' : + **Mixed inlet fluid temperatures**. This boundary condition was + introduced by Cimmino (2015) [#Similarities-Cimmin2015]_ for + parallel-connected boreholes and extended to mixed + configurations by Cimmino (2019) [#Similarities-Cimmin2019]_. + + nSegments : int or list, optional + Number of line segments used per borehole, or list of number of + line segments used for each borehole. + Default is 8. + segment_ratios : array, list of arrays, or callable, optional + Ratio of the borehole length represented by each segment. The + sum of ratios must be equal to 1. The shape of the array is of + (nSegments,) or list of (nSegments[i],). If segment_ratios==None, + segments of equal lengths are considered. If a callable is provided, it + must return an array of size (nSegments,) when provided with nSegments + (of type int) as an argument, or an array of size (nSegments[i],) when + provided with an element of nSegments (of type list). + Default is :func:`utilities.segment_ratios`. + m_flow_borehole : (nInlets,) array or (nMassFlow, nInlets,) array, optional + Fluid mass flow rate into each circuit of the network. If a + (nMassFlow, nInlets,) array is supplied, the + (nMassFlow, nMassFlow,) variable mass flow rate g-functions + will be evaluated using the method of Cimmino (2024) + [#gFunction-CimBer2024]_. Only required for the 'MIFT' boundary + condition. Only one of 'm_flow_borehole' and 'm_flow_network' can be + provided. + Default is None. + m_flow_network : float or (nMassFlow,) array, optional + Fluid mass flow rate into the network of boreholes. If an array + is supplied, the (nMassFlow, nMassFlow,) variable mass flow + rate g-functions will be evaluated using the method of Cimmino + (2024) [#gFunction-CimBer2024]_. Only required for the 'MIFT' boundary + condition. Only one of 'm_flow_borehole' and 'm_flow_network' can be + provided. + Default is None. + cp_f : float, optional + Fluid specific isobaric heat capacity (in J/kg.degC). Only required + for the 'MIFT' boundary condition. + Default is None. + approximate_FLS : bool, optional + Set to true to use the approximation of the FLS solution of Cimmino + (2021) [#Similarities-Cimmin2021]_. This approximation does not require + the numerical evaluation of any integral. + Default is False. + nFLS : int, optional + Number of terms in the approximation of the FLS solution. This + parameter is unused if `approximate_FLS` is set to False. + Default is 10. Maximum is 25. + mQuad : int, optional + Number of Gauss-Legendre sample points for the integral over :math:`u` + in the inclined FLS solution. + Default is 11. + linear_threshold : float, optional + Threshold time (in seconds) under which the g-function is + linearized. The g-function value is then interpolated between 0 + and its value at the threshold. If linear_threshold==None, the + g-function is linearized for times + `t < r_b**2 / (25 * self.alpha)`. + Default is None. + disp : bool, optional + Set to true to print progression messages. + Default is False. + profiles : bool, optional + Set to true to keep in memory the temperatures and heat extraction + rates. + Default is False. + kind : string, optional + Interpolation method used for segment-to-segment thermal response + factors. See documentation for scipy.interpolate.interp1d. + Default is 'linear'. + dtype : numpy dtype, optional + numpy data type used for matrices and vectors. Should be one of + numpy.single or numpy.double. + Default is numpy.double. + disTol : float, optional + Relative tolerance on radial distance. Two distances + (d1, d2) between two pairs of boreholes are considered equal if the + difference between the two distances (abs(d1-d2)) is below tolerance. + Default is 0.01. + tol : float, optional + Relative tolerance on length and depth. Two lengths H1, H2 + (or depths D1, D2) are considered equal if abs(H1 - H2)/H2 < tol. + Default is 1.0e-6. + + References + ---------- + .. [#Similarities-CimBer2014] Cimmino, M., & Bernier, M. (2014). A + semi-analytical method to generate g-functions for geothermal bore + fields. International Journal of Heat and Mass Transfer, 70, 641-650. + .. [#Similarities-Cimmin2015] Cimmino, M. (2015). The effects of borehole + thermal resistances and fluid flow rate on the g-functions of geothermal + bore fields. International Journal of Heat and Mass Transfer, 91, + 1119-1127. + .. [#Similarities-Cimmin2018] Cimmino, M. (2018). Fast calculation of the + g-functions of geothermal borehole fields using similarities in the + evaluation of the finite line source solution. Journal of Building + Performance Simulation, 11 (6), 655-668. + .. [#Similarities-Cimmin2019] Cimmino, M. (2019). Semi-analytical method + for g-function calculation of bore fields with series- and + parallel-connected boreholes. Science and Technology for the Built + Environment, 25 (8), 1007-1022. + .. [#Similarities-Cimmin2021] Cimmino, M. (2021). An approximation of the + finite line source solution to model thermal interactions between + geothermal boreholes. International Communications in Heat and Mass + Transfer, 127, 105496. + + """ + def initialize( + self, disTol: float = 0.01, tol: float = 1e-6, **kwargs) -> int: + """ + Split boreholes into segments and identify similarities in the + borefield. + + Returns + ------- + nSources : int + Number of finite line heat sources in the borefield used to + initialize the matrix of segment-to-segment thermal response + factors (of size: nSources x nSources). + + """ + self.disTol = disTol + self.tol = tol + # Check the validity of inputs + self._check_solver_specific_inputs() + # Split boreholes into segments + self.segments = self.borefield.segments(self.nSegments, self.segment_ratios) + self._i1Segments = np.cumsum( + np.broadcast_to(self.nSegments, len(self.borefield)), + dtype=int) + self._i0Segments = np.concatenate( + ([0], self._i1Segments[:-1]), + dtype=int) + return len(self.segments) + + def thermal_response_factors( + self, time: npt.ArrayLike, alpha: float, kind: str = 'linear' + ) -> interp1d: + """ + Evaluate the segment-to-segment thermal response factors for all pairs + of segments in the borefield at all time steps using the finite line + source solution. + + This method returns a scipy.interpolate.interp1d object of the matrix + of thermal response factors, containing a copy of the matrix accessible + by h_ij.y[:nSources,:nSources,:nt+1]. The first index along the + third axis corresponds to time t=0. The interp1d object can be used to + obtain thermal response factors at any intermediat time by + h_ij(t)[:nSources,:nSources]. + + Attributes + ---------- + time : float or array + Values of time (in seconds) for which the g-function is evaluated. + alpha : float + Soil thermal diffusivity (in m2/s). + kind : str, optional + Interpolation method used for segment-to-segment thermal response + factors. See documentation for scipy.interpolate.interp1d. + Default is 'linear'. + + Returns + ------- + h_ij : interp1d object + interp1d object (scipy.interpolate) of the matrix of + segment-to-segment thermal response factors. + + """ + if self.disp: + print('Calculating segment to segment response factors ...', + end='') + # Number of time values + nt = len(np.atleast_1d(time)) + # Initialize chrono + tic = perf_counter() + # Initialize segment-to-segment response factors + h_ij = np.zeros((self.nSources, self.nSources, nt+1), dtype=self.dtype) + + # Find unique boreholes geometries + unique_boreholes, unique_borehole_indices, unique_nSegments, \ + unique_segment_ratios = \ + self._find_unique_borehole_geometries( + self.borefield, nSegments=self.nSegments, + segment_ratios=self.segment_ratios, rtol=self.tol) + # Split vertical and inclined boreholes + vertical_boreholes, vertical_nSegments, vertical_segment_ratios, \ + vertical_indices, inclined_boreholes, inclined_nSegments, \ + inclined_segment_ratios, inclined_indices = \ + self._split_vertical_and_inclined_boreholes( + unique_boreholes, nSegments=unique_nSegments, + segment_ratios=unique_segment_ratios, + indices=unique_borehole_indices) + + # --------------------------------------------------------------------- + # Segment-to-segment thermal response factors for same-borehole thermal + # interactions (vertical boreholes) + # --------------------------------------------------------------------- + if len(vertical_boreholes) > 0: + segments_j, segments_i = \ + self._segment_pairs_same_borehole( + vertical_boreholes, vertical_nSegments, + segment_ratios=vertical_segment_ratios) + h = finite_line_source( + time, alpha, segments_j, segments_i, outer=False) + + # Local indices of segment pairs + nVerticalBoreholes = len(vertical_boreholes) + if isinstance(vertical_nSegments, (int, np.integer)): + nSegments = np.broadcast_to(vertical_nSegments, nVerticalBoreholes) + else: + nSegments = vertical_nSegments + local_segment_indices = \ + [np.triu_indices(nSegments[i]) for i in range(nVerticalBoreholes)] + local_segment_indices_i = [indices[0] for indices in local_segment_indices] + local_segment_indices_j = [indices[1] for indices in local_segment_indices] + # Global indices of the first segments of boreholes + m0 = [self._i0Segments[indices] for indices in vertical_indices] + # Indices of the matrix h + m = np.concatenate([np.add.outer(m0_i, indices_i).flatten() for m0_i, indices_i in zip(m0, local_segment_indices_i)]) + n = np.concatenate([np.add.outer(m0_j, indices_j).flatten() for m0_j, indices_j in zip(m0, local_segment_indices_j)]) + nSegmentPairs = np.array([len(indices) for indices in local_segment_indices_i]) + k1 = np.cumsum(nSegmentPairs) + k0 = np.concatenate(([0], k1[:-1])) + k = np.concatenate([np.tile(np.arange(l0, l1), len(indices)) for l0, l1, indices in zip(k0, k1, vertical_indices)]) + h_ij[m, n, 1:] = h[k, :] + h_ij[n, m, 1:] = (h.T * segments_i.H / segments_j.H).T[k, :] + + # --------------------------------------------------------------------- + # Segment-to-segment thermal response factors for same-borehole thermal + # interactions (inclined boreholes) + # --------------------------------------------------------------------- + if len(inclined_boreholes) > 0: + segments_j, segments_i = \ + self._segment_pairs_same_borehole( + inclined_boreholes, inclined_nSegments, + segment_ratios=inclined_segment_ratios) + h = finite_line_source( + time, alpha, segments_j, segments_i, outer=False) + + # Local indices of segment pairs + nInclinedBoreholes = len(inclined_boreholes) + if isinstance(inclined_nSegments, (int, np.integer)): + nSegments = np.broadcast_to(inclined_nSegments, nInclinedBoreholes) + else: + nSegments = inclined_nSegments + local_segment_indices = \ + [np.triu_indices(nSegments[i]) for i in range(nInclinedBoreholes)] + local_segment_indices_i = [indices[0] for indices in local_segment_indices] + local_segment_indices_j = [indices[1] for indices in local_segment_indices] + # Global indices of the first segments of boreholes + m0 = [self._i0Segments[indices] for indices in inclined_indices] + # Indices of the matrix h + m = np.concatenate([np.add.outer(m0_i, indices_i).flatten() for m0_i, indices_i in zip(m0, local_segment_indices_i)]) + n = np.concatenate([np.add.outer(m0_j, indices_j).flatten() for m0_j, indices_j in zip(m0, local_segment_indices_j)]) + nSegmentPairs = np.array([len(indices) for indices in local_segment_indices_i]) + k1 = np.cumsum(nSegmentPairs) + k0 = np.concatenate(([0], k1[:-1])) + k = np.concatenate([np.tile(np.arange(l0, l1), len(indices)) for l0, l1, indices in zip(k0, k1, inclined_indices)]) + h_ij[m, n, 1:] = h[k, :] + h_ij[n, m, 1:] = (h.T * segments_i.H / segments_j.H).T[k, :] + + # --------------------------------------------------------------------- + # Segment-to-segment thermal response factors for borehole-to-borehole + # thermal interactions + # --------------------------------------------------------------------- + for (i, j) in combinations_with_replacement( + range(len(unique_boreholes)), 2): + unique_borehole_indices_j = unique_borehole_indices[j] + unique_borehole_indices_i = unique_borehole_indices[i] + unique_borehole_j = unique_boreholes[j] + unique_borehole_i = unique_boreholes[i] + borefield_j = self.borefield[unique_borehole_indices_j] + borefield_i = self.borefield[unique_borehole_indices_i] + nBoreholes_j = len(borefield_j) + nBoreholes_i = len(borefield_i) + + if isinstance(unique_nSegments, (int, np.integer)): + nSegments_j = unique_nSegments + nSegments_i = unique_nSegments + else: + nSegments_j = unique_nSegments[j] + nSegments_i = unique_nSegments[i] + + if not isinstance(unique_segment_ratios, list): + segment_ratios_j = unique_segment_ratios + segment_ratios_i = unique_segment_ratios + else: + segment_ratios_j = unique_segment_ratios[j] + segment_ratios_i = unique_segment_ratios[i] + + segments_j, segments_i = \ + self._segment_pairs( + unique_borehole_j, unique_borehole_i, + nSegments_j, nSegments_i, + segment_ratios_j=segment_ratios_j, + segment_ratios_i=segment_ratios_i, + to_self=i==j) + if unique_borehole_i.is_vertical and unique_borehole_j.is_vertical: + # ------------------------------------------------------------- + # Vertical boreholes + # ------------------------------------------------------------- + unique_distances, distance_indices = \ + self._find_unique_distances_vertical( + borefield_j, borefield_i, rtol=self.disTol, + to_self=i==j) + h = finite_line_source_vertical( + time, alpha, segments_j, segments_i, distances=unique_distances, outer=i!=j) + # Broadcast values to h_ij matrix + if i == j and nBoreholes_i > 1: + # TODO : Diagonal elements are repeated + # Local indices of segment pairs + local_segment_indices_i, local_segment_indices_j = \ + np.triu_indices(nSegments_i) + # Local indices of borehole pairs + local_borehole_indices_i, local_borehole_indices_j = \ + np.triu_indices(nBoreholes_i, k=1) + # Global indices of the first segments of boreholes + m0 = self._i0Segments[unique_borehole_indices_i] + n0 = m0 + + # Upper triangle indices of thermal response factors of + # segments (n) of borehole (j) onto segments (m) of + # borehole (i) + m_u = np.add.outer( + local_segment_indices_i, + m0)[..., local_borehole_indices_i] + n_u = np.add.outer( + local_segment_indices_j, + n0)[..., local_borehole_indices_j] + k_u = distance_indices + # Upper triangle indices of thermal response factors of + # segments (n) of borehole (i) onto segments (m) of + # borehole (j) + m_l = np.add.outer( + local_segment_indices_i, + m0)[..., local_borehole_indices_j] + n_l = np.add.outer( + local_segment_indices_j, + n0)[..., local_borehole_indices_i] + k_l = distance_indices + # Concatenate indices + m = np.concatenate((m_u, m_l), axis=1) + n = np.concatenate((n_u, n_l), axis=1) + k = np.concatenate((k_u, k_l), axis=0) + + # Assign h_ij matrix elements + h_ij[m, n, 1:] = h[:, k, :] + h_ij[n, m, 1:] = (h.T * segments_i.H / segments_j.H).T[:, k, :] + elif i != j: + # Local indices of segment pairs + local_segment_indices_i, local_segment_indices_j = \ + np.meshgrid( + np.arange(nSegments_i, dtype=int), + np.arange(nSegments_j, dtype=int), + indexing='ij') + # Local indices of borehole pairs + local_borehole_indices_i, local_borehole_indices_j = \ + np.meshgrid( + np.arange(nBoreholes_i, dtype=int), + np.arange(nBoreholes_j, dtype=int), + indexing='ij') + local_borehole_indices_i = local_borehole_indices_i.flatten() + local_borehole_indices_j = local_borehole_indices_j.flatten() + # Global indices of the first segments of boreholes + m0 = self._i0Segments[unique_borehole_indices_i] + n0 = self._i0Segments[unique_borehole_indices_j] + + m = np.add.outer( + local_segment_indices_i, + m0)[..., local_borehole_indices_i] + n = np.add.outer( + local_segment_indices_j, + n0)[..., local_borehole_indices_j] + k = distance_indices.flatten() + + # Assign h_ij matrix elements + h_ij[m, n, 1:] = h[:, :, k, :] + h_ij[n, m, 1:] = (h.T * segments_i.H / segments_j.H[..., np.newaxis]).T[:, :, k, :] + # Interp1d object for thermal response factors + h_ij = interp1d( + np.hstack((0., time)), h_ij, + kind=kind, copy=True, assume_sorted=True, axis=2) + + toc = perf_counter() + if self.disp: + print(f' {toc - tic:.3f} sec') + + return h_ij + + @classmethod + def _find_unique_distances_vertical( + cls, borefield_j: Borefield, borefield_i: Borefield, + rtol: float = 0.01, to_self: Union[None, bool] = None + ) -> Tuple[np.ndarray, np.ndarray]: + """ + Finds unique distances (within tolerance) between boreholes of + borefield (j) and boreholes of borefield (i). + + Parameters + ---------- + borefield_j : Borefield object + Borefield object of the boreholes extracting heat. + borefield_i : Borefield object + Borefield object of the boreholes for which borehole wall + temperatures are to be evaluated. + rtol : float, optional + Relative tolerance on the distances for them to be considered + equal. + Default is 0.01. + to_self : bool, optional + True if borefield_j and borefield_i are the same borefield. In this + case, a condensed array of distances between boreholes is + evaluated, corresponding to the upper triangular (non-diagonal) + elements of the borehole-to-borehole distance matrix. If false, + the full borehole-to-borehole distance matrix is evaluated. If + None, this parameter is evaluated by comparison between borefield_j + and borefield_i. + Default is None. + + Returns + ------- + distances : (nDistances,) array + Array of unique distances between boreholes (in meters). + indices : array + Indices of unique distances corresponding to each borehole pair. If + to_self==True, a 1d array is returned, corresponding to the + condensed (non-diagonal) array of borehole pairs. If + to_self==False, a (nBoreholes_i, nBoreholes_j,) array is returned. + + """ + if to_self is None: + to_self = borefield_j == borefield_i + + # Find all distances between the boreholes, sorted and flattened + if to_self: + distances = borefield_j.distance_to_self(outer=False) + indices = np.zeros( + int(len(borefield_j) * (len(borefield_j) - 1) / 2), + dtype=int) + else: + distances = borefield_j.distance(borefield_i, outer=True).flatten() + indices_i, indices_j = np.meshgrid( + np.arange(len(borefield_i), dtype=int), + np.arange(len(borefield_j), dtype=int), + indexing='ij') + indices_i = indices_i.flatten() + indices_j = indices_j.flatten() + indices = np.zeros( + (len(borefield_i), len(borefield_j)), + dtype=int) + + index_array = np.argsort(distances) + sorted_distances = distances[index_array] + nDis = len(distances) + labels = np.zeros(nDis, dtype=int) + + # Find unique distances within tolerance + unique_distances = [] + n = 0 + # Start the search at the first distance + j0 = 0 + j1 = 1 + while j0 < nDis and j1 > 0: + # Find the index of the first distance for which the distance is + # outside tolerance to the current distance + j1 = np.searchsorted( + sorted_distances, + (1 + rtol) * sorted_distances[j0]) + # Add the average of the distances within tolerance to the + # list of unique distances and store the number of distances + unique_distances.append(np.mean(sorted_distances[j0:j1])) + if to_self: + indices[index_array[j0:j1]] = n + else: + indices[indices_i[index_array[j0:j1]], indices_j[index_array[j0:j1]]] = n + labels[j0:j1] = n + n = n + 1 + j0 = j1 + return np.array(unique_distances), indices + + @classmethod + def _find_unique_borehole_geometries( + cls, + borefield: Borefield, + nSegments: Union[None, npt.ArrayLike] = None, + segment_ratios: Union[None, Callable[[int], npt.ArrayLike], List[np.ndarray], np.ndarray] = None, + rtol: float = 1e-6 + ) -> Tuple[ + Borefield, + List[np.ndarray], + Union[None, int, np.ndarray], + Union[None, Callable, List[np.ndarray], np.ndarray]]: + """ + Finds unique borehole geometries (H, D, r_b, tilt). + + Parameters + ---------- + borefield : Borefield object + The borefield. + nSegments : int or (nBoreholes,) array of int, optional + Number of segments per borehole. If nSegments is None, the number + of segments is not used to compare boreholes. + Default is None. + segment_ratios : (nSegments,) array, (nBoreholes,) list of (nSegments_i,) arrays or callable, optional + Segment ratios for the discretization along boreholes. If + segment_ratios is None, the segment ratios are not used to + compare boreholes. + Default is None. + rtol : float, optional + Relative tolerance on geometric parameters under which they are + considered equal. + Default is 1e-6. + + Returns + ------- + unique_boreholes : Borefield object + Borefield object of unique borehole geometries, all located at + the origin (x=0, y=0) and with orientation=0. + unique_borehole_indices : list of arrays of int + Indices of boreholesi in the borefied corresponding to each + unique borehole geometry. + unique_nSegments : None, int or (nUniqueBoreholes,) array of int + Number of segments along each unique borehole geometry. None is + returned if nSegments is None. + unique_segment_ratios : (nSegments,) array, (nUniqueBoreholes,) list of (nSegments_i,) arrays or callable + Segment ratios for the discretization along unique borehole + geometries. None is returned if segment_ratios is None. + + """ + # Convert nSegments to array if list + if isinstance(nSegments, list): + np.asarray(nSegments, dtype=int) + # All remaining boreholes in borefield + remaining_borehole_indices = np.arange( + len(borefield), + dtype=int) + remaining_boreholes = borefield[:] + # Fin unique borehole geometries + unique_borehole_indices = [] + n = 0 + while len(remaining_borehole_indices) > 0: + # Compare all remaining boreholes to the first remaining borehole + reference_borehole = remaining_boreholes[0] + reference_borehole_index = remaining_borehole_indices[0] + # Geometric parameters + similar_boreholes = np.all( + np.stack( + [np.abs(remaining_boreholes.H - reference_borehole.H) < rtol * reference_borehole.H, + np.abs(remaining_boreholes.D - reference_borehole.D) < rtol * reference_borehole.D, + np.abs(remaining_boreholes.r_b - reference_borehole.r_b) < rtol * reference_borehole.r_b, + np.abs(remaining_boreholes.tilt - reference_borehole.tilt) < rtol * reference_borehole.tilt + 1e-12 + ], + axis=0), + axis=0 + ) + # Also compare nSegments if it was provided as a list or an array + if isinstance(nSegments, np.ndarray): + similar_boreholes = np.logical_and( + similar_boreholes, + np.equal( + nSegments[remaining_borehole_indices], + nSegments[reference_borehole_index]) + ) + # Also compare segment_ratios if it was provided as a list + if isinstance(segment_ratios, list): + similar_boreholes = np.logical_and( + similar_boreholes, + [len(segment_ratios[i]) == len(segment_ratios[reference_borehole_index]) + and np.allclose( + segment_ratios[i], + segment_ratios[reference_borehole_index], + rtol=rtol + ) + for i in remaining_borehole_indices] + ) + # Create a unique borehole for all found similar boreholes + unique_borehole_indices.append(remaining_borehole_indices[similar_boreholes]) + # Remove them from the remaining boreholes + remaining_borehole_indices = remaining_borehole_indices[~similar_boreholes] + remaining_boreholes = remaining_boreholes[~similar_boreholes] + n = n + 1 + # Create a Borefield object from the unqiue borehole geometries + m = np.array( + [indices[0] for indices in unique_borehole_indices], + dtype=int) + unique_boreholes = Borefield( + borefield.H[m], borefield.D[m], borefield.r_b[m], 0., 0., tilt=borefield.tilt[m]) + # Only return an array of nSegments if a list or array was provided + if isinstance(nSegments, np.ndarray): + unique_nSegments = nSegments[m] + else: + unique_nSegments = nSegments + # Only return a list of segment_ratios if a list was provided + if isinstance(segment_ratios, list): + unique_segment_ratios = [segment_ratios[n] for n in m] + else: + unique_segment_ratios = segment_ratios + return unique_boreholes, unique_borehole_indices, unique_nSegments, unique_segment_ratios + + @classmethod + def _segment_pairs_same_borehole( + cls, + borefield: Borefield, + nSegments: npt.ArrayLike, + segment_ratios: Union[None, Callable[[int], npt.ArrayLike], List[np.ndarray], np.ndarray] = None + ) -> Tuple[Borefield, Borefield]: + """ + Returns condensed borefields for all non-repeated pairs of segments + along boreholes of a borefield and themselves. + + Parameters + ---------- + borefield : Borefield object + The borefield. + nSegments : int or (nBoreholes,) array of int + Unmber of segments per borehole. + segment_ratios : array, list of arrays, or callable, optional + Ratio of the borehole length represented by each segment. The + sum of ratios must be equal to 1. The shape of the array is of + (nSegments,) or list of (nSegments[i],). If segment_ratios==None, + segments of equal lengths are considered. If a callable is + provided, it must return an array of size (nSegments,) when + provided with nSegments (of type int) as an argument, or an array + of size (nSegments[i],) when provided with an element of nSegments + (of type list). + Default is None. + + Returns + ------- + segments_j : Borefield object + Borefield object of segments extracting heat. + segments_i : Borefield object + Borefield object of segments where the temperature is evaluated. + + """ + # Segment the borefield + segments = borefield.segments(nSegments, segment_ratios=segment_ratios) + # Indices of list ranges of first and last segment along boreholes + n1 = np.cumsum( + np.broadcast_to(nSegments, len(borefield)), + dtype=int) + n0 = np.concatenate( + ([0], n1[:-1]), + dtype=int) + # Condensed arrays of indices of segment pairs + i = [np.arange(m0, m1)[np.triu_indices(m1 - m0)[0]] for m0, m1 in zip(n0, n1)] + j = [np.arange(m0, m1)[np.triu_indices(m1 - m0)[1]] for m0, m1 in zip(n0, n1)] + indices_j = np.concatenate(j) + indices_i = np.concatenate(i) + # Expand segments into Borefield objects of segment pairs + segments_j = segments[indices_j] + segments_i = segments[indices_i] + return segments_j, segments_i + + @classmethod + def _segment_pairs( + cls, + borehole_j: Borehole, + borehole_i: Borehole, + nSegments_j: int, + nSegments_i: int, + segment_ratios_j: Union[None, Callable[[int], npt.ArrayLike], np.ndarray] = None, + segment_ratios_i: Union[None, Callable[[int], npt.ArrayLike], np.ndarray] = None, + to_self: bool = False) -> Tuple[Borefield, Borefield]: + """ + Returns borefields of segments for the evaluation of segment-to-segment + thermal response factors. + + Parameters + ---------- + borehole_j : Borehole object + The borehole extracting heat. + borehole_i : Borehole object + The borehole where temperatures are evaluated. + nSegments_j : int + Number of segments along borehole_j. + nSegments_i : int + Number of segments along borehole_i. + segment_ratios_j : array, list of arrays, or callable, optional + Ratio of the borehole length represented by each segment of + borehole_j. The sum of ratios must be equal to 1. The shape of the + array is of (nSegments_j,) or list of (nSegments[j],). If + segment_ratios is None, segments of equal lengths are considered. + If a callable is provided, it must return an array of size + (nSegments,) when provided with nSegments (of type int) as an + argument, or an array of size (nSegments[j],) when provided with an + element of nSegments (of type list). + Default is None. + segment_ratios_i : array, list of arrays, or callable, optional + Ratio of the borehole length represented by each segment of + borehole_i. + to_self : bool, optional + True if segment pairs are created for the interaction between a + borehole and itself, in which case the method returns condensed + borefields for all non-repeated pairs of segments along the + borehole and itself. If False, the returned Borefield objects + are of lengths (nSegment_j,) and (nSegments_i,). + Default is False. + + Returns + ------- + segments_j: Borefield object + Borefield object of segments extracting heat. + segments_i: Borefield object + Borefield object of segments where the temperature is evaluated. + + """ + # Segment boreholes + segments_j = Borefield.from_boreholes( + borehole_j.segments( + nSegments_j, + segment_ratios=segment_ratios_j)) + segments_i = Borefield.from_boreholes( + borehole_i.segments( + nSegments_i, + segment_ratios=segment_ratios_i)) + + # Create condensed Borefield objects for non-repeated segment pairs + if to_self: + i, j = np.triu_indices(nSegments_j, k=0) + segments_j = segments_j[j] + segments_i = segments_j[i] + return segments_j, segments_i + + @classmethod + def _split_vertical_and_inclined_boreholes( + cls, + borefield: Borefield, + nSegments: Union[None, npt.ArrayLike] = None, + segment_ratios: Union[None, Callable[[int], npt.ArrayLike], List[np.ndarray], np.ndarray] = None, + indices: Union[None, List[np.ndarray]] = None) -> Tuple[ + Borefield, + Union[None, int, np.ndarray], + Union[None, Callable, List[np.ndarray], np.ndarray], + List[np.ndarray], + Union[None, int, np.ndarray], + Union[None, Callable, List[np.ndarray], np.ndarray], + List[np.ndarray]]: + """ + Splits a borefield into a borefield of vertical boreholes and a + borefield of inclined boreholes. + + Parameters + ---------- + borefield : Borefield object + The borefield. + nSegments : int or (nBoreholes,) array of int, optional + Number of segments along boreholes. If nSegments is None, + None is returned for vertical_nSegments and inclined_nSegments. + The default is None. + segment_ratios : array, list of arrays, or callable, optional + Ratio of the borehole length represented by each segment of + the boreholes. The sum of ratios must be equal to 1. The shape of + the array is of (nSegments,) or list of (nSegments[i],). If + segment_ratios is None, segments of equal lengths are considered. + If a callable is provided, it must return an array of size + (nSegments,) when provided with nSegments (of type int) as an + argument, or an array of size (nSegments[i],) when provided with an + element of nSegments (of type list). + Default is None. + indices : (nBoreholes,) list of arrays of int, optional + Arrays of indices corresponding to each borehole in Borefield. If + indices is None, None is returned for vertical_indices and + inclined_indices. + Default is None. + + Returns + ------- + vertical_boreholes : Borefield object + Vertical boreholes in the borefield. + vertical_nSegments : int or array of int + Number of segments per vertical borehole. + vertical_segment_ratios : (vertical_nSegments,) array, (nVerticalBoreholes,) list of (vertical_nSegments_i,) arrays or callable + Segment ratios for the discretization along vertical boreholes + geometries. None is returned if segment_ratios is None. + vertical_indices : (nVerticalBoreholes,) list of arrays of int + Indices of the vertical boreholes. + inclined_boreholes : Borefield object + Inclined boreholes in the borefield. + inclined_nSegments : int or array of int + Number of segments per inclined borehole. + inclined_segment_ratios : (inclined_nSegments,) array, (nInclinedBoreholes,) list of (inclined_nSegments_i,) arrays or callable + Segment ratios for the discretization along inclined boreholes + geometries. None is returned if segment_ratios is None. + inclined_indices : (nInclinedBoreholes,) list of arrays of int + Indices of the inclined boreholes. + + """ + # Find vertical and inclined boreholes + vertical_indices = np.arange( + len(borefield), dtype=int)[borefield.is_vertical] + vertical_boreholes = borefield[vertical_indices] + inclined_indices = np.arange( + len(borefield), dtype=int)[borefield.is_tilted] + inclined_boreholes = borefield[inclined_indices] + # Return nSegments as None or int if provided as such + if nSegments is None or isinstance(nSegments, (int, np.integer)): + vertical_nSegments = nSegments + inclined_nSegments = nSegments + else: + vertical_nSegments = np.asarray(nSegments, dtype=int)[vertical_indices] + inclined_nSegments = np.asarray(nSegments, dtype=int)[inclined_indices] + # Only return segment_ratios as list if a list was provided + if not isinstance(segment_ratios, list): + vertical_segment_ratios = segment_ratios + inclined_segment_ratios = segment_ratios + else: + vertical_segment_ratios = [segment_ratios[i] for i in vertical_indices] + inclined_segment_ratios = [segment_ratios[i] for i in inclined_indices] + # If a list of arrays of indices was provided, create lists for + # vertical and inclined boreholes + if isinstance(indices, list): + vertical_indices = [indices[m] for m in vertical_indices] + inclined_indices = [indices[m] for m in inclined_indices] + return vertical_boreholes, vertical_nSegments, vertical_segment_ratios, \ + vertical_indices, inclined_boreholes, inclined_nSegments, \ + inclined_segment_ratios, inclined_indices + + def _check_solver_specific_inputs(self): + """ + This method ensures that solver specific inputs to the Solver object + are what is expected. + + """ + assert isinstance(self.disTol, (np.floating, float)) and self.disTol > 0., \ + "The distance tolerance 'disTol' should be a positive float." + assert isinstance(self.tol, (np.floating, float)) and self.tol > 0., \ + "The relative tolerance 'tol' should be a positive float." + return diff --git a/pygfunction/utilities.py b/pygfunction/utilities.py index 44ac2c5c..0039a6b9 100644 --- a/pygfunction/utilities.py +++ b/pygfunction/utilities.py @@ -108,7 +108,7 @@ def segment_ratios(nSegments, end_length_ratio=0.02): def is_even(n): "Returns True if n is even." return not(n & 0x1) - assert nSegments >= 1 and isinstance(nSegments, int), \ + assert nSegments >= 1 and isinstance(nSegments, (int, np.integer)), \ "The number of segments `nSegments` should be greater or equal " \ "to 1 and of type int." assert nSegments <= 2 or 0. < end_length_ratio < 0.5 and \ @@ -507,4 +507,4 @@ def _erf_coeffs(N): approximations and bounds for the Gaussian Q-function by sums of exponentials. IEEE Transactions on communications, 68(10), 6514-6524. """ - return _a_erf[N], _b_erf[N] + return _a_erf[N-1], _b_erf[N-1]