Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
8 changes: 7 additions & 1 deletion docs/data.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
88 changes: 41 additions & 47 deletions workflow/scripts/solve_network.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -31,32 +31,25 @@ 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)
planning_year (int): target planning horizon year
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")
Expand All @@ -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):
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
Loading