Skip to content

Couple TES temperatures to district heating supply temperatures #1612

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 65 commits into from
Mar 31, 2025
Merged
Show file tree
Hide file tree
Changes from 62 commits
Commits
Show all changes
65 commits
Select commit Hold shift + click to select a range
55f4d0e
introduce etpr for TES using stores
TomKae00 Feb 12, 2025
6f684d3
Master into branch
TomKae00 Feb 12, 2025
2f67385
add constraints
TomKae00 Feb 13, 2025
8dedc43
copleted implementation of constraints for TES stores
TomKae00 Feb 16, 2025
2a44a18
Merge branch 'master' into ltes_stores
TomKae00 Feb 16, 2025
ca2d24c
updated release notes
TomKae00 Feb 16, 2025
3576274
adjusted mock snakemake default
TomKae00 Feb 16, 2025
e40fcf6
library change
TomKae00 Feb 16, 2025
a16db94
merge master into branch
TomKae00 Feb 17, 2025
b46ec98
update release notes
TomKae00 Feb 18, 2025
ee3b5f9
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Feb 18, 2025
84ebe30
Delete .DS_Store
TomKae00 Feb 18, 2025
df3fc81
change retrieval of etpr values in etpr_constraint
TomKae00 Feb 18, 2025
9662bf2
Merge branch 'ltes_stores' of https://github.com/TomKae00/pypsa-eur i…
TomKae00 Feb 18, 2025
599867d
changed TES constraints check based on the configs
TomKae00 Feb 19, 2025
69e0809
constraint checks
TomKae00 Feb 19, 2025
68740c6
enhance error handling for the tes constraints
TomKae00 Feb 25, 2025
cf9c4e4
Merge branch 'master' into ltes_stores
TomKae00 Feb 25, 2025
b538ee7
small changes
TomKae00 Feb 25, 2025
ef05a0e
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Feb 25, 2025
ec0f962
changed mock snakemake back to default settings
TomKae00 Feb 25, 2025
7933ec2
Merge branch 'ltes_stores' of https://github.com/TomKae00/pypsa-eur i…
TomKae00 Feb 25, 2025
8ab2e84
Merge branch 'master' into ltes_stores
amos-schledorn Feb 26, 2025
bcc9f67
Merge branch 'master' into ltes_stores
amos-schledorn Mar 3, 2025
c45b19b
feat: add marginal cost for water tank chargers
amos-schledorn Mar 3, 2025
b231dd3
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Mar 3, 2025
53e778a
docs: update add_retrofit_gas_boiler_constraint docs
amos-schledorn Mar 3, 2025
3052766
docs: update return type annotation for get_heat_system_type method
amos-schledorn Mar 3, 2025
d76df97
feat: add heat vents for decentral and rural heating
amos-schledorn Mar 3, 2025
20b734c
Merge remote-tracking branch 'origin/price-water-tank-chargers' into …
amos-schledorn Mar 3, 2025
665e407
feat: remove unnecessary central_heat_vent config
amos-schledorn Mar 3, 2025
0af1d58
feat: pass heat vent marginal cost to prepare_sector_network
amos-schledorn Mar 3, 2025
b0536c2
refactor: rename marginal_cost_storage to marginal_cost_home_battery_…
amos-schledorn Mar 3, 2025
677c555
docs: update configtables
amos-schledorn Mar 3, 2025
a041def
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Mar 3, 2025
b3f1e80
docs: update release notes
amos-schledorn Mar 3, 2025
a6524c4
Merge remote-tracking branch 'origin/price-water-tank-chargers' into …
amos-schledorn Mar 3, 2025
5ba0af2
Merge branch 'master' into price-water-tank-chargers
amos-schledorn Mar 4, 2025
e3721e7
add suggestions to ensure that charger and discharger align for charg…
TomKae00 Mar 4, 2025
ad71120
Merge branch 'ltes_stores' of https://github.com/TomKae00/pypsa-eur i…
TomKae00 Mar 4, 2025
96f8b5d
Merge remote-tracking branch 'upstream/price-water-tank-chargers' int…
TomKae00 Mar 4, 2025
ca85eff
add lifetime to PTES and TTES charger/discharger, so that they are bu…
TomKae00 Mar 4, 2025
ba87ae5
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Mar 4, 2025
631b648
Merge branch 'master' into ltes_stores
amos-schledorn Mar 7, 2025
6bc6123
feat: add small penalty on ptes charging
amos-schledorn Mar 7, 2025
f02155a
feat: update fixed cost to capital cost str
amos-schledorn Mar 7, 2025
1c1624f
feat: remove unnecessary passing of obsolete config
amos-schledorn Mar 7, 2025
02495b5
feat: remove unneeded config passing
amos-schledorn Mar 7, 2025
5c46a65
feat: remove unneeded config passing
amos-schledorn Mar 7, 2025
e2eedb7
feat: remove unneeded config passing
amos-schledorn Mar 7, 2025
5b32b8e
Update scripts/solve_network.py
amos-schledorn Mar 7, 2025
ec51d01
Update solve_network.py
amos-schledorn Mar 7, 2025
6c5128c
feat: re-add TES necessary parameters
amos-schledorn Mar 7, 2025
bcc0334
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Mar 7, 2025
64a54d5
Merge remote-tracking branch 'TomK_fork/ltes_stores' into e_max_pu_ptes
Mar 13, 2025
29dc64d
normalize by max temperature spread
Mar 17, 2025
9d9b960
Merge remote-tracking branch 'upstream/master' into e_max_pu_ptes
Mar 24, 2025
428b5fc
add district heating temperatures as input for prepare_sector_network
Mar 25, 2025
a47dfc5
adjust reference deltaT
Mar 25, 2025
c76fd80
address review and allow for myopic foresight
Mar 26, 2025
71deb9b
added docs and release note
Mar 26, 2025
28d8ece
remove unused function arguments and snakemake inputs, add filepath t…
Mar 27, 2025
98bfd19
address final review
Mar 28, 2025
328b3f6
adjust .rst syntax
Mar 28, 2025
29ce774
Merge branch 'master' into e_max_pu_ptes
fneum Mar 28, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions config/config.default.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -471,6 +471,10 @@ sector:
upper_threshold_ambient_temperature: 10
rolling_window_ambient_temperature: 72
relative_annual_temperature_reduction: 0.01
ptes:
dynamic_capacity: true
max_top_temperature: 90 # °C
min_bottom_temperature: # °C
heat_source_cooling: 6 #K
heat_pump_cop_approximation:
refrigerant: ammonia
Expand Down
4 changes: 4 additions & 0 deletions doc/configtables/sector.csv
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ district_heating,--,,`prepare_sector_network.py <https://github.com/PyPSA/pypsa-
-- -- upper_threshold_ambient_temperature,C,float, Assume `min_forward_temperature` if ambient temperature is above this threshold
-- -- rolling_window_ambient_temperature, h, int, Rolling window size for averaging ambient temperature when approximating supply temperature
-- -- relative_annual_temperature_reduction,, float, Relative annual reduction of district heating forward and return temperature - defaults to 0.01 (1%)
-- ptes,,,
-- -- dynamic_capacity,--,"{true, false}",Add option for dynamic temperature-dependent capacity of pit storage in district heating
-- -- max_top_temperature,C,float,The maximum top temperature of the pit storage according to DEA technology catalogue (2018)
-- -- min_bottom_temperature,C,float,The minimum bottom temperature of the pit storage according to DEA technology catalogue (2018)
-- heat_source_cooling,K,float,Cooling of heat source for heat pumps
-- heat_pump_cop_approximation,,,
-- -- refrigerant,--,"{ammonia, isobutane}",Heat pump refrigerant assumed for COP approximation
Expand Down
4 changes: 4 additions & 0 deletions doc/release_notes.rst
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ Release Notes
Upcoming Release
================

* Added option to use dynamic capacity for pit storage using the `e_max_pu` attribute of the store component, which is calculated in the new rule `build_tes_capacity_profiles` and added to the network in `prepare_sector_network`.
The dynamic capacity linearly depends on the temperature spread within the storage, which is coupled to the forward and return flow temperatures of the district heating network.
The feature can be turned off by setting ``sector: district_heating: ptes: dynamic_capacity: false`` in the configuration file.

* Added rule :mod:`build_co2_sequestration_potentials`, which processes the raw data from `CO2Stop <https://setis.ec.europa.eu/european-co2-storage-
database_en>`_. Integrated from separate repository (https://github.com/ericzhou571/Co2Storage).

Expand Down
48 changes: 48 additions & 0 deletions rules/build_sector.smk
Original file line number Diff line number Diff line change
Expand Up @@ -380,6 +380,45 @@ rule build_direct_heat_source_utilisation_profiles:
"../scripts/build_direct_heat_source_utilisation_profiles.py"


rule build_tes_capacity_profiles:
params:
max_top_temperature=config_provider(
"sector",
"district_heating",
"ptes",
"max_top_temperature",
),
min_bottom_temperature=config_provider(
"sector",
"district_heating",
"ptes",
"min_bottom_temperature",
),
snapshots=config_provider("snapshots"),
input:
central_heating_forward_temperature_profiles=resources(
"central_heating_forward_temperature_profiles_base_s_{clusters}_{planning_horizons}.nc"
),
central_heating_return_temperature_profiles=resources(
"central_heating_return_temperature_profiles_base_s_{clusters}_{planning_horizons}.nc"
),
regions_onshore=resources("regions_onshore_base_s_{clusters}.geojson"),
output:
ptes_e_max_pu_profiles=resources(
"ptes_e_max_pu_profiles_base_s_{clusters}_{planning_horizons}.nc"
),
resources:
mem_mb=2000,
log:
logs("build_tes_capacity_profiles_s_{clusters}_{planning_horizons}.log"),
benchmark:
benchmarks("build_tes_capacity_profiles/s_{clusters}_{planning_horizons}")
conda:
"../envs/environment.yaml"
script:
"../scripts/build_tes_capacity/run.py"


def solar_thermal_cutout(wildcards):
c = config_provider("solar_thermal", "cutout")(wildcards)
if c == "default":
Expand Down Expand Up @@ -1209,6 +1248,15 @@ rule prepare_sector_network:
temp_soil_total=resources("temp_soil_total_base_s_{clusters}.nc"),
temp_air_total=resources("temp_air_total_base_s_{clusters}.nc"),
cop_profiles=resources("cop_profiles_base_s_{clusters}_{planning_horizons}.nc"),
ptes_e_max_pu_profiles=(
resources(
"ptes_e_max_pu_profiles_base_s_{clusters}_{planning_horizons}.nc"
)
if config_provider(
"sector", "district_heating", "ptes", "dynamic_capacity"
)
else []
),
solar_thermal_total=lambda w: (
resources("solar_thermal_total_base_s_{clusters}.nc")
if config_provider("sector", "solar_thermal")(w)
Expand Down
4 changes: 4 additions & 0 deletions rules/solve_myopic.smk
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,10 @@ rule add_brownfield:
drop_leap_day=config_provider("enable", "drop_leap_day"),
carriers=config_provider("electricity", "renewable_carriers"),
heat_pump_sources=config_provider("sector", "heat_pump_sources"),
tes=config_provider("sector", "tes"),
dynamic_ptes_capacity=config_provider(
"sector", "district_heating", "ptes", "dynamic_capacity"
),
input:
unpack(input_profile_tech_brownfield),
simplify_busmap=resources("busmap_base_s.csv"),
Expand Down
35 changes: 35 additions & 0 deletions scripts/add_brownfield.py
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,38 @@ def update_heat_pump_efficiency(n: pypsa.Network, n_p: pypsa.Network, year: int)
)


def update_dynamic_ptes_capacity(n, n_p, year):
"""
Updates dynamic pit storage capacity based on district heating temperature changes.

Parameters
----------
n : pypsa.Network
Original network.
n_p : pypsa.Network
Network with updated parameters.
year : int
Target year for capacity update.

Returns
-------
None
Updates capacity in-place.
"""
# pit storages in previous iteration
dynamic_ptes_idx_previous_iteration = n_p.stores.index[
n_p.stores.index.str.contains("water pits")
]
# construct names of same-technology dynamic pit storage in the current iteration
corresponding_idx_this_iteration = dynamic_ptes_idx_previous_iteration.str[
:-4
] + str(year)
# update pit storage capacity in previous iteration in-place to capacity in this iteration
n_p.stores_t.e_max_pu[dynamic_ptes_idx_previous_iteration] = n.stores_t.e_max_pu[
corresponding_idx_this_iteration
].values


if __name__ == "__main__":
if "snakemake" not in globals():
from _helpers import mock_snakemake
Expand Down Expand Up @@ -324,6 +356,9 @@ def update_heat_pump_efficiency(n: pypsa.Network, n_p: pypsa.Network, year: int)

update_heat_pump_efficiency(n, n_p, year)

if snakemake.params.tes and snakemake.params.dynamic_ptes_capacity:
update_dynamic_ptes_capacity(n, n_p, year)

add_brownfield(
n,
n_p,
Expand Down
88 changes: 88 additions & 0 deletions scripts/build_tes_capacity/run.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# SPDX-FileCopyrightText: Contributors to PyPSA-Eur <https://github.com/pypsa/pypsa-eur>
#
# SPDX-License-Identifier: MIT
"""
Calculate dynamic pit thermal energy storage (PTES) capacity profiles based on
district heating forward and return flow temperatures. The linear relation between
temperature difference and capacity is taken from Sorknaes (2018).

The capacity of thermal energy storage systems varies with the temperature difference
between the forward and return flows in district heating networks assuming a direct
integration of the storage. This script calculates normalized capacity factors (e_max_pu)
for PTES systems based on these temperature differences.

Source
------
Sorknæs, P. 2018. "Simulation method for a pit seasonal thermal energy storage system with a heat pump in a district heating system", Energy, Volume 152, https://doi.org/10.1016/j.energy.2018.03.152.

Relevant Settings
-----------------

.. code:: yaml
sector:
district_heating:
dynamic_ptes_capacity: true

Inputs
------
- `resources/<run_name>/central_heating_forward_temperature_profiles.nc`: Forward flow temperature profiles
- `resources/<run_name>/central_heating_return_temperature_profiles.nc`: Return flow temperature profiles

Outputs
-------
- `resources/<run_name>/ptes_e_max_pu_profiles.nc`: Normalized PTES capacity profiles
"""

import logging

import xarray as xr
from _helpers import set_scenario_config
from tes_capacity_approximator import TesCapacityApproximator

logger = logging.getLogger(__name__)

if __name__ == "__main__":
if "snakemake" not in globals():
from _helpers import mock_snakemake

snakemake = mock_snakemake(
"build_tes_parameters",
clusters=48,
planning_horizons="2050",
)

set_scenario_config(snakemake)

# Load temperature profiles
logger.info("Loading district heating temperature profiles")
t_forward = xr.open_dataarray(
snakemake.input.central_heating_forward_temperature_profiles
)
t_return = xr.open_dataarray(
snakemake.input.central_heating_return_temperature_profiles
)

# Define operational limits for PTES
max_top_temperature = snakemake.params.get("max_top_temperature", 90) # °C
min_bottom_temperature = snakemake.params.get("min_bottom_temperature", 35) # °C

logger.info(
f"Calculating PTES capacity profiles with max temperature {max_top_temperature}°C"
)

# Create TES capacity approximator
tes_capacity_approximator = TesCapacityApproximator(
top_temperature=t_forward,
bottom_temperature=t_return,
max_top_temperature=max_top_temperature,
min_bottom_temperature=min_bottom_temperature,
)

# Calculate e_max_pu
e_max_pu = tes_capacity_approximator.calculate_e_max_pu()

# Save output
logger.info(
f"Saving PTES capacity profiles to {snakemake.output.ptes_e_max_pu_profiles}"
)
e_max_pu.to_netcdf(snakemake.output.ptes_e_max_pu_profiles)
104 changes: 104 additions & 0 deletions scripts/build_tes_capacity/tes_capacity_approximator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
# SPDX-FileCopyrightText: Contributors to PyPSA-Eur <https://github.com/pypsa/pypsa-eur>
#
# SPDX-License-Identifier: MIT

import xarray as xr


class TesCapacityApproximator:
"""
A class to approximate thermal energy storage (TES) capacity based on temperature data.

This class calculates the normalized temperature difference between the top and bottom
layers of a pit thermal energy storage (PTES) system, which determines the available
energy storage capacity. The top temperature is clipped to a maximum operational
limit of 90°C.

Attributes
----------
top_temperature : xr.DataArray
The top temperature data (forward flow temperature).
bottom_temperature : xr.DataArray
The bottom temperature data (return flow temperature).
max_top_temperature : float
Maximum operational temperature of top layer in PTES, default 90°C.
min_bottom_temperature : float
Minimum operational temperature of bottom layer in PTES, default 35°C.
"""

def __init__(
self,
top_temperature: xr.DataArray,
bottom_temperature: xr.DataArray,
max_top_temperature: float = 90,
min_bottom_temperature: float = 35,
):
"""
Initialize TesCapacityApproximator.

Parameters
----------
top_temperature : xr.DataArray
The top temperature data (forward flow temperature).
bottom_temperature : xr.DataArray
The bottom temperature data (return flow temperature).
max_top_temperature : float, optional
Maximum operational temperature of top layer in PTES, default 90°C.
min_bottom_temperature : float, optional
Minimum operational temperature of bottom layer in PTES, default 35°C.
"""
self.top_temperature = top_temperature
self.bottom_temperature = bottom_temperature
self.max_top_temperature = max_top_temperature
self.min_bottom_temperature = min_bottom_temperature

@property
def clipped_top_temperature(self) -> xr.DataArray:
"""
Clip top temperature to maximum operational limit.

Returns
-------
xr.DataArray
Top temperature clipped to maximum operational limit.
"""
return self.top_temperature.where(
self.top_temperature <= self.max_top_temperature, self.max_top_temperature
)

@property
def e_max_pu(self) -> xr.DataArray:
"""
Calculate the normalized delta T for TES capacity in relation to
max and min temperature.

Returns
-------
xr.DataArray
Normalized delta T values between 0 and 1, representing the
available storage capacity as a percentage of maximum capacity.
"""
delta_t = self.clipped_top_temperature - self.bottom_temperature
normalized_delta_t = delta_t / (
self.max_top_temperature - self.min_bottom_temperature
)
return normalized_delta_t.clip(min=0) # Ensure non-negative values

def calculate_e_max_pu(self, nodes=None) -> xr.DataArray:
"""
Method to compute e_max_pu based on the top and bottom temperatures.

Parameters
----------
nodes : list or pd.Index, optional
Subset of nodes to select from the temperature data.

Returns
-------
xr.DataArray
Computed e_max_pu values, optionally filtered for specific nodes.
"""
result = self.e_max_pu
if nodes is not None:
result = result.sel(name=nodes)
return result
18 changes: 16 additions & 2 deletions scripts/prepare_sector_network.py
Original file line number Diff line number Diff line change
Expand Up @@ -2760,6 +2760,7 @@ def add_heat(
cop_profiles_file: str,
direct_heat_source_utilisation_profile_file: str,
hourly_heat_demand_total_file: str,
ptes_e_max_pu_file: str,
district_heat_share_file: str,
solar_thermal_total_file: str,
retro_cost_file: str,
Expand Down Expand Up @@ -3172,18 +3173,30 @@ def add_heat(
p_nom_extendable=True,
lifetime=costs.at["central water pit storage", "lifetime"],
)

n.links.loc[
nodes + f" {heat_system} water pits charger",
"energy to power ratio",
] = energy_to_power_ratio_water_pit

if options["district_heating"]["ptes"]["dynamic_capacity"]:
# Load pre-calculated e_max_pu profiles
e_max_pu_data = xr.open_dataarray(ptes_e_max_pu_file)
e_max_pu = (
e_max_pu_data.sel(name=nodes)
.to_pandas()
.reindex(index=n.snapshots)
)
else:
e_max_pu = 1

n.add(
"Store",
nodes + f" {heat_system} water pits",
nodes,
suffix=f" {heat_system} water pits",
bus=nodes + f" {heat_system} water pits",
e_cyclic=True,
e_nom_extendable=True,
e_max_pu=e_max_pu,
carrier=f"{heat_system} water pits",
standing_loss=1 - np.exp(-1 / 24 / tes_time_constant_days),
capital_cost=costs.at["central water pit storage", "capital_cost"],
Expand Down Expand Up @@ -6075,6 +6088,7 @@ def add_import_options(
cop_profiles_file=snakemake.input.cop_profiles,
direct_heat_source_utilisation_profile_file=snakemake.input.direct_heat_source_utilisation_profiles,
hourly_heat_demand_total_file=snakemake.input.hourly_heat_demand_total,
ptes_e_max_pu_file=snakemake.input.ptes_e_max_pu_profiles,
district_heat_share_file=snakemake.input.district_heat_share,
solar_thermal_total_file=snakemake.input.solar_thermal_total,
retro_cost_file=snakemake.input.retro_cost,
Expand Down