diff --git a/doc/configtables/offwind-ac.csv b/doc/configtables/offwind-ac.csv index 9dc0614c..b2533f04 100644 --- a/doc/configtables/offwind-ac.csv +++ b/doc/configtables/offwind-ac.csv @@ -2,7 +2,7 @@ cutout,--,"Should be a folder listed in the configuration ``atlite: cutouts:`` (e.g. 'europe-2013-era5') or reference an existing folder in the directory ``cutouts``. Source module must be ERA5.","Specifies the directory where the relevant weather data ist stored." resource,,, -- method,--,"Must be 'wind'","A superordinate technology type." --- turbine,--,"One of turbine types included in `atlite `_","Specifies the turbine type and its characteristic power curve." +-- turbine,--,"One of turbine types included in `atlite `_. Can be a string or a dictionary with years as keys which denote the year another turbine model becomes available.","Specifies the turbine type and its characteristic power curve." capacity_per_sqkm,:math:`MW/km^2`,float,"Allowable density of wind turbine placement." correction_factor,--,float,"Correction factor for capacity factor time series." excluder_resolution,m,float,"Resolution on which to perform geographical elibility analysis." diff --git a/doc/configtables/offwind-dc.csv b/doc/configtables/offwind-dc.csv index c947f358..7c537543 100644 --- a/doc/configtables/offwind-dc.csv +++ b/doc/configtables/offwind-dc.csv @@ -2,7 +2,7 @@ cutout,--,"Should be a folder listed in the configuration ``atlite: cutouts:`` (e.g. 'europe-2013-era5') or reference an existing folder in the directory ``cutouts``. Source module must be ERA5.","Specifies the directory where the relevant weather data ist stored." resource,,, -- method,--,"Must be 'wind'","A superordinate technology type." --- turbine,--,"One of turbine types included in `atlite `__","Specifies the turbine type and its characteristic power curve." +-- turbine,--,"One of turbine types included in `atlite `_. Can be a string or a dictionary with years as keys which denote the year another turbine model becomes available.","Specifies the turbine type and its characteristic power curve." capacity_per_sqkm,:math:`MW/km^2`,float,"Allowable density of wind turbine placement." correction_factor,--,float,"Correction factor for capacity factor time series." excluder_resolution,m,float,"Resolution on which to perform geographical elibility analysis." diff --git a/doc/configtables/onwind.csv b/doc/configtables/onwind.csv index f6b36e5d..3b09214b 100644 --- a/doc/configtables/onwind.csv +++ b/doc/configtables/onwind.csv @@ -2,7 +2,7 @@ cutout,--,"Should be a folder listed in the configuration ``atlite: cutouts:`` (e.g. 'europe-2013-era5') or reference an existing folder in the directory ``cutouts``. Source module must be ERA5.","Specifies the directory where the relevant weather data ist stored." resource,,, -- method,--,"Must be 'wind'","A superordinate technology type." --- turbine,--,"One of turbine types included in `atlite `__","Specifies the turbine type and its characteristic power curve." +-- turbine,--,"One of turbine types included in `atlite `_. Can be a string or a dictionary with years as keys which denote the year another turbine model becomes available.","Specifies the turbine type and its characteristic power curve." capacity_per_sqkm,:math:`MW/km^2`,float,"Allowable density of wind turbine placement." corine,,, -- grid_codes,--,"Any subset of the `CORINE Land Cover code list `_","Specifies areas according to CORINE Land Cover codes which are generally eligible for wind turbine placement." diff --git a/doc/configtables/solar.csv b/doc/configtables/solar.csv index 8328d342..18587694 100644 --- a/doc/configtables/solar.csv +++ b/doc/configtables/solar.csv @@ -2,7 +2,7 @@ cutout,--,"Should be a folder listed in the configuration ``atlite: cutouts:`` (e.g. 'europe-2013-era5') or reference an existing folder in the directory ``cutouts``. Source module can be ERA5 or SARAH-2.","Specifies the directory where the relevant weather data ist stored that is specified at ``atlite/cutouts`` configuration. Both ``sarah`` and ``era5`` work." resource,,, -- method,--,"Must be 'pv'","A superordinate technology type." --- panel,--,"One of {'Csi', 'CdTe', 'KANENA'} as defined in `atlite `__","Specifies the solar panel technology and its characteristic attributes." +-- panel,--,"One of {'Csi', 'CdTe', 'KANENA'} as defined in `atlite `_ . Can be a string or a dictionary with years as keys which denote the year another turbine model becomes available.","Specifies the solar panel technology and its characteristic attributes." -- orientation,,, -- -- slope,°,"Realistically any angle in [0., 90.]","Specifies the tilt angle (or slope) of the solar panel. A slope of zero corresponds to the face of the panel aiming directly overhead. A positive tilt angle steers the panel towards the equator." -- -- azimuth,°,"Any angle in [0., 360.]","Specifies the `azimuth `_ orientation of the solar panel. South corresponds to 180.°." diff --git a/doc/release_notes.rst b/doc/release_notes.rst index 7a123b9e..976d727e 100644 --- a/doc/release_notes.rst +++ b/doc/release_notes.rst @@ -79,6 +79,12 @@ Upcoming Release Energiewende (2021) `_. +* Added option to specify turbine and solar panel models for specific years as a + dictionary (e.g. ``renewable: onwind: resource: turbine:``). The years will be + interpreted as years from when the the corresponding turbine model substitutes + the previous model for new installations. This will only have an effect on + workflows with foresight "myopic" and still needs to be added foresight option + "perfect". PyPSA-Eur 0.9.0 (5th January 2024) diff --git a/rules/solve_myopic.smk b/rules/solve_myopic.smk index 94e75a5d..e8817de6 100644 --- a/rules/solve_myopic.smk +++ b/rules/solve_myopic.smk @@ -51,7 +51,16 @@ rule add_brownfield: H2_retrofit=config["sector"]["H2_retrofit"], H2_retrofit_capacity_per_CH4=config["sector"]["H2_retrofit_capacity_per_CH4"], threshold_capacity=config["existing_capacities"]["threshold_capacity"], + snapshots={k: config["snapshots"][k] for k in ["start", "end", "inclusive"]}, + carriers=config["electricity"]["renewable_carriers"], input: + **{ + f"profile_{tech}": RESOURCES + f"profile_{tech}.nc" + for tech in config["electricity"]["renewable_carriers"] + if tech != "hydro" + }, + simplify_busmap=RESOURCES + "busmap_elec_s{simpl}.csv", + cluster_busmap=RESOURCES + "busmap_elec_s{simpl}_{clusters}.csv", network=RESULTS + "prenetworks/elec_s{simpl}_{clusters}_l{ll}_{opts}_{sector_opts}_{planning_horizons}.nc", network_p=solved_previous_horizon, #solved network at previous time step diff --git a/scripts/add_brownfield.py b/scripts/add_brownfield.py index 00a729b0..3b77c437 100644 --- a/scripts/add_brownfield.py +++ b/scripts/add_brownfield.py @@ -11,8 +11,10 @@ import logging import numpy as np import pandas as pd import pypsa +import xarray as xr from _helpers import update_config_with_sector_opts from add_existing_baseyear import add_build_year_to_new_assets +from pypsa.clustering.spatial import normed_or_uniform logger = logging.getLogger(__name__) idx = pd.IndexSlice @@ -143,6 +145,57 @@ def disable_grid_expansion_if_LV_limit_hit(n): n.global_constraints.drop("lv_limit", inplace=True) +def adjust_renewable_profiles(n, input_profiles, params, year): + """ + Adjusts renewable profiles according to the renewable technology specified, + using the latest year below or equal to the selected year. + """ + + # spatial clustering + cluster_busmap = pd.read_csv(snakemake.input.cluster_busmap, index_col=0).squeeze() + simplify_busmap = pd.read_csv( + snakemake.input.simplify_busmap, index_col=0 + ).squeeze() + clustermaps = simplify_busmap.map(cluster_busmap) + clustermaps.index = clustermaps.index.astype(str) + + # temporal clustering + dr = pd.date_range(**params["snapshots"], freq="h") + snapshotmaps = ( + pd.Series(dr, index=dr).where(lambda x: x.isin(n.snapshots), pd.NA).ffill() + ) + + for carrier in params["carriers"]: + if carrier == "hydro": + continue + with xr.open_dataset(getattr(input_profiles, "profile_" + carrier)) as ds: + if ds.indexes["bus"].empty or "year" not in ds.indexes: + continue + + closest_year = max( + (y for y in ds.year.values if y <= year), default=min(ds.year.values) + ) + + p_max_pu = ( + ds["profile"] + .sel(year=closest_year) + .transpose("time", "bus") + .to_pandas() + ) + + # spatial clustering + weight = ds["weight"].sel(year=closest_year).to_pandas() + weight = weight.groupby(clustermaps).transform(normed_or_uniform) + p_max_pu = (p_max_pu * weight).T.groupby(clustermaps).sum().T + p_max_pu.columns = p_max_pu.columns + f" {carrier}" + + # temporal_clustering + p_max_pu = p_max_pu.groupby(snapshotmaps).mean() + + # replace renewable time series + n.generators_t.p_max_pu.loc[:, p_max_pu.columns] = p_max_pu + + if __name__ == "__main__": if "snakemake" not in globals(): from _helpers import mock_snakemake @@ -167,6 +220,8 @@ if __name__ == "__main__": n = pypsa.Network(snakemake.input.network) + adjust_renewable_profiles(n, snakemake.input, snakemake.params, year) + add_build_year_to_new_assets(n, year) n_p = pypsa.Network(snakemake.input.network_p) diff --git a/scripts/add_electricity.py b/scripts/add_electricity.py index d67e2fa2..9fd7e4a5 100755 --- a/scripts/add_electricity.py +++ b/scripts/add_electricity.py @@ -385,6 +385,10 @@ def attach_wind_and_solar( if ds.indexes["bus"].empty: continue + # if-statement for compatibility with old profiles + if "year" in ds.indexes: + ds = ds.sel(year=ds.year.min(), drop=True) + supcar = car.split("-", 2)[0] if supcar == "offwind": underwater_fraction = ds["underwater_fraction"].to_pandas() diff --git a/scripts/build_renewable_profiles.py b/scripts/build_renewable_profiles.py index 6c450aca..a075450d 100644 --- a/scripts/build_renewable_profiles.py +++ b/scripts/build_renewable_profiles.py @@ -200,7 +200,7 @@ if __name__ == "__main__": if "snakemake" not in globals(): from _helpers import mock_snakemake - snakemake = mock_snakemake("build_renewable_profiles", technology="solar") + snakemake = mock_snakemake("build_renewable_profiles", technology="offwind-dc") configure_logging(snakemake) nprocesses = int(snakemake.threads) @@ -208,6 +208,13 @@ if __name__ == "__main__": noprogress = noprogress or not snakemake.config["atlite"]["show_progress"] params = snakemake.params.renewable[snakemake.wildcards.technology] resource = params["resource"] # pv panel params / wind turbine params + + tech = next(t for t in ["panel", "turbine"] if t in resource) + models = resource[tech] + if not isinstance(models, dict): + models = {0: models} + resource[tech] = models[next(iter(models))] + correction_factor = params.get("correction_factor", 1.0) capacity_per_sqkm = params["capacity_per_sqkm"] snapshots = snakemake.params.snapshots @@ -323,22 +330,40 @@ if __name__ == "__main__": duration = time.time() - start logger.info(f"Completed average capacity factor calculation ({duration:2.2f}s)") - logger.info("Calculate weighted capacity factor time series...") - start = time.time() + profiles = [] + capacities = [] + for year, model in models.items(): - profile, capacities = func( - matrix=availability.stack(spatial=["y", "x"]), - layout=layout, - index=buses, - per_unit=True, - return_capacity=True, - **resource, - ) + logger.info( + f"Calculate weighted capacity factor time series for model {model}..." + ) + start = time.time() - duration = time.time() - start - logger.info( - f"Completed weighted capacity factor time series calculation ({duration:2.2f}s)" - ) + resource[tech] = model + + profile, capacity = func( + matrix=availability.stack(spatial=["y", "x"]), + layout=layout, + index=buses, + per_unit=True, + return_capacity=True, + **resource, + ) + + dim = {"year": [year]} + profile = profile.expand_dims(dim) + capacity = capacity.expand_dims(dim) + + profiles.append(profile.rename("profile")) + capacities.append(capacity.rename("weight")) + + duration = time.time() - start + logger.info( + f"Completed weighted capacity factor time series calculation for model {model} ({duration:2.2f}s)" + ) + + profiles = xr.merge(profiles) + capacities = xr.merge(capacities) logger.info("Calculating maximal capacity per bus") p_nom_max = capacity_per_sqkm * availability @ area @@ -365,8 +390,8 @@ if __name__ == "__main__": ds = xr.merge( [ - (correction_factor * profile).rename("profile"), - capacities.rename("weight"), + correction_factor * profiles, + capacities, p_nom_max.rename("p_nom_max"), potential.rename("potential"), average_distance.rename("average_distance"), @@ -386,9 +411,13 @@ if __name__ == "__main__": ds["underwater_fraction"] = xr.DataArray(underwater_fraction, [buses]) # select only buses with some capacity and minimal capacity factor + mean_profile = ds["profile"].mean("time") + if "year" in ds.indexes: + mean_profile = mean_profile.max("year") + ds = ds.sel( bus=( - (ds["profile"].mean("time") > params.get("min_p_max_pu", 0.0)) + (mean_profile > params.get("min_p_max_pu", 0.0)) & (ds["p_nom_max"] > params.get("min_p_nom_max", 0.0)) ) ) diff --git a/scripts/prepare_sector_network.py b/scripts/prepare_sector_network.py index 4948d451..394a67f8 100755 --- a/scripts/prepare_sector_network.py +++ b/scripts/prepare_sector_network.py @@ -421,6 +421,11 @@ def update_wind_solar_costs(n, costs): tech = "offwind-" + connection profile = snakemake.input["profile_offwind_" + connection] with xr.open_dataset(profile) as ds: + + # if-statement for compatibility with old profiles + if "year" in ds.indexes: + ds = ds.sel(year=ds.year.min(), drop=True) + underwater_fraction = ds["underwater_fraction"].to_pandas() connection_cost = ( snakemake.params.length_factor