geoloop 0.0.1__py3-none-any.whl → 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. geoloop/axisym/AxisymetricEL.py +751 -0
  2. geoloop/axisym/__init__.py +3 -0
  3. geoloop/bin/Flowdatamain.py +89 -0
  4. geoloop/bin/Lithologymain.py +84 -0
  5. geoloop/bin/Loadprofilemain.py +100 -0
  6. geoloop/bin/Plotmain.py +250 -0
  7. geoloop/bin/Runbatch.py +81 -0
  8. geoloop/bin/Runmain.py +86 -0
  9. geoloop/bin/SingleRunSim.py +928 -0
  10. geoloop/bin/__init__.py +3 -0
  11. geoloop/cli/__init__.py +0 -0
  12. geoloop/cli/batch.py +106 -0
  13. geoloop/cli/main.py +105 -0
  14. geoloop/configuration.py +946 -0
  15. geoloop/constants.py +112 -0
  16. geoloop/geoloopcore/CoaxialPipe.py +503 -0
  17. geoloop/geoloopcore/CustomPipe.py +727 -0
  18. geoloop/geoloopcore/__init__.py +3 -0
  19. geoloop/geoloopcore/b2g.py +739 -0
  20. geoloop/geoloopcore/b2g_ana.py +516 -0
  21. geoloop/geoloopcore/boreholedesign.py +683 -0
  22. geoloop/geoloopcore/getloaddata.py +112 -0
  23. geoloop/geoloopcore/pyg_ana.py +280 -0
  24. geoloop/geoloopcore/pygfield_ana.py +519 -0
  25. geoloop/geoloopcore/simulationparameters.py +130 -0
  26. geoloop/geoloopcore/soilproperties.py +152 -0
  27. geoloop/geoloopcore/strat_interpolator.py +194 -0
  28. geoloop/lithology/__init__.py +3 -0
  29. geoloop/lithology/plot_lithology.py +277 -0
  30. geoloop/lithology/process_lithology.py +695 -0
  31. geoloop/loadflowdata/__init__.py +3 -0
  32. geoloop/loadflowdata/flow_data.py +161 -0
  33. geoloop/loadflowdata/loadprofile.py +325 -0
  34. geoloop/plotting/__init__.py +3 -0
  35. geoloop/plotting/create_plots.py +1142 -0
  36. geoloop/plotting/load_data.py +432 -0
  37. geoloop/utils/RunManager.py +164 -0
  38. geoloop/utils/__init__.py +0 -0
  39. geoloop/utils/helpers.py +841 -0
  40. geoloop-1.0.0.dist-info/METADATA +120 -0
  41. geoloop-1.0.0.dist-info/RECORD +46 -0
  42. geoloop-1.0.0.dist-info/entry_points.txt +2 -0
  43. geoloop-0.0.1.dist-info/licenses/LICENSE → geoloop-1.0.0.dist-info/licenses/LICENSE.md +2 -1
  44. geoloop-0.0.1.dist-info/METADATA +0 -10
  45. geoloop-0.0.1.dist-info/RECORD +0 -6
  46. {geoloop-0.0.1.dist-info → geoloop-1.0.0.dist-info}/WHEEL +0 -0
  47. {geoloop-0.0.1.dist-info → geoloop-1.0.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,739 @@
1
+ import numpy as np
2
+ from scipy.integrate import odeint
3
+ from scipy.interpolate import UnivariateSpline
4
+
5
+ from geoloop.axisym.AxisymetricEL import AxiGrid
6
+ from geoloop.geoloopcore.boreholedesign import BoreholeDesign
7
+ from geoloop.geoloopcore.CoaxialPipe import CoaxialPipe
8
+ from geoloop.geoloopcore.CustomPipe import CustomPipe
9
+ from geoloop.geoloopcore.simulationparameters import SimulationParameters
10
+ from geoloop.geoloopcore.soilproperties import SoilProperties
11
+
12
+ IFLUXF = [1, 0, 0]
13
+ JFLUXF = [0, 1, 0]
14
+ KFLUXF = [0, 0, 1]
15
+
16
+
17
+ class B2G:
18
+ """
19
+ Class for depth dependent variation in pipe temperatures and borehole wall temperatures as well as soil properties,
20
+ using a 2D axisymmetric finite volume model
21
+
22
+ The model is based on a modified approach from the work of Cazorla-Marin [1, 2, 3].
23
+
24
+ The modification is that the thermal resistance and Tb node of pygfunction is used for the borehole wall,
25
+ and 3 additional nodes are included.
26
+
27
+ The Tb node is subject to heat flow determined from the thermal resistance network :Rbinv @ (Tf - Tb np.ones)
28
+
29
+ - q = sum ( qi = Rbinv @ (Tf - Tb np.ones)).
30
+
31
+ Currently, the model is with finite volume formulation without Lax-Wendroff explicit finite volume scheme
32
+ (LW scheme not implemented yet).
33
+ This is a 2nd order explicit scheme, with the thermal resistance network of the borehole wall and the fluid nodes.
34
+ it practically limits the number of vertical nodes to ca. 10.
35
+
36
+ Attributes
37
+ ----------
38
+ custom_pipe : CustomPipe
39
+ Pipe configuration object with geometric and thermal properties.
40
+ is_coaxial : bool
41
+ True if the pipe is a coaxial configuration.
42
+ ag : AxiGrid
43
+ Axisymmetric finite volume grid (initialized later).
44
+
45
+ References
46
+ ----------
47
+ [1] Cazorla Marín, A.: Modelling and experimental validation of an innovative coaxial helical borehole heat exchanger
48
+ for a dual source heat pump system, PhD, Universitat Politècnica de València, Valencia (Spain),
49
+ https://doi.org/10.4995/Thesis/10251/125696, 2019.
50
+ [2] Cazorla-Marín, A., Montagud-Montalvá, C., Tinti, F., and Corberán, J. M.: A novel TRNSYS type of a coaxial borehole
51
+ heat exchanger for both short and mid term simulations: B2G model, Applied Thermal Engineering, 164, 114500,
52
+ https://doi.org/10.1016/j.applthermaleng.2019.114500, 2020.
53
+ [3] Cazorla-Marín, A., Montagud-Montalvá, C., Corberán, J. M., Montero, Á., and Magraner, T.: A TRNSYS assisting tool
54
+ for the estimation of ground thermal properties applied to TRT (thermal response test) data: B2G model, Applied
55
+ Thermal Engineering, 185, 116370, https://doi.org/10.1016/j.applthermaleng.2020.116370, 2021.
56
+ """
57
+
58
+ def __init__(self, custom_pipe: CustomPipe) -> None:
59
+ """
60
+ Initialize the B2G model with a given custom pipe configuration.
61
+
62
+ Parameters
63
+ ----------
64
+ custom_pipe : CustomPipe
65
+ Object containing borehole geometry, pipe arrangement, and thermal properties.
66
+ """
67
+ self.custom_pipe = custom_pipe
68
+ if isinstance(self.custom_pipe, CoaxialPipe):
69
+ self.is_coaxial = True
70
+ else:
71
+ self.is_coaxial = False
72
+
73
+ def runsimulation(
74
+ self,
75
+ bh_design: BoreholeDesign,
76
+ soil_props: SoilProperties,
77
+ sim_params: SimulationParameters,
78
+ ) -> tuple:
79
+ """
80
+ Run the borehole-to-ground simulation using finite difference axisymmetric grid.
81
+
82
+ Parameters
83
+ ----------
84
+ bh_design : BoreholeDesign
85
+ Borehole geometry and thermal resistances.
86
+ soil_props : SoilProperties
87
+ Soil properties and ground temperature profile.
88
+ sim_params : SimulationParameters
89
+ Simulation settings: time array, inlet temperatures, flow rates, etc.
90
+
91
+ Returns
92
+ -------
93
+ hours : ndarray
94
+ Time array in hours.
95
+ Q_b : ndarray
96
+ Borehole thermal power [W].
97
+ flowrate : ndarray
98
+ Mass flow rate [kg/s].
99
+ qsign : ndarray
100
+ Sign of heat extraction (-1 for extraction, +1 for injection).
101
+ T_fi : ndarray
102
+ Fluid inlet temperatures [°C].
103
+ T_fo : ndarray
104
+ Fluid outlet temperatures [°C].
105
+ T_bave : ndarray
106
+ Average borehole wall temperature [°C].
107
+ z : ndarray
108
+ Depth coordinates [m].
109
+ T_b : ndarray
110
+ Borehole wall temperature field [°C].
111
+ T_f : ndarray
112
+ Pipe fluid temperature field [°C].
113
+ qzb : ndarray
114
+ Vertical heat flux along borehole [W/m].
115
+ h_fpipes : ndarray
116
+ Convective film coefficients for each pipe.
117
+ result : ndarray
118
+ Raw solution array from the finite difference solver.
119
+ zstart : ndarray
120
+ Lower boundary of each vertical cell [m].
121
+ zend : ndarray
122
+ Upper boundary of each vertical cell [m].
123
+ """
124
+ custom_pipe = self.custom_pipe
125
+ nx = sim_params.nsegments + 1
126
+ z = np.linspace(custom_pipe.b.D, custom_pipe.b.D + custom_pipe.b.H, nx)
127
+
128
+ # Define vertical cell boundaries
129
+ zstart = z * 1.0
130
+ zend = z * 1.0
131
+ dz = np.diff(z)
132
+ zstart[-1] -= dz[-1]
133
+ zend[0:-1] = zstart[0:-1] + 0.5 * dz
134
+
135
+ # Borehole resistances
136
+ k_g = bh_design.get_k_g(zstart, zend)
137
+ R_p = bh_design.get_r_p(z)
138
+ # create the right structure for thermal resistances
139
+ self.custom_pipe.init_thermal_resistances(k_g, R_p)
140
+
141
+ # Soil conductivity
142
+ k_s = soil_props.get_k_s(zstart, zend, sim_params.isample)
143
+
144
+ # Time and mass flow scaling
145
+ hours = sim_params.time / 3600.0
146
+ m_flow = sim_params.m_flow[0]
147
+ qscale = sim_params.m_flow / m_flow
148
+ h_fpipes = np.zeros((qscale.shape[0], custom_pipe.nPipes))
149
+ h_fpipes[:] = custom_pipe.h_f
150
+
151
+ # solve for temperature distribution
152
+ T_f, T_b, dtf, qzb, result = self.get_temperature_depthvar(
153
+ hours,
154
+ qscale,
155
+ sim_params.Tin,
156
+ soil_props,
157
+ nr=sim_params.nr,
158
+ rmax=sim_params.rmax,
159
+ nsegments=nx,
160
+ k=k_s,
161
+ alfa=soil_props.alfa,
162
+ )
163
+
164
+ T_fi = T_f[:, 0, 0]
165
+ T_fo = T_f[:, 0, custom_pipe.nPipes - 1]
166
+ flowrate = sim_params.m_flow
167
+ Q_b = (T_fo - T_fi) * custom_pipe.cp_f * flowrate
168
+ qsign = np.sign(Q_b)
169
+ T_bave = np.average(T_b, axis=1)
170
+
171
+ return (
172
+ hours,
173
+ Q_b,
174
+ flowrate,
175
+ qsign,
176
+ T_fi,
177
+ T_fo,
178
+ T_bave,
179
+ z,
180
+ T_b,
181
+ T_f,
182
+ -qzb,
183
+ h_fpipes,
184
+ result,
185
+ zstart,
186
+ zend,
187
+ )
188
+
189
+ def modify_par_ag(self, ny_add: int, param: np.ndarray) -> np.ndarray:
190
+ """
191
+ Modify the dimension of the AxiGrid object parameter, by adding ny_add nodes in the y direction to represent
192
+ the pipes.
193
+
194
+ This member function is used internally to modify the original grid parameters of the AxiGrid object.
195
+
196
+ Parameters
197
+ ----------
198
+ ny_add : int
199
+ Number of additional nodes to insert in the y-direction for pipes.
200
+ param : np.ndarray
201
+ Original parameter array (e.g., k, vol, overcp, rcf, rcbulk, axyz).
202
+
203
+ Returns
204
+ -------
205
+ np.ndarray
206
+ Modified parameter array with added pipe nodes.
207
+ """
208
+ grid = self.ag
209
+ ag_ny = grid.ny + ny_add
210
+ grid_mesh = np.arange(self.ag.nx * ag_ny, dtype=float).reshape(
211
+ self.ag.nx, ag_ny, 1
212
+ )
213
+ grid_mesh *= 0
214
+ grid_mesh[:, ny_add + 1 :, :] = param[:, 1:, :]
215
+ return grid_mesh
216
+
217
+ def modify_trans_ag(self, ny_add: int, transmission: np.ndarray) -> np.ndarray:
218
+ """
219
+ Modify the dimension of the AxiGrid object transmission or flux (at faces), by adding ny_add nodes in the y
220
+ direction to represent the pipes.
221
+
222
+ This member function is used internally to modify the original grid parameters of the AxiGrid object.
223
+
224
+ Parameters
225
+ ----------
226
+ ny_add : int
227
+ Number of additional nodes to insert in the y-direction.
228
+ transmission : np.ndarray
229
+ Original transmission/flux array.
230
+
231
+ Returns
232
+ -------
233
+ np.ndarray
234
+ Modified transmission array with added pipe nodes.
235
+ """
236
+ grid = self.ag
237
+ ag_ny = grid.ny + ny_add
238
+ grid_transmission = np.arange(3 * self.ag.nx * ag_ny, dtype=float).reshape(
239
+ 3, self.ag.nx, ag_ny, 1
240
+ )
241
+ grid_transmission *= 0
242
+ grid_transmission[:, :, ny_add + 1 :, :] = transmission[:, :, 1:, :]
243
+ return grid_transmission
244
+
245
+ def modify_ag(self) -> None:
246
+ """
247
+ Modify the axisymmetric grid to include the pipes and the borehole heat capacity.
248
+
249
+ This member function is used internally by initAG to modify the original grid parameters of the AxiGrid object.
250
+
251
+ Replace the heat capacity of the second node in the axisymmetric grid to take into account the borehole heat
252
+ capacity, corrected for the pipes (which are treated by the pipe nodes).
253
+
254
+ In addition, insert additional nodes, starting from node 0 to include the pipes
255
+ and roll the properties of the original axisymmetric grid to include the pipes.
256
+ """
257
+ g = self.ag
258
+ n_pipes = self.custom_pipe.nPipes
259
+ ny_add = n_pipes - 1
260
+
261
+ for i in range(g.nx):
262
+ j = 1
263
+ if self.is_coaxial:
264
+ a1 = (
265
+ self.custom_pipe.r_in[0] ** 2 - self.custom_pipe.r_out[1] ** 2
266
+ ) * np.pi
267
+ a2 = self.custom_pipe.r_in[1] ** 2 * np.pi
268
+ areapipes = a1 + a2
269
+ else:
270
+ rpipes = self.custom_pipe.r_in # was r_out, but heat capacity of pipes is only fluid, rest should be connected
271
+ # the
272
+ areapipes = np.sum(rpipes**2 * np.pi)
273
+
274
+ axitoparea = g.axisumdr[i][1] ** 2 * np.pi - areapipes
275
+
276
+ g.vol[i][j][0] = axitoparea * g.dx[i].item()
277
+ g.overcp[i][j][0] = 1.0 / (g.vol[i][j][0] * g.rcbulk[i][j][0])
278
+
279
+ # adjust the vertical transimission coefficient, based on the new volume, using Langevin approach
280
+ for i in range(g.nx - 1):
281
+ j = 1
282
+ ia = 0
283
+ g.txyz[ia][i][j][0] = 1 / (
284
+ 0.5 * g.dx[i] ** 2 / (g.k[i][j][0] * g.vol[i][j][0])
285
+ + 0.5 * g.dx[i + 1] ** 2 / (g.k[i + 1][j][0] * g.vol[i + 1][j][0])
286
+ )
287
+
288
+ # from here add new nodes (remember that the first node is corresponding to the first inlet
289
+ # add nodes for the pipes and set properties of the first npipe nodes to 0
290
+ g.axisumdr = self.modify_par_ag(ny_add, g.axisumdr)
291
+ g.axidr = self.modify_par_ag(ny_add, g.axidr)
292
+ g.axicellrmid = self.modify_par_ag(ny_add, g.axicellrmid)
293
+ g.k = self.modify_par_ag(ny_add, g.k)
294
+ g.vol = self.modify_par_ag(ny_add, g.vol)
295
+ g.overcp = self.modify_par_ag(ny_add, g.overcp)
296
+ g.rcf = self.modify_par_ag(ny_add, g.rcf)
297
+ g.rcbulk = self.modify_par_ag(ny_add, g.rcbulk)
298
+ g.axyz = self.modify_par_ag(ny_add, g.axyz)
299
+
300
+ g.txyz = self.modify_trans_ag(ny_add, g.txyz)
301
+ g.fxyz = self.modify_trans_ag(ny_add, g.fxyz)
302
+
303
+ for i in range(g.nx):
304
+ if self.is_coaxial:
305
+ # modify the properties for the tubes. Needed are the fluxes, overcp
306
+ g.vol[i, 1, 0] = g.dx[i] * np.pi * self.custom_pipe.r_in[1] ** 2
307
+ g.vol[i, 0, 0] = (
308
+ g.dx[i]
309
+ * np.pi
310
+ * (self.custom_pipe.r_in[0] ** 2 - self.custom_pipe.r_out[1] ** 2)
311
+ )
312
+ else:
313
+ # modify the properties for the tubes. Needed are the fluxes, overcp
314
+ g.vol[i, 0:n_pipes, 0] = g.dx[i] * np.pi * self.custom_pipe.r_in**2
315
+
316
+ for i in range(g.nx):
317
+ g.rcf[i, 0:n_pipes, 0] = self.custom_pipe.cp_f * self.custom_pipe.rho_f
318
+ g.rcbulk[i, 0:n_pipes, 0] = g.rcf[i, 0:n_pipes, 0]
319
+ g.overcp[:, 0:n_pipes, :] = 1 / (
320
+ g.vol[:, 0:n_pipes, :] * g.rcbulk[:, 0:n_pipes, :]
321
+ )
322
+
323
+ # Set mass flux through the pipes
324
+ pflux = np.asarray(self.custom_pipe.m_flow_pipe / self.custom_pipe.rho_f)
325
+ for i in range(g.nx):
326
+ g.fxyz[0, i, 0:n_pipes, 0] = pflux
327
+
328
+ # Adjust transmission for first pipe row
329
+ for i in range(g.nx):
330
+ j = 0
331
+ ia = 1
332
+ g.txyz[ia, i, j, 0] = g.dx[i] / self.custom_pipe._Rd[i][0][1]
333
+
334
+ g.ny += ny_add
335
+ g.dirichlet = g.overcp * 0 + 1
336
+
337
+ def init_ag(
338
+ self,
339
+ soil_props: SoilProperties,
340
+ nr: int = 35,
341
+ rmax: float = 10,
342
+ T_f_instart: float = 0,
343
+ nsegments: int = 10,
344
+ k: float | np.ndarray = 2,
345
+ rcf: float | np.ndarray = 4e6,
346
+ rcr: float | np.ndarray = 3e6,
347
+ por: float | np.ndarray = 0.4,
348
+ ) -> None:
349
+ """
350
+ Initialize the axisymmetric grid (AxiGrid) for the borehole-to-ground model.
351
+
352
+ Sets up the radial and vertical grid, adds pipe nodes, and initializes
353
+ ground and fluid temperatures.
354
+
355
+ Parameters
356
+ ----------
357
+ soil_props : SoilProperties
358
+ Soil properties object to obtain ground temperatures.
359
+ nr : int, optional
360
+ Maximum number of radial nodes (should be odd), by default 35
361
+ rmax : float, optional
362
+ Radius of the midpoint of the last cell, by default 10, is a constant
363
+ T_f_instart : float, optional
364
+ Initial fluid temperature used as default to set up the grid initial temperatures, and fluid conductivity
365
+ and heat capacities
366
+ nsegments : int, optional
367
+ Number of nodes (nx) in the vertical direction, by default 10
368
+ k : float or np.ndarray, optional
369
+ Bulk thermal conductivity in the radial direction, by default 2.0
370
+ rcf : float or np.ndarray, optional
371
+ Volumetric heat capacity of fluid [J/m³·K], by default 4e6
372
+ rcr : float or np.ndarray, optional
373
+ Volumetric heat capacity of rock [J/m³·K], by default 3e6
374
+ por : float or np.ndarray, optional
375
+ Porosity of the cells, by default 0.4
376
+
377
+ Returns
378
+ -------
379
+ None
380
+ """
381
+ cpipe = self.custom_pipe
382
+ rw = cpipe.b.r_b
383
+ xmin = cpipe.b.D
384
+ xmax = xmin + cpipe.b.H
385
+
386
+ # nr needs to be odd for rmax to scale as multiple of rwmin
387
+ if nr % 2 == 0:
388
+ nr += 1
389
+
390
+ # scaling factor of cell size and spacing: factor = size cell [i+2] / size cell [i]
391
+ factor = 1.5
392
+ # how much of the cell size is corresponding the left (smaller cell) vs right (larger cell)
393
+ ratio = factor**0.5 - 1
394
+ rwmin = (1 - ratio**2) * rw
395
+
396
+ nx = nsegments
397
+ self.ag = AxiGrid(
398
+ nx,
399
+ nr,
400
+ xmin,
401
+ xmax,
402
+ rwmin,
403
+ rmax,
404
+ k,
405
+ rcf,
406
+ rcr,
407
+ por,
408
+ firstwellcell=True,
409
+ endcellbound=True,
410
+ )
411
+
412
+ # Modify grid to add pipe nodes
413
+ self.modify_ag()
414
+
415
+ # Initialize ground temperatures along vertical nodes
416
+ tvd = self.ag.x
417
+ Tg_profile = soil_props.getTg(tvd)
418
+ self.ag.initTempX(Tg_profile)
419
+
420
+ # set the inlet temperatures
421
+ self.ag.init_vals[0, : cpipe.nInlets, 0] = T_f_instart
422
+
423
+ return
424
+
425
+ def get_temperature_depthvar(
426
+ self,
427
+ hours: np.ndarray,
428
+ qscale: np.ndarray,
429
+ T_f_in: np.ndarray,
430
+ soil_props: SoilProperties,
431
+ nr: int = 35,
432
+ rmax: float = 10,
433
+ nsegments: int = 10,
434
+ k: float | np.ndarray = 2.0,
435
+ alfa: float = 1e-6,
436
+ ) -> tuple:
437
+ """
438
+ Compute the time-dependent temperature profiles for fluid and borehole wall.
439
+
440
+ Solves the 2D axisymmetric finite difference heat transfer for a borehole
441
+ including the thermal interaction between fluid, pipes, grout, and ground.
442
+
443
+ Parameters
444
+ ----------
445
+ hours : np.ndarray
446
+ Array of time points in hours.
447
+ qscale : np.ndarray
448
+ Scaling factor for mass flow / heat rate.
449
+ T_f_in : np.ndarray
450
+ Array of inlet fluid temperatures [°C] (takes first component as inlet temperature).
451
+ soil_props : SoilProperties
452
+ Soil properties object for ground temperatures.
453
+ nr : int, optional
454
+ Maximum number of radial nodes (should be odd), by default 35.
455
+ rmax : float, optional
456
+ Radius of the midpoint of the last cell, by default 10.
457
+ nsegments : int, optional
458
+ Number of nodes in the vertical direction, by default 10.
459
+ k : float or np.ndarray, optional
460
+ Thermal conductivity (radial), by default 2.0.
461
+ alfa : float, optional
462
+ Thermal diffusivity of soil [m²/s], by default 1e-6.
463
+
464
+ Returns
465
+ -------
466
+ T_f : np.ndarray
467
+ Fluid temperature [hours, vertical nodes, pipes].
468
+ T_b : np.ndarray
469
+ Borehole wall temperature [hours, vertical nodes].
470
+ dtf : np.ndarray
471
+ Temperature difference between outlet and inlet [hours, vertical nodes].
472
+ qzb : np.ndarray
473
+ Heat flux to borehole [hours, vertical nodes].
474
+ result : np.ndarray
475
+ Full 4D result array from the solver [hours, vertical nodes, radial nodes, axial nodes].
476
+ """
477
+ # initialze axisymmetrica
478
+ self.init_ag(
479
+ soil_props,
480
+ nr=nr,
481
+ rmax=rmax,
482
+ T_f_instart=T_f_in[0],
483
+ nsegments=nsegments,
484
+ k=k,
485
+ rcr=k / alfa,
486
+ por=0.0,
487
+ )
488
+
489
+ dt = 3600
490
+
491
+ # Compute derivative of inlet temperature using spline
492
+ x = hours
493
+ y = T_f_in
494
+ spl = UnivariateSpline(x, y, k=4, s=0)
495
+ T_f_in_derivs = np.asarray(spl.derivatives(x))[:, 1]
496
+
497
+ # Run heat loop solver
498
+ result = ode_heatloop(hours, dt, qscale, T_f_in_derivs, self)
499
+
500
+ npipes = self.custom_pipe.nPipes
501
+ T_f = result[:, :, 0:npipes, :].reshape(len(hours), self.ag.nx, npipes)
502
+ T_b = result[:, :, npipes, :].reshape(len(hours), self.ag.nx)
503
+
504
+ # Mass flow and heat capacity
505
+ mflow = self.custom_pipe.m_flow * qscale
506
+ dtf = T_f[:, :, npipes - 1] - T_f[:, :, 0]
507
+
508
+ # Calculate heat flux to borehole wall
509
+ qzb = dtf * 1.0
510
+ for i in range(len(x)):
511
+ qzb[i, :] *= mflow[i] * self.custom_pipe.cp_f
512
+
513
+ for it in range(len(x)):
514
+ for i in range(self.ag.nx):
515
+ temppipe = T_f[it, i].reshape(npipes)
516
+ # get the borewall temperature
517
+ temp_b = T_b[it, i]
518
+
519
+ if self.is_coaxial:
520
+ qpipe = (self.ag.dx[i] / self.custom_pipe._Rd[i][0][0]) * (
521
+ temppipe[0] - temp_b
522
+ )
523
+ # get the heatflow (negative is heat flow to borehole) for each of the pipes to borehole wall
524
+ else:
525
+ qpipe = (
526
+ self.custom_pipe.R1[i]
527
+ @ (temppipe - np.ones(npipes) * temp_b)
528
+ * self.ag.dx[i]
529
+ )
530
+
531
+ qsum = np.sum(qpipe)
532
+ qzb[it, i] = qsum
533
+
534
+ qzb *= -1
535
+
536
+ return T_f, T_b, dtf, qzb, result
537
+
538
+
539
+ def ode_heatloop(
540
+ time: np.ndarray, dt: float, qscale: np.ndarray, T_f_in_derivs: np.ndarray, b2g: B2G
541
+ ) -> np.ndarray:
542
+ """
543
+ Integrates the borehole heat transfer equations over time using an ODE solver.
544
+
545
+ Solves the transient 2D axisymmetric heat transfer between fluid, pipes, borehole, and ground.
546
+ Uses a simple explicit upwind scheme and Dirichlet boundary conditions for the inlet temperatures.
547
+
548
+ Parameters
549
+ ----------
550
+ time : np.ndarray
551
+ Array of time points [h] for integration.
552
+ dt : float
553
+ Timestep scaling in seconds.
554
+ qscale : np.ndarray
555
+ Time array of scaling factors of the reference flow (time dimension)
556
+ T_f_in_derivs : np.ndarray
557
+ Time derivative of inlet fluid temperature [°C/h].
558
+ b2g : B2G
559
+ Borehole-to-ground model containing pipe and axisymmetric grid information.
560
+
561
+ Returns
562
+ -------
563
+ np.ndarray
564
+ Temperature array of shape (len(time), nx, ny, nz), where nx, ny, nz are
565
+ the dimensions of b2g.ag (grid).
566
+ """
567
+ grid = b2g.ag
568
+
569
+ class ScaleFunc:
570
+ """Interpolates a time-dependent scaling factor or derivative."""
571
+
572
+ def __init__(self, scale: np.ndarray, time: np.ndarray):
573
+ self.scale = scale
574
+ self.time = time
575
+
576
+ def get_scale(self, t: float) -> float:
577
+ return np.interp(t, self.time, self.scale)
578
+
579
+ # Create interpolation function for time-dependent quantities
580
+ qscale_t = ScaleFunc(qscale, time)
581
+ T_f_in_derivs_t = ScaleFunc(T_f_in_derivs, time)
582
+
583
+ def ode(
584
+ y: np.ndarray, t: float, dt: float, g: AxiGrid, custom_pipe: CustomPipe
585
+ ) -> np.ndarray:
586
+ """
587
+ Compute dT/dt for the grid at time t.
588
+
589
+ Parameters
590
+ ----------
591
+ y : np.ndarray
592
+ Flattened temperature array of shape (nx*ny*nz).
593
+ t : float
594
+ Current simulation time.
595
+ dt : float
596
+ Timestep scaling.
597
+ g : AxiGrid
598
+ Axisymmetric grid.
599
+ custom_pipe : CustomPipe
600
+ Pipe configuration with flow and thermal resistance information.
601
+
602
+ Returns
603
+ -------
604
+ np.ndarray
605
+ Flattened array of temperature derivatives (dT/dt * dt).
606
+ """
607
+ # use npipes in ny direction to set the fluid fluxes. This is all arranged with the
608
+ s = y.reshape(g.nx, g.ny, g.nz)
609
+ dsdt = np.zeros_like(s)
610
+
611
+ nPipes = custom_pipe.nPipes
612
+ nInlets = custom_pipe.nInlets
613
+
614
+ qscale = qscale_t.get_scale(t)
615
+ Tf_der = T_f_in_derivs_t.get_scale(t)
616
+
617
+ for k in range(g.nz):
618
+ for i in range(g.nx):
619
+ # get for the depth segment the pipe temperatures
620
+ temppipe = s[i, 0:nPipes, :].reshape(nPipes)
621
+
622
+ # get the borewall temperature
623
+ temp_b = s[i, nPipes, 0]
624
+ # get the heatflow (negative is heat flow to borehole) for each of the pipes to borehole wall
625
+ if isinstance(custom_pipe, CoaxialPipe):
626
+ qpipe = temppipe * 0
627
+ qpipe[0] = (1.0 / custom_pipe._Rd[i][0][0]) * (temppipe[0] - temp_b)
628
+ else:
629
+ qpipe = custom_pipe.R1[i] @ (temppipe - np.ones(nPipes) * temp_b)
630
+ # get the
631
+ qsum = np.sum(qpipe)
632
+ # if (i == 5):
633
+ # # print("qsum", qsum)
634
+
635
+ for j in range(g.ny):
636
+ # dsdt[i][j][k] = 0.0
637
+ # in boundary condtion list?
638
+
639
+ # else do diffusion and fluxes
640
+ for ia in range(2):
641
+ ii = i + IFLUXF[ia]
642
+ jj = j + JFLUXF[ia]
643
+ kk = k
644
+ if isinstance(custom_pipe, CoaxialPipe):
645
+ usecpipe = (ia == 1) and (j == 0)
646
+ else:
647
+ usecpipe = (ia == 1) and (j < nPipes)
648
+ if usecpipe:
649
+ jj = nPipes
650
+ ok = g.checkindex(ii, jj, kk)
651
+ if ok:
652
+ dtemp = s[i][j][k] - s[ii][jj][kk]
653
+ # diffusion, for the pipe nodes g.txyz[1][i][j=0..npipe-1][k] should not be used instead ,
654
+ # handled with the heatflux condition from the cpipe R matrix
655
+
656
+ if usecpipe:
657
+ dsdt[i][j][k] -= g.overcp[i][j][k] * qpipe[j] * g.dx[i]
658
+ dsdt[ii][jj][kk] += (
659
+ g.overcp[ii][jj][kk] * qpipe[j] * g.dx[ii]
660
+ )
661
+ else:
662
+ dsdt[i][j][k] -= (
663
+ g.overcp[i][j][k] * g.txyz[ia][i][j][k] * dtemp
664
+ )
665
+ dsdt[ii][jj][kk] += (
666
+ g.overcp[ii][jj][kk] * g.txyz[ia][i][j][k] * dtemp
667
+ )
668
+
669
+ if usecpipe and isinstance(custom_pipe, CoaxialPipe):
670
+ # j ==0
671
+ dtemp = s[i][j][k] - s[ii][1][kk]
672
+ dsdt[i][j][k] -= (
673
+ g.overcp[i][j][k] * g.txyz[ia][i][j][k] * dtemp
674
+ )
675
+ dsdt[ii][1][kk] += (
676
+ g.overcp[ii][1][kk] * g.txyz[ia][i][j][k] * dtemp
677
+ )
678
+
679
+ # fluxes [m3/s] upwind scheme, this does not implement LAX-WENDROF
680
+ # these fluxes are used in the pipe nodes, and should be set
681
+ # in the correct way , i.e.
682
+ # - they correspond to downward (positive) and upward flowrates (negative) (ia==0)
683
+ # - the correponding overcp agrees with pipe fluid volume density and heat capacity
684
+ flux = g.fxyz[ia][i][j][k] * qscale
685
+
686
+ if (ia == 0) and (j < nInlets) and (i == g.nx - 2):
687
+ if j == 0:
688
+ enthsum = 0
689
+ cpsum = 0
690
+ for m in range(nInlets):
691
+ flux = g.fxyz[ia][i][m][k] * qscale
692
+ dtemp = s[i][m][k] - s[ii][m][k]
693
+ # for an inlet pipe the flux should always be >=0
694
+ if flux > 0:
695
+ enthsum += dtemp * flux * g.rcf[ii][m][k]
696
+ cpsum += 1 / g.overcp[ii][m][k]
697
+ if cpsum > 0:
698
+ for m in range(nInlets):
699
+ dsdt[ii][m][k] += enthsum / cpsum
700
+ else:
701
+ if flux > 0:
702
+ dsdt[ii][jj][kk] += (
703
+ dtemp
704
+ * flux
705
+ * g.rcf[ii][jj][kk]
706
+ * g.overcp[ii][jj][kk]
707
+ )
708
+ elif flux < 0:
709
+ dsdt[i][j][k] += (
710
+ dtemp
711
+ * flux
712
+ * g.rcf[i][j][k]
713
+ * g.overcp[i][j][k]
714
+ )
715
+
716
+ # correct the bottom nodes
717
+ i = g.nx - 1
718
+ hsum = 0
719
+ cpsum = 0
720
+ for j in range(nPipes):
721
+ hsum += dsdt[i][j][0] / g.overcp[i][j][0]
722
+ cpsum += 1.0 / g.overcp[i][j][0]
723
+ for j in range(nPipes):
724
+ dsdt[i][j][0] = hsum / cpsum
725
+
726
+ # set the inlet temeratures to change according to the inpu derivatives
727
+ dsdt[0, 0:nInlets, 0] = Tf_der / dt
728
+
729
+ # overrule nodal changes which have fixed (dirichlet) boundary condition
730
+ dsdt *= g.dirichlet
731
+ dsdt_scaled = dsdt * dt
732
+ dydt = dsdt_scaled.reshape(g.nx * g.ny * g.nz)
733
+
734
+ return dydt
735
+
736
+ init_vals = grid.init_vals.reshape(grid.nx * grid.ny * grid.nz)
737
+ resultode = odeint(ode, init_vals, time, args=(dt, grid, b2g.custom_pipe))
738
+
739
+ return resultode.reshape(len(time), grid.nx, grid.ny, grid.nz)