Skip to content
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

Feature: heat pump cop profiles #393

Merged
merged 19 commits into from
Jul 1, 2024
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
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
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

* **ADD** Spatial resolution that aligns with the regions defined by the [e-Highway 2050 project](https://cordis.europa.eu/project/id/308908/reporting) (`ehighways`) (#370).

* **ADD** fully-electrified heat demand (#284, #343, #390, #391) and heat pumps with variable COP to meet that demand (#393).
* **ADD** fully-electrified heat demand (#284, #343, #389) and heat pumps with variable COP to meet that demand (#80).
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems you've implemented most parts of #39, too. Is that correct? If yes, would add that too.


* **ADD** fully-electrified road transportation (#270, #271, #358). A parameter allows to define the share of uncontrolled (timeseries) vs controlled charging (optimised) by the solver (#338). Data for controlled charging constraints is readily available (#356), but corresponding constraints are not yet implemented (#385).

Expand Down
12 changes: 9 additions & 3 deletions Snakefile
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,8 @@ rule techs_and_locations_template:
params:
scaling_factors = config["scaling-factors"],
capacity_factors = config["capacity-factors"]["average"],
max_power_densities = config["parameters"]["maximum-installable-power-density"]
max_power_densities = config["parameters"]["maximum-installable-power-density"],
heat_pump_shares = config["parameters"]["heat-pump"]["heat-pump-shares"],
wildcard_constraints:
tech_group = "(?!transmission).*" # i.e. all but transmission
conda: "envs/default.yaml"
Expand Down Expand Up @@ -166,8 +167,10 @@ rule model_template:
"techs/supply/rooftop-solar.yaml",
"techs/supply/wind-offshore.yaml",
"techs/supply/nuclear.yaml",
"techs/supply/heat.yaml",
]
),
cop_data = "build/models/{resolution}/timeseries/supply/heat_pump_cop.csv",
capacityfactor_timeseries_data = expand(
"build/models/{{resolution}}/timeseries/supply/capacityfactors-{technology}.csv",
technology=ALL_CF_TECHNOLOGIES
Expand Down Expand Up @@ -241,9 +244,12 @@ rule test:
capacity_factor_timeseries = expand(
"build/models/{{resolution}}/timeseries/supply/capacityfactors-{technology}.csv",
technology=ALL_CF_TECHNOLOGIES
)
),
unscaled_space_heat = "build/data/heat/hourly_unscaled_heat_demand.nc",
cop = "build/models/{resolution}/timeseries/supply/heat_pump_cop.csv"
params:
config = config
config = config,
test_args = [] # add e.g. "--pdb" to enter ipdb on test failure
log: "build/logs/{resolution}/test-report.html"
output: "build/logs/{resolution}/test.success"
conda: "./envs/test.yaml"
Expand Down
23 changes: 9 additions & 14 deletions config/default.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -189,41 +189,36 @@ parameters:
nuts-year: 2013
heat:
space_heat:
carnot-performance: 0.36 # [Nouvel_2015]
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor cleanup here to remove some unused configs and update refs (still more to do)

gas-eff: 0.97 # [DEA_2016], but 70-80% according to [Qu_2014]
oil-eff: 0.9 # [DEA_2016], but 0.63 according to [martin_2014]
gas-eff: 0.97 # [@DEA:2020], but 70-80% according to [Qu_2014]
oil-eff: 0.9 # [@DEA:2020], but 0.63 according to [martin_2014]
solid-fossil-eff: 0.8 # Assume same as biofuel
biofuel-eff: 0.8 # [mermoud_2015] [Chandrasekaran_2013] [DEA_2016]
biofuel-eff: 0.8 # [@DEA:2020] [mermoud_2015] [Chandrasekaran_2013] [DEA_2016]
solar-thermal-eff: 1 # Eurostat energy balances method
electricity-eff: 1 # must be 1 for the time being (we assume 1 -> 1 electricity -> heat conversion)
space-heat-temp: 36 # degrees C [Nouvel_2015]
hp-cop: 3.5
water_heat:
gas-eff: 0.97 # [DEA_2016], but 70-80% according to [Qu_2014]
oil-eff: 0.9 # [DEA_2016], but 0.63 according to [martin_2014]
hot_water:
gas-eff: 0.97 # [@DEA:2020], but 70-80% according to [Qu_2014]
oil-eff: 0.9 # [@DEA:2020], but 0.63 according to [martin_2014]
solid-fossil-eff: 0.8 # Assume same as biofuel
biofuel-eff: 0.8 # [mermoud_2015] [Chandrasekaran_2013] [DEA_2016]
solar-thermal-eff: 1 # Eurostat energy balances method
electricity-eff: 1 # must be 1 for the time being (we assume 1 -> 1 electricity -> heat conversion)
water-heat-temp: 52 # degrees C [Nouvel_2015]
hp-cop: 3.5
cooking:
gas-eff: 0.28 # [Karunanithy_2016]
oil-eff: 0.28 # [Karunanithy_2016] assuming oil == gas efficiency
solid-fossil-eff: 0.15 # [Ramanathan_1994] scaled down 60%, based on values calculated by [Karunanithy_2016]
biofuel-eff: 0.1 # [Ramanathan_1994] scaled down 60%, based on values calculated by [Karunanithy_2016]
electricity-eff: 0.5 # [Karunanithy_2016] based on 2/3 40% efficient direct electric, 1/3 70% efficient induction
heat-pump:
sink-temperature:
sink-temperature: # All values are assumed.
underfloor: 35
radiator-large: 50
radiator-conventional: 65
hot-water: 60
space-heat-sink-ratio: # All values are assumed.
space-heat-sink-shares: # All values are assumed.
underfloor: 0.1
radiator-large: 0.15
radiator-conventional: 0.75
heat-pump-ratio: # see https://stats.ehpa.org, 2018 market data assuming current ashp = air-to-air AND air-to-water
heat-pump-shares: # see https://stats.ehpa.org, 2018 market data assuming current ashp = air-to-air AND air-to-water
ashp: 0.9
gshp: 0.1
correction-factor: 0.85 # [@Ruhnau:2019]
brynpickering marked this conversation as resolved.
Show resolved Hide resolved
Expand Down
29 changes: 8 additions & 21 deletions config/schema.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -458,9 +458,6 @@ properties:
description: Parameters of space heating technologies.
additionalProperties: false
properties:
carnot-performance:
type: number
description: Carnot performance.
gas-eff:
type: number
description: Gas boiler efficiency.
Expand All @@ -479,13 +476,7 @@ properties:
electricity-eff:
type: number
description: Electric heater efficiency.
space-heat-temp:
type: number
description: Space heating temperature.
hp-cop:
type: number
description: Heat pump coefficient of performance.
water_heat:
hot_water:
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

water_heat -> hot_water applied everywhere for consistency

type: object
description: Parameters of water heating technologies.
additionalProperties: false
Expand All @@ -508,12 +499,6 @@ properties:
electricity-eff:
type: number
description: Electric heater efficiency.
water-heat-temp:
type: number
description: Water heating temperature.
hp-cop:
type: number
description: Heat pump coefficient of performance.
cooking:
type: object
description: Parameters of cooking technologies.
Expand Down Expand Up @@ -557,9 +542,9 @@ properties:
hot-water:
type: integer
description: Hot water temperature (not space heating).
space-heat-sink-ratio:
space-heat-sink-shares:
type: object
description: Ratio of building stock assumed to have different space heating methods
description: Share of building stock assumed to have different space heating methods
additionalProperties: false
properties:
underfloor:
Expand All @@ -571,9 +556,11 @@ properties:
radiator-conventional:
type: number
description: Conventional radiators (usually installed alongside gas boilers).
heat-pump-ratio:
heat-pump-shares:
type: object
description: Assumed ratio of air- vs ground-source heat pumps. Must sum to 1.
description: >-
Market share of air- vs ground-source heat pumps, which will be used to create a generic "heat pump" technology.
Must sum to 1.
additionalProperties: false
properties:
ashp:
Expand All @@ -590,7 +577,7 @@ properties:
type: number
minimum: 0
maximum: 1
description: Correction factor to go from manufacturer data to 'real' systems
description: Correction factor to go from manufacturer data on heat pump performance to 'real' system performance.
scope:
type: object
description: Spatial and temporal scope bounding the models.
Expand Down
1 change: 1 addition & 0 deletions envs/default.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ dependencies:
- hdf5=1.12.2
- libnetcdf=4.8.1
- scipy=1.9.1
brynpickering marked this conversation as resolved.
Show resolved Hide resolved
- dask=2023.2.0
- pip:
- -e ./lib
- styleframe==4.2
2 changes: 2 additions & 0 deletions lib/eurocalliopelib/template.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from pathlib import Path

import jinja2
import numpy as np

from eurocalliopelib import filters

Expand All @@ -20,6 +21,7 @@ def parametrise_template(path_to_template, path_to_output_yaml, **kwargs):
undefined=jinja2.StrictUndefined, # This ensures that missing pandas index elements raise an exception instead of silently returning None
)
env.filters["unit"] = filters.unit
env.globals["mean"] = np.mean
rendered = env.get_template(path_to_template.name).render(**kwargs)

with open(path_to_output_yaml, "w") as result_file:
Expand Down
26 changes: 17 additions & 9 deletions rules/heat.smk
Original file line number Diff line number Diff line change
Expand Up @@ -102,35 +102,43 @@ rule population_per_weather_gridbox:


rule unscaled_heat_profiles:
message: "Generate gridded heat demand profile shapes for {wildcards.year} from weather and population data"

message: "Generate gridded heat demand profile shapes from weather and population data"
input:
population = rules.population_per_weather_gridbox.output[0],
wind_speed = "data/automatic/gridded-weather/wind10m.nc",
temperature = "data/automatic/gridded-weather/temperature.nc",
when2heat = rules.download_when2heat_params.output[0]
params:
first_year = config["scope"]["temporal"]["first-year"],
final_year = config["scope"]["temporal"]["final-year"],
conda: "../envs/default.yaml"
output: "build/data/{resolution}/hourly_unscaled_heat_demand.nc"
output: "build/data/heat/hourly_unscaled_heat_demand.nc"
script: "../scripts/heat/unscaled_heat_profiles.py"


rule heat_pump_cop:
message: "Generate {wildcards.resolution} heat pump coefficient of performance (COP)"
message: "Generate gridded heat pump coefficient of performance (COP)"
input:
temperature_air = "data/automatic/gridded-weather/temperature.nc",
temperature_ground = "data/automatic/gridded-weather/tsoil5.nc",
population = rules.population_per_weather_gridbox.output[0],
heat_pump_characteristics = rules.download_heat_pump_characteristics.output[0]
params:
sink_temperature = config["parameters"]["heat-pump"]["sink-temperature"],
space_heat_sink_ratio = config["parameters"]["heat-pump"]["space-heat-sink-ratio"],
space_heat_sink_shares = config["parameters"]["heat-pump"]["space-heat-sink-shares"],
correction_factor = config["parameters"]["heat-pump"]["correction-factor"],
heat_pump_ratio = config["parameters"]["heat-pump"]["heat-pump-ratio"],
heat_pump_shares = config["parameters"]["heat-pump"]["heat-pump-shares"],
first_year = config["scope"]["temporal"]["first-year"],
final_year = config["scope"]["temporal"]["final-year"],
conda: "../envs/default.yaml"
output: "build/data/{resolution}/heat_pump_cop.nc"
output: "build/data/heat/heat_pump_cop.nc"
script: "../scripts/heat/heat_pump_cop.py"

rule group_gridded_timeseries:
message: "Generate {wildcards.resolution} {wildcards.input_dataset} timeseries data from gridded data "
input:
gridded_timeseries_data = "build/data/heat/{input_dataset}.nc",
grid_weights = rules.population_per_weather_gridbox.output[0],
annual_demand = rules.rescale_annual_heat_demand_to_resolution.output.total_demand
conda: "../envs/default.yaml"
threads: 4
output: "build/models/{resolution}/timeseries/supply/{input_dataset}.csv"
script: "../scripts/heat/group_gridded_timeseries.py"
19 changes: 10 additions & 9 deletions scripts/heat/annual_heat_demand.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
END_USE_CAT_NAMES = {
"FC_OTH_HH_E_CK": "cooking",
"FC_OTH_HH_E_SH": "space_heat",
"FC_OTH_HH_E_WH": "water_heat",
"FC_OTH_HH_E_WH": "hot_water",
}

CH_ENERGY_CARRIER_TRANSLATION = {
Expand All @@ -28,7 +28,7 @@

CH_HH_END_USE_TRANSLATION = {
"Raumwärme": "space_heat",
"Warmwasser": "water_heat",
"Warmwasser": "hot_water",
"Prozesswärme": "process_heat",
"Beleuchtung": "end_use_electricity",
"Klima, Lüftung, HT": "end_use_electricity",
Expand Down Expand Up @@ -271,9 +271,10 @@ def _get_rows_to_update(df):
renewables_carriers = renewables.drop("RA000", axis=1).sum(axis=1, min_count=1)
renewables_all = renewables.xs("RA000", axis=1)
# Only update those rows where the sum of renewable energy carriers != RA000
return renewables.loc[
~np.isclose(renewables_carriers, renewables_all)
], renewables_carriers
return (
renewables.loc[~np.isclose(renewables_carriers, renewables_all)],
renewables_carriers,
)

to_update, renewables_carriers = _get_rows_to_update(df)
# Some rows have no data other than RA000, so we need to assign that data to one of the
Expand Down Expand Up @@ -311,7 +312,7 @@ def read_ch_hh_final_demand(path_to_ch_end_use: str) -> pd.DataFrame:
skipfooter=8,
translation=CH_ENERGY_CARRIER_TRANSLATION,
)
water_heat = get_ch_sheet(
hot_water = get_ch_sheet(
path_to_ch_end_use,
"Tabelle 20",
skipfooter=5,
Expand All @@ -327,8 +328,8 @@ def read_ch_hh_final_demand(path_to_ch_end_use: str) -> pd.DataFrame:

df = (
pd.concat(
[space_heat, water_heat, cooking],
keys=("space_heat", "water_heat", "cooking"),
[space_heat, hot_water, cooking],
keys=("space_heat", "hot_water", "cooking"),
names=["cat_name", "carrier_name"],
)
.assign(country_code="CHE")
Expand Down Expand Up @@ -638,7 +639,7 @@ def get_national_useful_heat_demand(
"""

demands = []
for end_use in ["space_heat", "water_heat", "cooking"]:
for end_use in ["space_heat", "hot_water", "cooking"]:
_demand = (
annual_final_energy_demand.loc[[end_use]]
.mul(efficiencies(heat_tech_params[end_use]), level="carrier_name", axis=0)
Expand Down
88 changes: 88 additions & 0 deletions scripts/heat/group_gridded_timeseries.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
from functools import partial
from multiprocessing import Pool

import pandas as pd
import xarray as xr


def group_gridcells(
gridded_data: xr.Dataset,
grid_weight: xr.DataArray,
annual_demand: xr.DataArray,
threads: int,
) -> pd.DataFrame:
"""Group gridded heat data into resolution-specific units, taking a weighted average of grid values.

Also take a weighted average of hot water and space heat demand (using per-unit annual demands) to return a single "heat" timeseries.

Args:
gridded_data (xr.Dataset): Gridded timeseries space heat and hot water data.
grid_weight (xr.DataArray): Weighted mapping from grid (a.k.a. "site") to units.
annual_demand (xr.DataArray): Per-unit annual space heating and hot water demands.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I find it hard to understand why this input is necessary. Can you explain? Is this a bias-correction? Or is it because the time series have no meaningful scale / are normed?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's doing two sets of weighted averages: weighted averaging "sites" into "IDs" (using population) and weighted averaging "space heat" and "hot water" demand into a general profile for "heat" (using annual demands).

threads (int): Number of threads over which to undertake multiprocessing.

Returns:
pd.DataFrame: Index: timeseries, Columns: unit IDs
"""

partial_func = partial(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe rename function to "apply_weights" or similar?

_site_weighted_ave, gridded_data=gridded_data, grid_weight=grid_weight
)
# This is a slow operation, so we parallelise it.
with Pool(threads) as pool:
per_id_averages = pool.map(partial_func, grid_weight.id.values)
weighted_average_ds = xr.concat(per_id_averages, dim="id")

weighted_average_da = (
weighted_average_ds.to_array("end_use")
.groupby("time.year")
.apply(_end_use_weighted_ave, annual_demand=annual_demand)
)
return weighted_average_da.astype("float32").to_series().unstack("id")


def prepare_annual_demand(annual_demand: pd.Series) -> xr.DataArray:
"""Restructure annual demand MultiIndex series into a multi-dimensional array.

Result sums over all building categories and only contains hot water and space heating demands (not cooking).
"""
return (
annual_demand.rename_axis(columns="id")
.stack()
.to_xarray()
.sel(end_use=["space_heat", "hot_water"])
.sum("cat_name")
)


def _site_weighted_ave(
id: str, gridded_data: xr.Dataset, grid_weight: xr.DataArray
) -> xr.Dataset:
"""Multi-processing helper function to get the weighted average of all gridcells for a given spatial unit (id)."""
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is nothing "multi-processing" related in this function, right? Better remove from comment in that case.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

More that it only exists because it is needs to be its own function so that it can be called by the multiprocessor.

id_grid_weight = grid_weight.sel(id=id).dropna("site")
return (
gridded_data.reindex_like(id_grid_weight)
# we normalise the weights in case they aren't already
* (id_grid_weight / id_grid_weight.sum("site"))
).sum(["site"])


def _end_use_weighted_ave(
one_year_profile: xr.DataArray, annual_demand: xr.DataArray
) -> xr.DataArray:
"""Take a weighted average of all heat energy end uses using annual demands per spatial unit as the weights."""
year = one_year_profile.time.dt.year[0]
normalised_demand = annual_demand / annual_demand.sum("end_use")
demand = one_year_profile * normalised_demand.sel(year=year).drop("year")
return demand.sum("end_use")


if __name__ == "__main__":
gridded_data = xr.open_dataset(snakemake.input.gridded_timeseries_data)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

First use of this approach in the repository. The line will make history. :D

grid_weights = xr.open_dataarray(snakemake.input.grid_weights)
annual_demand = pd.read_csv(snakemake.input.annual_demand, index_col=[0, 1, 2])
annual_demand_ds = prepare_annual_demand(annual_demand)
resolution_specific_data = group_gridcells(
gridded_data, grid_weights, annual_demand_ds, snakemake.threads
)
resolution_specific_data.to_csv(snakemake.output[0])
Loading
Loading