TNO Intern

Draft: Resolve "Update the heat pump mode of operation"

Closes #102

Merge request reports

Loading
+90 −13
Changes for docs/theory/energyconversion/directheatHP.md: 90 added lines, 13 removed lines.
Original line number Diff line number Diff line
@@ -3,22 +3,99 @@
## Direct heat with industrial Heat Pump

 Below is a schematic representation of the direct heat production with an industrial heat pump system, 
 which effectively allows to increase outlet temperature for a district heating network, 
 relative to the geothermal source production temperature
 which effectively allows the system to increase outlet temperature for a district heating network, 
 relative to the geothermal source production temperature.

### Determining the Injection Temperature

![Figure description](../../images/cartoon_heatpump.png)

*Figure 2: a schematic representation of the system.
On the left is the primary circuit of geothermal brine with temperature Tprod, 
assumed equal to the aquifer temperature Taq. 
The temperature is first lowered by amount ΔT1 in the heat exchanger. 
Next, the temperature is lowered by amount ΔT2 in the heat pump to the injection temperature Tinj. 
On the right hand side is the district heating network. 
The cool water returning from the network at temperature Tdh_out is first heated in the heat exchanger 
to Tdh_out + ΔT1, and then by the heatpump to the heat network inlet temperature Tdh_in.
It is assumed that condenser and evaporator temperatures of the heatpump are respectively 3°C lower and higher 
than the source and outlet temperature of the heat pump*
On the left is the primary circuit of geothermal brine while on the right is the circuit of the district-heating system.

The model has four elements:

1. A Geothermal system, Tprod is the temperature of the water produced from the reservoir, while Tinj is the temperature of the water pumped back into the aquifer.
2. A District heating system, Tdh_out is the water that comes _from_ the district heating system, while Tdh_in is the water that goes _to_ the district heating system
3. A Heat Exchanger, which transfers heat from the production water of the Geothermal system to the district-heating system.
4. A Heat Pump, which consumes grid electricity to further heat up the district-heating water and cool the geothermal system water. 

When modelling the system:

1. The production temperature, Tprod, is assumed equal to the temperature of the reservoir.
2. The district heating return temperature, Tdh_out, is known (set by the user, default: 35°C).
3. The district heating input temperature, Tdh_in, is known (set by the user, default: 70°C).
4. The total difference between the district heating input and district heating return temperature, ΔT.
5. The minimum absolute injection temperature and the maximum temperature difference between reservoir and injection temperature is also specified by the user, and is used as a condition on the final injection temperature.

This leaves the Injection temperature Tinj, as the remaining unknown.

This is calculated using the equation:

Tinj = Tprod - ΔT1 - ΔT2

where:

ΔT1 = Tprod - Tdh_out, must be between 0 -> ΔT (user specified)
ΔT2 = Tdh_in - (Tdh_out + ΔT1)
 
The final Tinj is then corrected using an absolute and relative injection temperature boundary, ensuring the injection temperature is:

- Always above the absolute minimum injection temperature
- The difference between Tprod - Tinj is never greater than the maximum allowed difference.

### Example 1: Heat pump required

Using the following input: 

- Tprod = 60°C
- Tdh_in = 70°C
- Tdh_out = 35°C
- ΔT = Tdh_in - Tdh_out = 35°C

It is clear that the production temperature of the aquifer is not hot enough to fully supply the district-heating system without assistance from the heat pump, as the temperature from the aquifer is 10°C colder than the temperature required by the district heating systm.
This means that the extra heat will be provided by the heatpump.

Transferred from geothermal heating system to district heating system:

ΔT1 = Tprod - Tdh_out, 60°C - 35°C = 25°C.

Heat additionally added by the heat pump to the district heating system:

ΔT2 = Tdh_in - (Tdh_out + ΔT1) = 70°C - 60°C = 10°C

Injection temperature is the production temperature minus the two deltas above: 

Tinj = Tprod - ΔT1 - ΔT2 = 60°C - 25°C - 10°C = 25°C

### Example 2: Heat pump _not_ required

What happens when the production temperature from the aquifer is hot enough that the heatpump is not required?

- Tprod = 75°C
- Tdh_in = 70°C
- Tdh_out = 35°C
- ΔT = Tdh_in - Tdh_out = 35°C

