diff --git a/config/config.default.yaml b/config/config.default.yaml index 0f01f2396..a9162feca 100644 --- a/config/config.default.yaml +++ b/config/config.default.yaml @@ -1223,6 +1223,12 @@ plotting: residential urban decentral water tanks discharger: '#baac9e' services rural water tanks discharger: '#bbc2b8' services urban decentral water tanks discharger: '#bdd8d3' + water pits: "#cc826a" + water pits charger: "#b36a5e" + water pits discharger: "#b37468" + urban central water pits: "#d96f4c" + urban central water pits charger: "#a85d47" + urban central water pits discharger: "#b36452" # heat demand Heat load: '#cc1f1f' heat: '#cc1f1f' diff --git a/doc/release_notes.rst b/doc/release_notes.rst index 225593d68..08c9de899 100644 --- a/doc/release_notes.rst +++ b/doc/release_notes.rst @@ -13,6 +13,8 @@ Upcoming Release - Bugfix: Changed setting ``central_heat_vent`` (default: ``true``), because the water tanks charger and discharger were used as heat vents with an efficiency of 0.9. +- Implemented an energy-to-power ratio constraint for TES, linking storage capacity to the corresponding charger capacity. Additionally, chargers and dischargers are now sized identically through a unified constraint. + * Bugfix: Geothermal heat potentials are now restricted to those in close proximity to future district heating areas as projected by Manz et al. 2024. Includes a refactoring change: Building of generic technical potentials from heat utilisation potentials was changed to specific computation of geothermal heat potentials. - Bug fix: Added setting ``run: use_shadow_directory:`` (default: ``true``) which sets the ``shadow`` parameter of the snakemake workflow. Configuring to ``true`` sets snakemake ``shadow`` parameter to ``shalloow``, ``false`` to `Ǹone``. Should be set to ``false`` for those cases, where snakemake has an issue with finding missing input/output files in solving rules. diff --git a/scripts/prepare_sector_network.py b/scripts/prepare_sector_network.py index 4690acf59..96a02a137 100755 --- a/scripts/prepare_sector_network.py +++ b/scripts/prepare_sector_network.py @@ -2607,12 +2607,20 @@ def add_heat( unit="MWh_th", ) + energy_to_power_ratio_water_tanks = costs.at[ + heat_system.central_or_decentral + " water tank storage", + "energy to power ratio", + ] + n.add( "Link", nodes + f" {heat_system} water tanks charger", bus0=nodes + f" {heat_system} heat", bus1=nodes + f" {heat_system} water tanks", - efficiency=costs.at["water tank charger", "efficiency"], + efficiency=costs.at[ + heat_system.central_or_decentral + " water tank charger", + "efficiency", + ], carrier=f"{heat_system} water tanks charger", p_nom_extendable=True, ) @@ -2623,10 +2631,17 @@ def add_heat( bus0=nodes + f" {heat_system} water tanks", bus1=nodes + f" {heat_system} heat", carrier=f"{heat_system} water tanks discharger", - efficiency=costs.at["water tank discharger", "efficiency"], + efficiency=costs.at[ + heat_system.central_or_decentral + " water tank discharger", + "efficiency", + ], p_nom_extendable=True, ) + n.links.loc[ + nodes + f" {heat_system} water tanks charger", "energy to power ratio" + ] = energy_to_power_ratio_water_tanks + tes_time_constant_days = options["tes_tau"][ heat_system.central_or_decentral ] @@ -2647,6 +2662,64 @@ def add_heat( ], ) + if heat_system == HeatSystem.URBAN_CENTRAL: + n.add("Carrier", f"{heat_system} water pits") + + n.add( + "Bus", + nodes + f" {heat_system} water pits", + location=nodes, + carrier=f"{heat_system} water pits", + unit="MWh_th", + ) + + energy_to_power_ratio_water_pit = costs.at[ + "central water pit storage", "energy to power ratio" + ] + + n.add( + "Link", + nodes + f" {heat_system} water pits charger", + bus0=nodes + f" {heat_system} heat", + bus1=nodes + f" {heat_system} water pits", + efficiency=costs.at[ + "central water pit charger", + "efficiency", + ], + carrier=f"{heat_system} water pits charger", + p_nom_extendable=True, + ) + + n.add( + "Link", + nodes + f" {heat_system} water pits discharger", + bus0=nodes + f" {heat_system} water pits", + bus1=nodes + f" {heat_system} heat", + carrier=f"{heat_system} water pits discharger", + efficiency=costs.at[ + "central water pit discharger", + "efficiency", + ], + p_nom_extendable=True, + ) + + n.links.loc[ + nodes + f" {heat_system} water pits charger", + "energy to power ratio", + ] = energy_to_power_ratio_water_pit + + n.add( + "Store", + nodes + f" {heat_system} water pits", + bus=nodes + f" {heat_system} water pits", + e_cyclic=True, + e_nom_extendable=True, + 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", "fixed"], + lifetime=costs.at["central water pit storage", "lifetime"], + ) + if options["resistive_heaters"]: key = f"{heat_system.central_or_decentral} resistive heater" diff --git a/scripts/solve_network.py b/scripts/solve_network.py index b3fe65006..e850860fa 100644 --- a/scripts/solve_network.py +++ b/scripts/solve_network.py @@ -33,6 +33,7 @@ import sys from typing import Any +import linopy import numpy as np import pandas as pd import pypsa @@ -794,6 +795,104 @@ def add_operational_reserve_margin(n, sns, config): n.model.add_constraints(lhs <= rhs, name="Generator-p-reserve-upper") +def add_TES_energy_to_power_ratio_constraints(n: pypsa.Network) -> None: + """ + Add TES constraints to the network. + + For each TES storage unit, enforce: + Store-e_nom - etpr * Link-p_nom == 0 + + Parameters + ---------- + n : pypsa.Network + A PyPSA network with TES and heating sectors enabled. + + Raises + ------ + ValueError + If no valid TES storage or charger links are found. + """ + indices_charger_p_nom_extendable = n.links.index[ + n.links.index.str.contains("water tanks charger|water pits charger") + & n.links.p_nom_extendable + ] + indices_stores_e_nom_extendable = n.stores.index[ + n.stores.index.str.contains("water tanks|water pits") + & n.stores.e_nom_extendable + ] + + if indices_charger_p_nom_extendable.empty or indices_stores_e_nom_extendable.empty: + raise ValueError( + "No valid extendable charger links or stores found for TES energy to power constraints." + ) + + energy_to_power_ratio_values = n.links.loc[ + indices_charger_p_nom_extendable, "energy to power ratio" + ].values + + linear_expr_list = [] + for charger, tes, energy_to_power_value in zip( + indices_charger_p_nom_extendable, + indices_stores_e_nom_extendable, + energy_to_power_ratio_values, + ): + charger_var = n.model["Link-p_nom"].loc[charger] + store_var = n.model["Store-e_nom"].loc[tes] + linear_expr = store_var - energy_to_power_value * charger_var + linear_expr_list.append(linear_expr) + + # Merge the individual expressions + merged_expr = linopy.expressions.merge( + linear_expr_list, dim="Store-ext, Link-ext", cls=type(linear_expr_list[0]) + ) + + n.model.add_constraints(merged_expr == 0, name="TES_energy_to_power_ratio") + + +def add_TES_charger_ratio_constraints(n: pypsa.Network) -> None: + """ + Add TES charger ratio constraints. + + For each TES unit, enforce: + Link-p_nom(charger) - efficiency * Link-p_nom(discharger) == 0 + + Parameters + ---------- + n : pypsa.Network + A PyPSA network with TES and heating sectors enabled. + + Raises + ------ + ValueError + If no valid TES discharger or charger links are found. + """ + indices_charger_p_nom_extendable = n.links.index[ + n.links.index.str.contains("water tanks charger|water pits charger") + & n.links.p_nom_extendable + ] + indices_discharger_p_nom_extendable = n.links.index[ + n.links.index.str.contains("water tanks discharger|water pits discharger") + & n.links.p_nom_extendable + ] + + if ( + indices_charger_p_nom_extendable.empty + or indices_discharger_p_nom_extendable.empty + ): + raise ValueError( + "No valid extendable TES discharger or charger links found for TES charger ratio constraints." + ) + + eff_discharger = n.links.efficiency[indices_discharger_p_nom_extendable].values + lhs = ( + n.model["Link-p_nom"].loc[indices_charger_p_nom_extendable] + - n.model["Link-p_nom"].loc[indices_discharger_p_nom_extendable] + * eff_discharger + ) + + n.model.add_constraints(lhs == 0, name="TES_charger_ratio") + + def add_battery_constraints(n): """ Add constraint ensuring that charger = discharger, i.e. @@ -997,6 +1096,33 @@ def extra_functionality( ): add_solar_potential_constraints(n, config) + if n.config.get("sector", {}).get("tes", False): + if ( + n.buses.index.str.lower() + .str.contains( + r"urban central heat|urban decentral heat|rural heat", + case=False, + na=False, + ) + .any() + ): + add_TES_energy_to_power_ratio_constraints(n) + add_TES_charger_ratio_constraints(n) + elif ( + n.links.index.str.lower() + .str.contains("pits charger|tanks charger", case=False, na=False) + .any() + or n.stores.index.str.lower() + .str.contains("pits", case=False, na=False) + .any() + or n.stores.index.str.lower() + .str.contains("tanks", case=False, na=False) + .any() + ): + raise ValueError( + "Unsupported network configuration: tes is enabled but no heating bus was found." + ) + add_battery_constraints(n) add_lossy_bidirectional_link_constraints(n) add_pipe_retrofit_constraint(n)