From a834ff222acac9175b66285077b35baf7ceeb037 Mon Sep 17 00:00:00 2001 From: Fabian Neumann Date: Mon, 5 Feb 2024 12:10:35 +0100 Subject: [PATCH] streamline code for year-dependent technologies (turbines/panels) --- config/config.default.yaml | 17 ++---- doc/configtables/offwind-ac.csv | 2 +- doc/configtables/offwind-dc.csv | 2 +- doc/configtables/onwind.csv | 2 +- doc/configtables/solar.csv | 2 +- doc/release_notes.rst | 6 ++ rules/build_electricity.smk | 1 - rules/solve_myopic.smk | 3 + scripts/add_brownfield.py | 73 ++++++++-------------- scripts/add_electricity.py | 4 ++ scripts/build_renewable_profiles.py | 95 ++++++++++++----------------- scripts/prepare_sector_network.py | 5 ++ 12 files changed, 90 insertions(+), 122 deletions(-) diff --git a/config/config.default.yaml b/config/config.default.yaml index bdbc046e..bc420b36 100644 --- a/config/config.default.yaml +++ b/config/config.default.yaml @@ -164,14 +164,11 @@ atlite: # docs in https://pypsa-eur.readthedocs.io/en/latest/configuration.html#renewable renewable: - year: 2020 onwind: cutout: europe-2013-era5 resource: method: wind - turbine: - 2020: Vestas_V112_3MW - 2030: NREL_ReferenceTurbine_2020ATB_5.5MW + turbine: Vestas_V112_3MW add_cutout_windspeed: true capacity_per_sqkm: 3 # correction_factor: 0.93 @@ -190,9 +187,7 @@ renewable: cutout: europe-2013-era5 resource: method: wind - turbine: - 2020: NREL_ReferenceTurbine_5MW_offshore.yaml - 2030: NREL_ReferenceTurbine_2020ATB_15MW_offshore + turbine: NREL_ReferenceTurbine_5MW_offshore add_cutout_windspeed: true capacity_per_sqkm: 2 correction_factor: 0.8855 @@ -208,10 +203,7 @@ renewable: cutout: europe-2013-era5 resource: method: wind - turbine: - 2020: Vestas_V164_7MW_offshore - 2025: NREL_ReferenceTurbine_2020ATB_15MW_offshore - 2030: NREL_ReferenceTurbine_2020ATB_18MW_offshore + turbine: Vestas_V164_7MW_offshore add_cutout_windspeed: true capacity_per_sqkm: 2 correction_factor: 0.8855 @@ -227,8 +219,7 @@ renewable: cutout: europe-2013-sarah resource: method: pv - panel: - 2020: CSi + panel: CSi orientation: slope: 35. azimuth: 180. 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 a3331748..6bf78fcd 100644 --- a/doc/release_notes.rst +++ b/doc/release_notes.rst @@ -71,6 +71,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/build_electricity.smk b/rules/build_electricity.smk index 5846dd9d..6b092638 100644 --- a/rules/build_electricity.smk +++ b/rules/build_electricity.smk @@ -261,7 +261,6 @@ rule build_renewable_profiles: params: snapshots={k: config["snapshots"][k] for k in ["start", "end", "inclusive"]}, renewable=config["renewable"], - foresight=config["foresight"], input: **opt, base_network=RESOURCES + "networks/base.nc", diff --git a/rules/solve_myopic.smk b/rules/solve_myopic.smk index a5f2b12b..49df9d1e 100644 --- a/rules/solve_myopic.smk +++ b/rules/solve_myopic.smk @@ -85,10 +85,13 @@ 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", diff --git a/scripts/add_brownfield.py b/scripts/add_brownfield.py index 912b01ae..3b77c437 100644 --- a/scripts/add_brownfield.py +++ b/scripts/add_brownfield.py @@ -145,78 +145,53 @@ def disable_grid_expansion_if_LV_limit_hit(n): n.global_constraints.drop("lv_limit", inplace=True) -def adjust_renewable_profiles(n, input_profiles, config, year): +def adjust_renewable_profiles(n, input_profiles, params, year): """ - Adjusts renewable profiles according to the renewable technology specified. - - If the planning horizon is not available, the closest year is used - instead. + 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) - dr = pd.date_range(**config["snapshots"], freq="H") + + # 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 config["electricity"]["renewable_carriers"]: - if carrier == "hydro": - continue - - clustermaps.index = clustermaps.index.astype(str) - dr = pd.date_range(**config["snapshots"], freq="H") - snapshotmaps = ( - pd.Series(dr, index=dr).where(lambda x: x.isin(n.snapshots), pd.NA).ffill() - ) - for carrier in config["electricity"]["renewable_carriers"]: + 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 - if year in ds.indexes["year"]: - p_max_pu = ( - ds["year_profiles"] - .sel(year=year) - .transpose("time", "bus") - .to_pandas() - ) - else: - available_previous_years = [ - available_year - for available_year in ds.indexes["year"] - if available_year < year - ] - available_following_years = [ - available_year - for available_year in ds.indexes["year"] - if available_year > year - ] - if available_previous_years: - closest_year = max(available_previous_years) - if available_following_years: - closest_year = min(available_following_years) - logging.warning( - f"Planning horizon {year} not in {carrier} profiles. Using closest year {closest_year} instead." - ) - p_max_pu = ( - ds["year_profiles"] - .sel(year=closest_year) - .transpose("time", "bus") - .to_pandas() - ) + + 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"].to_pandas() + 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 @@ -245,7 +220,7 @@ if __name__ == "__main__": n = pypsa.Network(snakemake.input.network) - adjust_renewable_profiles(n, snakemake.input, snakemake.config, year) + adjust_renewable_profiles(n, snakemake.input, snakemake.params, year) add_build_year_to_new_assets(n, year) diff --git a/scripts/add_electricity.py b/scripts/add_electricity.py index c9e5abca..431f1cfa 100755 --- a/scripts/add_electricity.py +++ b/scripts/add_electricity.py @@ -374,6 +374,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 66c888e6..99b177a8 100644 --- a/scripts/build_renewable_profiles.py +++ b/scripts/build_renewable_profiles.py @@ -200,24 +200,20 @@ if __name__ == "__main__": if "snakemake" not in globals(): from _helpers import mock_snakemake - snakemake = mock_snakemake("build_renewable_profiles", technology="onwind") + snakemake = mock_snakemake("build_renewable_profiles", technology="offwind-dc") configure_logging(snakemake) nprocesses = int(snakemake.threads) noprogress = snakemake.config["run"].get("disable_progressbar", True) noprogress = noprogress or not snakemake.config["atlite"]["show_progress"] - year = snakemake.params.renewable["year"] - foresight = snakemake.params.foresight params = snakemake.params.renewable[snakemake.wildcards.technology] resource = params["resource"] # pv panel params / wind turbine params - year_dependent_techs = { - k: resource.get(k) - for k in ["panel", "turbine"] - if isinstance(resource.get(k), dict) - } - for key, techs in year_dependent_techs.items(): - resource[key] = resource[key][year] + 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"] @@ -334,45 +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() - if year_dependent_techs and foresight != "overnight": - for key, techs in year_dependent_techs.items(): - year_profiles = list() - tech_profiles = dict() - tech_profiles[resource[key]] = profile - for year, tech in techs.items(): - resource[key] = tech - if tech not in tech_profiles: - tech_profiles[tech] = func( - matrix=availability.stack(spatial=["y", "x"]), - layout=layout, - index=buses, - per_unit=True, - return_capacity=False, - **resource, - ) - year_profile = tech_profiles[tech] - year_profile = year_profile.expand_dims({"year": [year]}).rename( - "year_profiles" - ) - year_profiles.append(year_profile) - year_profiles = xr.merge(year_profiles) + resource[tech] = model - duration = time.time() - start - logger.info( - f"Completed weighted capacity factor time series calculation ({duration:2.2f}s)" - ) + 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 @@ -399,17 +390,14 @@ 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"), ] ) - if year_dependent_techs: - ds = xr.merge([ds, year_profiles * correction_factor]) - if snakemake.wildcards.technology.startswith("offwind"): logger.info("Calculate underwater fraction of connections.") offshore_shape = gpd.read_file(snakemake.input["offshore_shapes"]).unary_union @@ -425,7 +413,7 @@ if __name__ == "__main__": # select only buses with some capacity and minimal capacity factor ds = ds.sel( bus=( - (ds["profile"].mean("time") > params.get("min_p_max_pu", 0.0)) + (ds["profile"].mean("time").max("year") > params.get("min_p_max_pu", 0.0)) & (ds["p_nom_max"] > params.get("min_p_nom_max", 0.0)) ) ) @@ -433,9 +421,6 @@ if __name__ == "__main__": if "clip_p_max_pu" in params: min_p_max_pu = params["clip_p_max_pu"] ds["profile"] = ds["profile"].where(ds["profile"] >= min_p_max_pu, 0) - ds["year_profiles"] = ds["year_profiles"].where( - ds["year_profiles"] >= min_p_max_pu, 0 - ) ds.to_netcdf(snakemake.output.profile) diff --git a/scripts/prepare_sector_network.py b/scripts/prepare_sector_network.py index 03cba48b..59184b79 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