It is clear that the production temperature of the aquifer is not hot enough to fully supply the district-heating system without assistance from the heat pump, as the temperature from the aquifer is 10°C colder than the temperature required by the district heating systm.
This means that the extra heat will be provided by the heatpump.

Transferred from geothermal heating system to district heating system:

ΔT1 = Tprod - Tdh_out, 75°C - 35°C = 40°C.
40°C < 35°C therefore ΔT1 = 35°C  

This means that the heat exchanger is able to provide all the heat necessary to the district heating system.

Heat additionally added by the heat pump to the district heating system:

ΔT2 = Tdh_in - (Tdh_out + ΔT1) = 70°C - (35°C + 35°C) = 0°C

Injection temperature is the production temperature minus the two deltas above: 

Tinj = Tprod - ΔT1 - ΔT2 = 75°C - 35°C - 0°C = 35°C

### Calculating the Coefficient of Performance (COP)

The COP of the heat pump is given by the following equation:

@@ -32,13 +109,13 @@ corrected for the conversion efficiency and any parasitic power losses.
For direct heat it does not include added heat in the heatpump from the compressor.
The reason not to include this is to account for geothermal energy only. This setting can be changed by setting

### Default values

Proposed parameters are the following:
Proposed default parameters are the following:

| parameter                                                              | symbol         | value  | unit     |
|------------------------------------------------------------------------|----------------|--------|----------|
| use heat pump                                                          | HPuse          | True   | bool     |
| HP application target temperature is Tdh_in                            | HPapp          | True   | bool     |
| target district heat outlet temperature                                | Tdh_in         | 80     | °C       |
| minimum aquifer production temperature                                 | Tminprod       | 40     | °C       |
| maximum cooling temperature for reinjection relative to reservoir (ΔT) | ΔTmax          | 40-200 | °C       |
+6 −0
Changes for docs/changelog.md: 6 added lines, 0 removed lines.
Original line number Diff line number Diff line
# Change log

## v1.3.0 (30-1-2026)

- After an issue was flagged with the heat pump module changes were implemented in the core java code, tested using the ThermoGIS Automated workflow and the GA4A projects. 
The ThermoGIS Jar has been updated and the documentation on the heatpump greatly expanded.
- The tests on the GA4A scenario XML have been extended to test expected values. 

