Skip to content

Commit ea0acca

Browse files
authored
Merge pull request #336 from wouterpeere/issue335-optimise-for-balanced-borefield
Issue335 optimise for balanced borefield
2 parents b58cdd2 + fe8644b commit ea0acca

File tree

14 files changed

+486
-163
lines changed

14 files changed

+486
-163
lines changed

CHANGELOG.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,24 @@ our [project board](https://github.com/users/wouterpeere/projects/2) on GitHub.
55

66
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
77

8-
## [2.3.2] - unpublished
8+
## [2.3.2] - 2025-04-02
99

1010
## Added
1111

1212
- Added support for DHW profiles in optimisation (issue #272).
1313
- Added Prandtl number to FluidData class (issue #326).
1414
- Pressure drop calculation for horizontal pipe and total system (issue #332).
15+
- Added optimisation function for balanced borefield (issue #335).
16+
- Min_temperature and Max_temperature property to results class (issue #335).
1517

1618
## Changed
1719

1820
- Added U-bend to the pressure drop calculation of the pipe (issue #332).
21+
- Remove optimise_load_power and optimise_load_energy from Borefield object (issue #332).
22+
23+
## Fixed
24+
25+
- Increase accuracy of optimise load profile (issue #335).
1926

2027
## [2.3.1] - 2025-01-23
2128

GHEtool/Borefield.py

Lines changed: 0 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -1884,93 +1884,6 @@ def Re(self) -> float:
18841884
"""
18851885
return self.borehole.Re
18861886

1887-
def optimise_load_profile_power(
1888-
self,
1889-
building_load: Union[HourlyBuildingLoad, HourlyBuildingLoadMultiYear],
1890-
temperature_threshold: float = 0.05,
1891-
use_hourly_resolution: bool = True,
1892-
max_peak_heating: float = None,
1893-
max_peak_cooling: float = None
1894-
) -> tuple[HourlyBuildingLoad, HourlyBuildingLoad]:
1895-
"""
1896-
This function optimises the load for maximum power in extraction and injection based on the given borefield and
1897-
the given hourly building load. It does so based on a load-duration curve.
1898-
The temperatures of the borefield are calculated on a monthly basis, even though we have hourly data,
1899-
for an hourly calculation of the temperatures would take a very long time.
1900-
1901-
Parameters
1902-
----------
1903-
borefield : Borefield
1904-
Borefield object
1905-
building_load : HourlyBuildingLoad | HourlyBuildingLoadMultiYear
1906-
Load data used for the optimisation.
1907-
temperature_threshold : float
1908-
The maximum allowed temperature difference between the maximum and minimum fluid temperatures and their
1909-
respective limits. The lower this threshold, the longer the convergence will take.
1910-
use_hourly_resolution : bool
1911-
If use_hourly_resolution is used, the hourly data will be used for this optimisation. This can take some
1912-
more time than using the monthly resolution, but it will give more accurate results.
1913-
max_peak_heating : float
1914-
The maximum peak power for the heating (building side) [kW]
1915-
max_peak_cooling : float
1916-
The maximum peak power for the cooling (building side) [kW]
1917-
1918-
Returns
1919-
-------
1920-
tuple [HourlyBuildingLoad, HourlyBuildingLoad]
1921-
borefield load, external load
1922-
1923-
Raises
1924-
------
1925-
ValueError
1926-
ValueError if no correct load data is given or the threshold is negative
1927-
"""
1928-
borefield_load, external_load = optimise_load_profile_power(
1929-
self, building_load, temperature_threshold, use_hourly_resolution, max_peak_heating, max_peak_cooling)
1930-
self.load = borefield_load
1931-
return borefield_load, external_load
1932-
1933-
def optimise_load_profile_energy(
1934-
self,
1935-
building_load: Union[HourlyBuildingLoad, HourlyBuildingLoadMultiYear],
1936-
temperature_threshold: float = 0.05,
1937-
max_peak_heating: float = None,
1938-
max_peak_cooling: float = None
1939-
) -> tuple[HourlyBuildingLoadMultiYear, HourlyBuildingLoadMultiYear]:
1940-
"""
1941-
This function optimises the load for maximum energy extraction and injection based on the given borefield and
1942-
the given hourly building load. It does so by iterating over every month of the simulation period and increasing or
1943-
decreasing the amount of geothermal heating and cooling until it meets the temperature requirements.
1944-
1945-
Parameters
1946-
----------
1947-
borefield : Borefield
1948-
Borefield object
1949-
building_load : HourlyBuildingLoad | HourlyBuildingLoadMultiYear
1950-
Load data used for the optimisation
1951-
temperature_threshold : float
1952-
The maximum allowed temperature difference between the maximum and minimum fluid temperatures and their
1953-
respective limits. The lower this threshold, the longer the convergence will take.
1954-
max_peak_heating : float
1955-
The maximum peak power for heating (building side) [kW]
1956-
max_peak_cooling : float
1957-
The maximum peak power for cooling (building side) [kW]
1958-
1959-
Returns
1960-
-------
1961-
tuple [HourlyBuildingLoadMultiYear, HourlyBuildingLoadMultiYear]
1962-
borefield load, external load
1963-
1964-
Raises
1965-
------
1966-
ValueError
1967-
ValueError if no correct load data is given or the threshold is negative
1968-
"""
1969-
borefield_load, external_load = optimise_load_profile_energy(
1970-
self, building_load, temperature_threshold, max_peak_heating, max_peak_cooling)
1971-
self.load = borefield_load
1972-
return borefield_load, external_load
1973-
19741887
def calculate_quadrant(self) -> int:
19751888
"""
19761889
This function returns the borefield quadrant (as defined by Peere et al., 2021 [#PeereEtAl]_)

GHEtool/Examples/optimise_load_profile.py

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import numpy as np
99

1010
from GHEtool import *
11+
from GHEtool.Methods import *
1112

1213

1314
def optimise():
@@ -33,7 +34,8 @@ def optimise():
3334

3435
# optimise the load for a 10x10 field (see data above) and a fixed depth of 150m.
3536
# first for an optimisation based on the power
36-
borefield.optimise_load_profile_power(building_load=load)
37+
building_load, _ = optimise_load_profile_power(borefield, load)
38+
borefield.load = building_load
3739

3840
print(f'Max extraction power (primary): {borefield.load.max_peak_extraction:,.0f}kW')
3941
print(f'Max injection power (primary): {borefield.load.max_peak_injection:,.0f}kW')
@@ -47,7 +49,23 @@ def optimise():
4749
borefield.print_temperature_profile(plot_hourly=True)
4850

4951
# first for an optimisation based on the energy
50-
borefield.optimise_load_profile_energy(building_load=load)
52+
building_load, _ = optimise_load_profile_energy(borefield, load)
53+
borefield.load = building_load
54+
55+
print(f'Max extraction power (primary): {borefield.load.max_peak_extraction:,.0f}kW')
56+
print(f'Max injection power (primary): {borefield.load.max_peak_injection:,.0f}kW')
57+
58+
print(
59+
f'Total energy extracted from the borefield over simulation period: {np.sum(borefield.load.monthly_baseload_extraction_simulation_period):,.0f}MWh')
60+
print(
61+
f'Total energy injected in the borefield over simulation period: {np.sum(borefield.load.monthly_baseload_injection_simulation_period):,.0f}MWh')
62+
63+
borefield.calculate_temperatures(hourly=True)
64+
borefield.print_temperature_profile(plot_hourly=True)
65+
66+
# first for an optimisation based on the balance
67+
building_load, _ = optimise_load_profile_balance(borefield, load)
68+
borefield.load = building_load
5169

5270
print(f'Max extraction power (primary): {borefield.load.max_peak_extraction:,.0f}kW')
5371
print(f'Max injection power (primary): {borefield.load.max_peak_injection:,.0f}kW')

GHEtool/Examples/optimise_load_profile_extra.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import numpy as np
99

1010
from GHEtool import *
11+
from GHEtool.Methods import *
1112

1213

1314
def optimise():
@@ -22,7 +23,8 @@ def optimise():
2223

2324
# optimise the load for a 10x10 field (see data above) and a fixed length of 150m.
2425
# first for an optimisation based on the power
25-
borefield.optimise_load_profile_energy(building_load=load)
26+
building_load, _ = optimise_load_profile_energy(borefield, load)
27+
borefield.load = building_load
2628

2729
print(f'Max heating power (primary): {borefield.load.max_peak_extraction:,.0f}kW')
2830
print(f'Max cooling power (primary): {borefield.load.max_peak_injection:,.0f}kW')

GHEtool/Methods/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
1-
from .optimise_load_profile import optimise_load_profile_power, optimise_load_profile_energy
1+
from .optimise_load_profile import optimise_load_profile_power, optimise_load_profile_energy, \
2+
optimise_load_profile_balance

GHEtool/Methods/optimise_load_profile.py

Lines changed: 182 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,6 @@ def optimise_load_profile_power(
1717
"""
1818
This function optimises the load for maximum power in extraction and injection based on the given borefield and
1919
the given hourly building load. It does so based on a load-duration curve.
20-
The temperatures of the borefield are calculated on a monthly basis, even though we have hourly data,
21-
for an hourly calculation of the temperatures would take a very long time.
2220
2321
Parameters
2422
----------
@@ -115,6 +113,7 @@ def optimise_load_profile_power(
115113
else:
116114
peak_dhw_load = max(0.1, peak_dhw_load - 1 * max(1, 10 * (
117115
borefield.Tf_min - min(borefield.results.peak_extraction))))
116+
heat_ok = False
118117
else:
119118
if (dhw_preferential and peak_heat_load != init_peak_heating) or (
120119
not dhw_preferential and 0.1 >= peak_dhw_load) or dhw_preferential is None:
@@ -132,6 +131,7 @@ def optimise_load_profile_power(
132131
if np.max(borefield.results.peak_injection) > borefield.Tf_max:
133132
peak_cool_load = max(0.1, peak_cool_load - 1 * max(1, 10 * (
134133
-borefield.Tf_max + np.max(borefield.results.peak_injection))))
134+
cool_ok = False
135135
else:
136136
peak_cool_load = min(init_peak_cooling, peak_cool_load * 1.01)
137137
if peak_cool_load == init_peak_cooling:
@@ -344,3 +344,183 @@ def f(hourly_load, monthly_peak) -> np.ndarray:
344344
building_load_copy.hourly_cooling_load_simulation_period - borefield_load.hourly_cooling_load_simulation_period))
345345

346346
return borefield_load, external_load
347+
348+
349+
def optimise_load_profile_balance(
350+
borefield,
351+
building_load: Union[HourlyBuildingLoad, HourlyBuildingLoadMultiYear],
352+
temperature_threshold: float = 0.05,
353+
use_hourly_resolution: bool = True,
354+
max_peak_heating: float = None,
355+
max_peak_cooling: float = None,
356+
dhw_preferential: bool = None,
357+
imbalance_factor: float = 0.01,
358+
) -> tuple[HourlyBuildingLoad, HourlyBuildingLoad]:
359+
"""
360+
This function optimises the load for maximum power in extraction and injection based on the given borefield and
361+
the given hourly building load, by maintaining a zero imbalance. It does so based on a load-duration curve.
362+
363+
Parameters
364+
----------
365+
borefield : Borefield
366+
Borefield object
367+
building_load : HourlyBuildingLoad | HourlyBuildingLoadMultiYear
368+
Load data used for the optimisation.
369+
temperature_threshold : float
370+
The maximum allowed temperature difference between the maximum and minimum fluid temperatures and their
371+
respective limits. The lower this threshold, the longer the convergence will take.
372+
use_hourly_resolution : bool
373+
If use_hourly_resolution is used, the hourly data will be used for this optimisation. This can take some
374+
more time than using the monthly resolution, but it will give more accurate results.
375+
max_peak_heating : float
376+
The maximum peak power for the heating (building side) [kW]
377+
max_peak_cooling : float
378+
The maximum peak power for the cooling (building side) [kW]
379+
dhw_preferential : bool
380+
True if heating should first be reduced only after which the dhw share is reduced.
381+
If it is None, then the dhw profile is not optimised and kept constant.
382+
imbalance_factor : float
383+
Maximum allowed imbalance w.r.t. to the maximum of either the heat injection or extraction.
384+
It should be given in a range of 0-1. At 1, it converges to the solution for optimise for power.
385+
386+
Returns
387+
-------
388+
tuple [HourlyBuildingLoad, HourlyBuildingLoad]
389+
borefield load, external load
390+
391+
Raises
392+
------
393+
ValueError
394+
ValueError if no correct load data is given or the threshold is negative
395+
"""
396+
# copy borefield
397+
borefield = copy.deepcopy(borefield)
398+
399+
# check if hourly load is given
400+
if not isinstance(building_load, (HourlyBuildingLoad, HourlyBuildingLoadMultiYear)):
401+
raise ValueError("The building load should be of the class HourlyBuildingLoad or HourlyBuildingLoadMultiYear!")
402+
403+
# check if threshold is positive
404+
if temperature_threshold < 0:
405+
raise ValueError(f"The temperature threshold is {temperature_threshold}, but it cannot be below 0!")
406+
407+
if imbalance_factor > 1 or imbalance_factor < 0:
408+
raise ValueError(f"The imbalance factor is {imbalance_factor}, but it should be between 0-1!")
409+
410+
# since the depth does not change, the Rb* value is constant
411+
borefield.Rb = borefield.borehole.get_Rb(borefield.H, borefield.D, borefield.r_b,
412+
borefield.ground_data.k_s(borefield.depth, borefield.D))
413+
414+
# set load
415+
borefield.load = copy.deepcopy(building_load)
416+
417+
# set initial peak loads
418+
init_peak_heating: float = borefield.load.max_peak_heating
419+
init_peak_dhw: float = borefield.load.max_peak_dhw
420+
init_peak_cooling: float = borefield.load.max_peak_cooling
421+
422+
# correct for max peak powers
423+
if max_peak_heating is not None:
424+
init_peak_heating = min(init_peak_heating, max_peak_heating)
425+
if max_peak_cooling is not None:
426+
init_peak_cooling = min(init_peak_cooling, max_peak_cooling)
427+
428+
# peak loads for iteration
429+
peak_heat_load: float = init_peak_heating
430+
peak_dhw_load: float = init_peak_dhw
431+
peak_cool_load: float = init_peak_cooling
432+
433+
# set iteration criteria
434+
cool_ok, heat_ok = False, False
435+
while not cool_ok or not heat_ok:
436+
# limit the primary geothermal extraction and injection load to peak_heat_load and peak_cool_load
437+
borefield.load.set_hourly_cooling_load(
438+
np.minimum(peak_cool_load, building_load.hourly_cooling_load
439+
if isinstance(borefield.load, HourlyBuildingLoad) else building_load.hourly_cooling_load_simulation_period))
440+
borefield.load.set_hourly_heating_load(
441+
np.minimum(peak_heat_load, building_load.hourly_heating_load
442+
if isinstance(borefield.load, HourlyBuildingLoad) else building_load.hourly_heating_load_simulation_period))
443+
borefield.load.set_hourly_dhw_load(
444+
np.minimum(peak_dhw_load, building_load.hourly_dhw_load
445+
if isinstance(borefield.load, HourlyBuildingLoad) else building_load.hourly_dhw_load_simulation_period))
446+
447+
# calculate temperature profile, just for the results
448+
borefield.calculate_temperatures(length=borefield.H, hourly=use_hourly_resolution)
449+
450+
# calculate relative imbalance
451+
imbalance = borefield.load.imbalance / np.maximum(borefield.load.yearly_average_injection_load,
452+
borefield.load.yearly_average_extraction_load)
453+
454+
# deviation from minimum temperature
455+
if abs(min(borefield.results.peak_extraction) - borefield.Tf_min) > temperature_threshold or \
456+
(abs(imbalance) > imbalance_factor and imbalance < 0):
457+
# check if it goes below the threshold
458+
if min(borefield.results.peak_extraction) < borefield.Tf_min:
459+
if (dhw_preferential and peak_heat_load > 0.1) \
460+
or (not dhw_preferential and peak_dhw_load <= 0.1) \
461+
or dhw_preferential is None:
462+
# first reduce the peak load in heating before touching the dhw load
463+
# if dhw_preferential is None, it is not optimised and kept constant
464+
peak_heat_load = max(0.1, peak_heat_load - 1 * max(1, 10 * (
465+
borefield.Tf_min - min(borefield.results.peak_extraction))))
466+
else:
467+
peak_dhw_load = max(0.1, peak_dhw_load - 1 * max(1, 10 * (
468+
borefield.Tf_min - min(borefield.results.peak_extraction))))
469+
heat_ok = False
470+
else:
471+
if abs(imbalance) > imbalance_factor and imbalance < 0:
472+
# remove imbalance
473+
if (dhw_preferential and peak_heat_load > 0.1) \
474+
or (not dhw_preferential and peak_dhw_load <= 0.1) \
475+
or dhw_preferential is None:
476+
# first reduce the peak load in heating before touching the dhw load
477+
# if dhw_preferential is None, it is not optimised and kept constant
478+
peak_heat_load = peak_heat_load * 0.99
479+
else:
480+
peak_dhw_load = peak_dhw_load * 0.99
481+
elif abs(imbalance) > imbalance_factor and imbalance > 0:
482+
if (dhw_preferential and peak_heat_load != init_peak_heating) or (
483+
not dhw_preferential and 0.1 >= peak_dhw_load) or dhw_preferential is None:
484+
peak_heat_load = min(init_peak_heating, peak_heat_load * 1.01)
485+
else:
486+
peak_dhw_load = min(init_peak_dhw, peak_dhw_load * 1.01)
487+
if (peak_heat_load == init_peak_heating and peak_dhw_load == init_peak_dhw) or cool_ok:
488+
heat_ok = True
489+
else:
490+
# imbalance small enough
491+
heat_ok = True
492+
else:
493+
heat_ok = True
494+
495+
# deviation from maximum temperature
496+
if abs(np.max(borefield.results.peak_injection) - borefield.Tf_max) > temperature_threshold or \
497+
(abs(imbalance) > imbalance_factor and imbalance > 0):
498+
# check if it goes above the threshold
499+
if np.max(borefield.results.peak_injection) > borefield.Tf_max:
500+
peak_cool_load = max(0.1, peak_cool_load - 1 * max(1, 10 * (
501+
-borefield.Tf_max + np.max(borefield.results.peak_injection))))
502+
cool_ok = False
503+
else:
504+
if abs(imbalance) > imbalance_factor and imbalance > 0:
505+
# remove imbalance
506+
peak_cool_load = peak_cool_load * 0.99
507+
elif abs(imbalance) > imbalance_factor and imbalance < 0:
508+
peak_cool_load = min(init_peak_cooling, peak_cool_load * 1.01)
509+
if peak_cool_load == init_peak_cooling or heat_ok:
510+
cool_ok = True
511+
else:
512+
# imbalance is small enough
513+
cool_ok = True
514+
else:
515+
cool_ok = True
516+
517+
# calculate external load
518+
external_load = HourlyBuildingLoad(simulation_period=building_load.simulation_period)
519+
external_load.set_hourly_heating_load(
520+
np.maximum(0, building_load.hourly_heating_load - borefield.load.hourly_heating_load))
521+
external_load.set_hourly_cooling_load(
522+
np.maximum(0, building_load.hourly_cooling_load - borefield.load.hourly_cooling_load))
523+
external_load.set_hourly_dhw_load(
524+
np.maximum(0, building_load.hourly_dhw_load - borefield.load.hourly_dhw_load))
525+
526+
return borefield.load, external_load

0 commit comments

Comments
 (0)