diff --git a/docs/data.md b/docs/data.md index 345641a0..c862fbac 100644 --- a/docs/data.md +++ b/docs/data.md @@ -8,7 +8,7 @@ Marcel Buchhorn, Bruno Smets, Luc Bertels, Bert De Roo, Myroslava Lesiv, Nandin- - Source: Copernicus Global Land Service: - Link: https://land.copernicus.eu/en/products/global-dynamic-land-cover/copernicus-global-land-service-land-cover-100m-collection-3-epoch-2019-globe -- License: Creative Commons Attribution 4.0 International +- License: Creative Commons Attribution 4.0 International - Description: 100m resolution land classification ### Administrative regions @@ -50,3 +50,9 @@ Gridded population data - License: CC-BY 4.0 (reference) - Description: Gridded population data. +### EV charging load +Weipeng Zhan, Yuan Liao, Junjun Deng, Zhenpo Wang, & Sonia Yeh. (2025). Large-scale empirical study of electric vehicle usage patterns and charging infrastructure needs. *npj Sustainable Mobility and Transport*. +- Source: Nature Portfolio +- Link: https://www.nature.com/articles/s44333-024-00023-3 +- License: [Nature Portfolio license](https://www.nature.com/articles/s44333-024-00023-3) +- Description: Empirical data on electric vehicle usage patterns and charging infrastructure needs, including charging ratio profiles for passenger and freight vehicles used to generate time-series charging demand. diff --git a/workflow/scripts/solve_network.py b/workflow/scripts/solve_network.py index b4962935..44fbc4e8 100644 --- a/workflow/scripts/solve_network.py +++ b/workflow/scripts/solve_network.py @@ -8,12 +8,12 @@ """ import logging +import os import numpy as np import pandas as pd import pypsa import xarray as xr -import os from _helpers import ConfigManager, configure_logging, mock_snakemake, setup_gurobi_tunnel_and_env from _pypsa_helpers import filter_carriers, mock_solve, store_duals_to_network from constants import YEAR_HRS @@ -31,7 +31,7 @@ def calc_nuclear_expansion_limit( ) -> None: """ Calculate and apply the nuclear expansion limit from configuration. - + Args: n (pypsa.Network): the network object config (dict): full configuration dictionary (mutated in place) @@ -39,24 +39,17 @@ def calc_nuclear_expansion_limit( network_path (str): path to the current network file, used to locate base year """ nuclear_cfg = config.setdefault("nuclear_reactors", {}) - if not nuclear_cfg.get("enable_growth_limit"): + + if not nuclear_cfg.get("enable_growth_limit", True): return - + annual_addition = nuclear_cfg.get("max_annual_capacity_addition") - if not annual_addition: - logger.warning("Nuclear growth limit enabled but max_annual_capacity_addition missing") - return - base_year = nuclear_cfg.get("base_year", 2020) n_years = planning_year - base_year - if n_years <= 0: - logger.info( - "Planning year %s is not after base year %s; skipping nuclear expansion limit", - planning_year, - base_year, - ) + + if not annual_addition or n_years <= 0: return - + base_capacity = nuclear_cfg.get("base_capacity") if base_capacity is None: base_path = network_path.replace(f"ntwk_{planning_year}.nc", f"ntwk_{base_year}.nc") @@ -65,26 +58,17 @@ def calc_nuclear_expansion_limit( base_capacity = n_base.generators[n_base.generators.carrier == "nuclear"]["p_nom"].sum() else: base_capacity = n.generators[n.generators.carrier == "nuclear"]["p_nom"].sum() - - max_capacity = base_capacity + annual_addition * n_years - logger.info( - f"Adding nuclear expansion limit for {planning_year}: {max_capacity:.0f} MW " - f"[{base_capacity:.0f} + {annual_addition:.0f} × {n_years} years]" - ) + max_capacity = base_capacity + annual_addition * n_years nuclear_gens_ext = n.generators[ (n.generators.carrier == "nuclear") & (n.generators.p_nom_extendable == True) ].index - - if len(nuclear_gens_ext) == 0: - logger.warning("No extendable nuclear generators found") - return - - n.generators.loc[nuclear_gens_ext, "p_nom_max"] = max_capacity - nuclear_cfg["expansion_limit"] = max_capacity - logger.info( - f"Nuclear expansion limit set: {max_capacity:.0f} MW for {len(nuclear_gens_ext)} generators" - ) + + if len(nuclear_gens_ext) > 0: + logger.info( + f"Nuclear expansion limit for {planning_year}: {max_capacity:.0f} MW " + f"[{base_capacity:.0f} + {annual_addition:.0f} × {n_years} years]" + ) def set_transmission_limit(n: pypsa.Network, kind: str, factor: float, n_years=1): @@ -325,25 +309,33 @@ def prepare_network( def add_nuclear_expansion_constraints(n: pypsa.Network): """ Add nuclear expansion limit constraint if configured. - + + This function adds a global constraint limiting the total capacity of all + extendable nuclear generators. The limit is based on max_annual_capacity_addition. + Args: n (pypsa.Network): the network object """ - limit = n.config.get("nuclear_reactors", {}).get("expansion_limit") - if limit is None: + nuclear_config = n.config.get("nuclear_reactors", {}) + + if not nuclear_config.get("enable_growth_limit", True): return - + nuclear_gens_ext = n.generators[ (n.generators.carrier == "nuclear") & (n.generators.p_nom_extendable == True) ].index - + if len(nuclear_gens_ext) == 0: return - - # Add global constraint: sum of all nuclear p_nom <= limit - lhs = n.model["Generator-p_nom"].loc[nuclear_gens_ext].sum() - n.model.add_constraints(lhs <= limit, name="nuclear_expansion_limit") - logger.info(f"Applied global nuclear constraint: sum(p_nom) <= {limit:.0f} MW") + + limit = nuclear_config.get("max_annual_capacity_addition") + if limit is None: + return + + n.model.add_constraints( + n.model["Generator-p_nom"].loc[nuclear_gens_ext].sum() <= limit, + name="nuclear_expansion_limit", + ) def add_battery_constraints(n: pypsa.Network): @@ -672,8 +664,10 @@ def add_remind_paid_off_constraints(n: pypsa.Network) -> None: continue else: paidoff_comp.dropna(subset=[paid_off_col], inplace=True) - _remind_only_techs = n.config["existing_capacities"].get("remind_only_tech_groups", []) - paidoff_comp = paidoff_comp.query("tech_group not in @_remind_only_techs") + + # techs that only exist as paid-off don't have usual counterparts + remind_only = n.config["existing_capacities"].get("remind_only_tech_groups", []) # noqa: F841 + paidoff_comp = paidoff_comp.query("tech_group not in @remind_only") if paidoff_comp.empty: continue @@ -726,14 +720,14 @@ def add_operational_reserve_margin(n: pypsa.network, config): contingency: 400000 # MW """ reserve_config = config["operational_reserve"] - _VRE_TECHS = config["Techs"].get("non_dispatchable", ["onwind", "offwind", "solar"]) + VRE_TECHS = config["Techs"].get("non_dispatchable", ["onwind", "offwind", "solar"]) # noqa F841 EPSILON_LOAD, EPSILON_VRES = reserve_config["epsilon_load"], reserve_config["epsilon_vres"] CONTINGENCY = float(reserve_config["contingency"]) # AC producers ac_mask = n.generators.bus.map(n.buses.carrier) == "AC" - _ac_buses = n.buses.query("carrier =='AC'").index - _attached_carriers = filter_carriers(n, "AC") + ac_buses = n.buses.query("carrier =='AC'").index # noqa: F841 + attached_carriers = filter_carriers(n, "AC") # noqa: F841 # conceivably a link could have a negative efficiency and flow towards bus0 - don't consider prod_links = n.links.query("carrier in @_attached_carriers & not bus0 in @_ac_buses") transport_links = prod_links.bus0.map(n.buses.carrier) == prod_links.bus1.map(n.buses.carrier) @@ -825,7 +819,7 @@ def extra_functionality(n: pypsa.Network, _) -> None: add_battery_constraints(n) add_transmission_constraints(n) add_nuclear_expansion_constraints(n) - + if config["heat_coupling"]: add_water_tank_charger_constraints(n, config) add_chp_constraints(n)