## v1.2.5 (26-1-2026)
A java 17 virtual machine is now automatically installed the first time a pythermogis simulation is run. This uses the [install-jdk](https://pypi.org/project/install-jdk/) python project.

(134 B)

File changed.

File stored in LFS. Learn more.

+15 −14
Changes for tests/java/test_ThermoGISDoublet_Benchmark.py: 15 added lines, 14 removed lines.
Original line number Diff line number Diff line
@@ -211,18 +211,18 @@ class ThermoGISDoubletBenchmark(TestCase):
        # Act
        results = doublet.calculateDoubletPerformance(input)

        print(results)
        # Assert
        self.assertTrue(np.isclose(17499.99940, transmissivity, 0.001))
        self.assertTrue(np.isclose(163.99771118164062, results.flow(), 1))
        self.assertTrue(np.isclose(60, results.pres(), 0.001))
        self.assertTrue(np.isclose(4.97566556930542, results.power(), 0.001))
        self.assertTrue(np.isclose(989.2578125, results.welld(), 0.001))
        self.assertTrue(np.isclose(4.383212200392486, results.cop(), 0.001))
        self.assertTrue(np.isclose(10.401010755009017, results.utc(), 0.001))
        self.assertTrue(np.isclose(16.50359210062243, results.capex(), 0.001))

    @pytest.mark.skip("This test requires a fix in the java core. ignore until fix is pushed")
    def test_calculateDoubletPerformance_directheatHP_App(self):
        self.assertTrue(np.isclose(5.6734843, results.power(), 0.001))
        self.assertTrue(np.isclose(955.27344, results.welld(), 0.001))
        self.assertTrue(np.isclose(3.8904712, results.cop(), 0.001))
        self.assertTrue(np.isclose(10.368587, results.utc(), 0.001))
        self.assertTrue(np.isclose(17.378712, results.capex(), 0.001))

    def test_calculateDoubletPerformance_directheatHP2(self):
        """
        This is a copy of a test from the Java ThermoGisDoubletTest.java script; to validate that this python implementation of the ThermoGIS Doublet
        returns the same values.
@@ -240,7 +240,6 @@ class ThermoGISDoubletBenchmark(TestCase):
                          .setOpexPerPower(100)
                          .setOpexBase(0)
                          .setHpDirectHeatInputTemp(80)
                          #.setHpApplicationMode(True)
                          .setUseHeatPump(True)
                          .setDhReturnTemp(50)
                          .setViscosityMode(ViscosityMode.KESTIN)
@@ -267,15 +266,17 @@ class ThermoGISDoubletBenchmark(TestCase):
        # Act
        results = doublet.calculateDoubletPerformance(input)

        print(results)

        # Assert
        self.assertTrue(np.isclose(17499.99940, transmissivity, 0.001))
        self.assertTrue(np.isclose(163.99771118164062, results.flow(), 1))
        self.assertTrue(np.isclose(60, results.pres(), 0.001))
        self.assertTrue(np.isclose(4.97566556930542, results.power(), 0.001))
        self.assertTrue(np.isclose(989.2578125, results.welld(), 0.001))
        self.assertTrue(np.isclose(4.383212200392486, results.cop(), 0.001))
        self.assertTrue(np.isclose(10.401010755009017, results.utc(), 0.001))
        self.assertTrue(np.isclose(16.50359210062243, results.capex(), 0.001))
        self.assertTrue(np.isclose(5.6734843, results.power(), 0.001))
        self.assertTrue(np.isclose(955.27344, results.welld(), 0.001))
        self.assertTrue(np.isclose(2.6190805, results.cop(), 0.001))
        self.assertTrue(np.isclose(13.150635, results.utc(), 0.001))
        self.assertTrue(np.isclose(19.1619, results.capex(), 0.001))

    def test_calculateDoubletPerformance_ORC(self):
        """
+20 −7
Changes for tests/java/test_utc_properties.py: 20 added lines, 7 removed lines.
Original line number Diff line number Diff line
@@ -4,6 +4,7 @@ from pythermogis import *

class UTCBuilder(TestCase):
    scenarios_file_path =  Path(__file__).parent.parent / "resources" / "test_input" / "scenarios"
    benchmark_path =  Path(__file__).parent.parent / "resources" / "test_benchmark_output" / "ga4a"

    def test_tg_scenario_xmls_parse_and_run(self):
        # These are the current 2.4 scenario settings, for BaseCase, HP, Stim and HP&Stim
@@ -16,13 +17,17 @@ class UTCBuilder(TestCase):
    def test_ga4a_scenario_xmls_parse_and_run(self):
        # The ga4a scenarios no longer work as they are missing necessary input parameters; they were generated by an older version of TG, I corrected them using the gui, producing a new set of configs.
        scenarios = ["ga4a_ORC_new_config.xml", "ga4a_directheat_new_config.xml", "ga4a_directheatHP_new_config.xml", "ga4a_chiller_new_config.xml"]
        utc_properties = [instantiate_utc_properties_from_xml(self.scenarios_file_path / scenario) for scenario in scenarios]

        # If test reaches here, then parsing of xml's worked, check that the scenarios actually run on a set of test data:
        [self.run_scenario(utc_property) for utc_property in utc_properties]

    def run_scenario(self, utc_properties):
        input_data = xr.Dataset({
        # If test reaches here, then parsing of xml's worked, check that the scenarios actually run on a set of test data:
        for scenario in scenarios:
            utc_properties = instantiate_utc_properties_from_xml(self.scenarios_file_path / scenario)
            benchmark_path = (self.benchmark_path / scenario).with_suffix(".nc")
            self.run_scenario(utc_properties, benchmark_path)

    def run_scenario(self, utc_properties, benchmark_path: Path = None):
        input_data = xr.Dataset(
            {
                "thickness_mean": ((), 300),
                "thickness_sd": ((), 50),
                "ntg": ((), 0.5),
@@ -30,6 +35,14 @@ class UTCBuilder(TestCase):
                "depth": ((), 5000),
                "ln_permeability_mean": ((), 5),
                "ln_permeability_sd": ((), 0.5),
        })
            }
        )

        results = calculate_doublet_performance_stochastic(
            input_data,
            utc_properties=utc_properties,
            rng_seed=0
        )

        calculate_doublet_performance_stochastic(input_data, utc_properties=utc_properties)
        if benchmark_path is not None:
            xr.testing.assert_equal(results, xr.load_dataset(benchmark_path))
Loading
Loading