Skip to content

Commit 0fb16d5

Browse files
authored
Merge branch 'pik-piam:main' into main
2 parents 68b6b99 + a2b2b8e commit 0fb16d5

File tree

6 files changed

+329
-17
lines changed

6 files changed

+329
-17
lines changed

config/default_config.yaml

Lines changed: 32 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
1-
# SPDX-FileCopyrightText: : 2022 The PyPSA-China Authors
1+
# SPDX-FileCopyrightText: : 2025 The PyPSA-China-PIK Authors
22
#
33
# SPDX-License-Identifier: CC0-1.0
44
# DO NOT FORGET TO LOOK AT THE TECHNOLOGY CONFIGURATION FILES
55

66
run:
7-
name: "unamed_default_run"
7+
name: "unnamed_run"
88
foresight: "overnight"
99

1010
paths:
@@ -73,7 +73,7 @@ enable:
7373
retrieve_raster: false
7474

7575
atlite:
76-
cutout_name: "China-2020c"
76+
cutout_name: "China-2020c_bare"
7777
freq: "h"
7878
nprocesses: 1
7979
show_progress: true
@@ -169,16 +169,23 @@ bus_carrier: {
169169
Techs:
170170
## tech selection
171171
#TODO: rework vre_techs to split with clean_techs. Merge vre with non_dispatchable
172-
vre_techs: [ "onwind","offwind","solar","solar thermal","hydroelectricity", "nuclear","biomass","beccs","heat pump","resistive heater","Sabatier","H2 CHP", "fuel cell"]
172+
vre_techs: [ "onwind","offwind","solar","solar thermal","hydroelectricity", "nuclear","biomass","beccs","heat pump","resistive heater","Sabatier", "fuel cell", "H2 CHP"]
173173
non_dispatchable: ['Offshore Wind', 'Onshore Wind', 'Solar', 'Solar Residential', "Solar Thermal"]
174-
conv_techs: ["OCGT", "CCGT", "CHP gas", "gas boiler","coal boiler","coal power plant","CHP coal"]
174+
conv_techs: ["CCGT", "CHP gas", "CHP OCGT gas", "gas boiler","coal boiler","coal power plant","CHP coal", "OCGT"]
175175
store_techs: ["H2","battery","water tanks","PHS"]
176176
non_dispatchable: ["onwind", "offwind", "solar", "solar thermal"]
177177
hydrogen_lines: true
178-
coal_ccs_retrofit: true # currently myopic pathway only. CC = co2 cap
178+
coal_ccs_retrofit: false # currently myopic pathway only. CC = co2 cap
179+
180+
## Nuclear capacity growth limit
181+
nuclear_reactors:
182+
enable_growth_limit: true # Switch: true=enable growth limit, false=unrestricted growth
183+
max_annual_capacity_addition: 5000 # Fixed MW to add per year
184+
base_year: 2020 # Base year for capacity reference (should be <= first planning year)
185+
# base_capacity: 50000 # (optional) Base year total capacity in MW. If not set, auto-detect from base_year network
179186

180187
## add components (overwrites vre tech choice)
181-
heat_coupling: true
188+
heat_coupling: false
182189
add_biomass: True
183190
add_hydro: True
184191
add_H2: True
@@ -224,6 +231,12 @@ solving:
224231
tunnel_port: 27000
225232
timeout_s: 15
226233
login_node: "03"
234+
gurobi_home: "/p/projects/rd3mod/gurobi1103/linux64"
235+
license_path: "/p/projects/rd3mod/gurobi_rc/gurobi.lic"
236+
verbose: "1"
237+
timeout: "10"
238+
ssl_cert: "/p/projects/rd3mod/ssl/ca-bundle.pem_2022-02-08"
239+
grb_cafile: "/p/projects/rd3mod/ssl/ca-bundle.pem_2022-02-08"
227240

228241
solver_options:
229242
highs-default:
@@ -294,13 +307,13 @@ lines:
294307
base_year: 2020 # base step year, the exp limit is relative to this year
295308

296309
security:
297-
line_margin: 70 # max percent of line capacity
310+
line_security_margin: 70 # max percent of line capacity
298311

299312
# brownfield options
300313
existing_capacities:
301314
add: True # whether to add brownfield capacities
302315
grouping_years: [1985, 1990, 1995, 2000, 2005, 2010, 2015, 2020, 2025, 2030, 2035, 2040, 2045, 2050, 2055, 2060]
303-
collapse_years: False # Treat as a single unit when preparing & solving network
316+
collapse_years: True # Treat as a single unit when preparing & solving network
304317
threshold_capacity: 80 # MW
305318
techs: ['coal','CHP coal', 'CHP gas', 'OCGT', 'CCGT', 'solar', 'onwind', 'offwind', 'nuclear', "PHS"]
306319
node_assignment: simple # simple | gps
@@ -346,3 +359,13 @@ fetch_regions:
346359
simplify_tol:
347360
eez: 0.5
348361
land: 0.05
362+
363+
subsidies:
364+
# New format: subsidies.fuel_type -> province -> value (EUR/MWh) only allowed for negative values (subsidies)
365+
gas:
366+
Guangdong: -8
367+
Jiangsu: -8.5
368+
Zhejiang: -9
369+
Beijing: -10
370+
Tianjin: -10
371+
Shanghai: -10

docs/.nav.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,6 @@ nav:
2020
- Tutorials: tutorials
2121
- Reference: reference
2222

23-
append_unmatched: true
23+
append_unmatched: false
2424

2525

workflow/notebooks/land_availability.ipynb

Lines changed: 95 additions & 0 deletions
Large diffs are not rendered by default.

workflow/scripts/_helpers.py

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -496,17 +496,26 @@ def setup_gurobi_tunnel_and_env(
496496
logger.error("SSH tunnel communication timed out.")
497497

498498
os.environ["https_proxy"] = f"socks5://127.0.0.1:{port}"
499-
os.environ["SSL_CERT_FILE"] = "/p/projects/rd3mod/ssl/ca-bundle.pem_2022-02-08"
500-
os.environ["GRB_CAFILE"] = "/p/projects/rd3mod/ssl/ca-bundle.pem_2022-02-08"
499+
os.environ["SSL_CERT_FILE"] = tunnel_config.get(
500+
"ssl_cert", "/p/projects/rd3mod/ssl/ca-bundle.pem_2022-02-08"
501+
)
502+
os.environ["GRB_CAFILE"] = tunnel_config.get(
503+
"grb_cafile", "/p/projects/rd3mod/ssl/ca-bundle.pem_2022-02-08"
504+
)
501505

502506
# Set up Gurobi environment variables
503-
os.environ["GUROBI_HOME"] = "/p/projects/rd3mod/gurobi1103/linux64"
507+
# TODO soft code
508+
os.environ["GUROBI_HOME"] = tunnel_config.get(
509+
"gurobi_home", "/p/projects/rd3mod/gurobi1103/linux64"
510+
)
504511
os.environ["PATH"] += f":{os.environ['GUROBI_HOME']}/bin"
505512
if "LD_LIBRARY_PATH" in os.environ:
506513
os.environ["LD_LIBRARY_PATH"] += f":{os.environ['GUROBI_HOME']}/lib"
507-
os.environ["GRB_LICENSE_FILE"] = "/p/projects/rd3mod/gurobi_rc/gurobi.lic"
508-
os.environ["GRB_CURLVERBOSE"] = "1"
509-
os.environ["GRB_SERVER_TIMEOUT"] = "10"
514+
os.environ["GRB_LICENSE_FILE"] = tunnel_config.get(
515+
"license_path", "/p/projects/rd3mod/gurobi_rc/gurobi.lic"
516+
)
517+
os.environ["GRB_CURLVERBOSE"] = tunnel_config.get("verbose", "1")
518+
os.environ["GRB_SERVER_TIMEOUT"] = tunnel_config.get("timeout", "10")
510519

511520
return socks_proc
512521

workflow/scripts/prepare_network.py

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,86 @@ def add_co2_capture_support(
246246
)
247247

248248

249+
def add_fuel_subsidies(n: pypsa.Network, subsidy_config: dict):
250+
"""Apply fuel subsidies to generators as a post-processing step.
251+
252+
Subsidies are applied to generators based on their carrier and location.
253+
The subsidy values (in EUR/MWh fuel) are divided by efficiency to convert
254+
to electricity basis (EUR/MWhel). Links are not modified as they get their
255+
fuel from generators.
256+
257+
Args:
258+
n (pypsa.Network): The network object to modify.
259+
subsidy_config (dict): Subsidy configuration dictionary with keys like
260+
"coal" or "gas", each containing a dict mapping provinces to subsidy values.
261+
"""
262+
if not subsidy_config:
263+
return
264+
265+
carriers = subsidy_config.keys()
266+
267+
for carrier in carriers:
268+
subs_dict = subsidy_config.get(carrier, {})
269+
if not subs_dict:
270+
continue
271+
272+
# Convert subsidy dict to Series indexed by province
273+
subs = pd.Series(subs_dict, dtype=float)
274+
275+
# Check that subsidies are non-positive (negative = subsidy, positive would be a reward)
276+
if (subs > 0).any():
277+
raise ValueError(
278+
f"Positive subsidy values found for carrier '{carrier}': "
279+
f"{subs[subs > 0].to_dict()}. Only zero or negative values are allowed "
280+
f"(negative reduces marginal cost, positive would increase it)."
281+
)
282+
283+
# Check if location column exists
284+
if "location" not in n.generators.columns:
285+
logger.warning(
286+
f"Location column not found in generators. "
287+
f"Cannot apply subsidies for carrier '{carrier}'."
288+
)
289+
continue
290+
291+
# Query generators with matching carrier and location in subsidy provinces
292+
mask = n.generators.query(
293+
f"carrier == @carrier and location in @subs.index"
294+
).index
295+
296+
if mask.empty:
297+
logger.warning(
298+
f"No generators found with carrier '{carrier}' and locations "
299+
f"in {list(subs.index)}. Skipping subsidy application."
300+
)
301+
continue
302+
303+
# Merge subsidies with generators by location
304+
gen_locs = n.generators.loc[mask, "location"]
305+
subs_to_apply = gen_locs.map(subs).fillna(0.0)
306+
307+
# Check if all provinces were found
308+
missing_provs = set(subs.index) - set(gen_locs.unique())
309+
if missing_provs:
310+
logger.warning(
311+
f"Subsidies specified for provinces {missing_provs} but no "
312+
f"generators found with carrier '{carrier}' in these provinces."
313+
)
314+
315+
# Apply subsidies: divide by efficiency to convert from fuel to electricity basis
316+
# Handle cases where efficiency might be NaN or missing
317+
efficiencies = n.generators.loc[mask, "efficiency"].fillna(1.0)
318+
subs_electricity = subs_to_apply / efficiencies
319+
320+
# Subtract subsidy from marginal cost (negative subsidy = cost reduction)
321+
n.generators.loc[mask, "marginal_cost"] -= subs_electricity
322+
323+
logger.info(
324+
f"Applied subsidies for carrier '{carrier}' to {len(mask)} generators "
325+
f"in provinces {sorted(gen_locs.unique())}"
326+
)
327+
328+
249329
def add_conventional_generators(
250330
network: pypsa.Network,
251331
nodes: pd.Index,
@@ -660,6 +740,7 @@ def add_voltage_links(network: pypsa.Network, config: dict):
660740
p_nom=edges["p_nom"].values,
661741
p_nom_min=edges["p_nom"].values,
662742
p_min_pu=0,
743+
p_max_pu=line_margin,
663744
efficiency=config["transmission_efficiency"]["DC"]["efficiency_static"]
664745
* config["transmission_efficiency"]["DC"]["efficiency_per_1000km"] ** (lengths / 1000),
665746
length=0,
@@ -1640,6 +1721,12 @@ def prepare_network(
16401721
add_voltage_links(network, config)
16411722

16421723
assign_locations(network)
1724+
1725+
# Apply fuel subsidies as post-processing step
1726+
subsidy_config = config.get("subsidies", {})
1727+
if subsidy_config:
1728+
add_fuel_subsidies(network, subsidy_config)
1729+
16431730
return network
16441731

16451732

workflow/scripts/solve_network.py

Lines changed: 99 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,70 @@
2929
logger = logging.getLogger(__name__)
3030

3131

32+
def calc_nuclear_expansion_limit(
33+
n: pypsa.Network,
34+
config: dict,
35+
planning_year: int,
36+
network_path: str,
37+
) -> None:
38+
"""
39+
Calculate and apply the nuclear expansion limit from configuration.
40+
41+
Args:
42+
n (pypsa.Network): the network object
43+
config (dict): full configuration dictionary (mutated in place)
44+
planning_year (int): target planning horizon year
45+
network_path (str): path to the current network file, used to locate base year
46+
"""
47+
nuclear_cfg = config.setdefault("nuclear_reactors", {})
48+
if not nuclear_cfg.get("enable_growth_limit"):
49+
return
50+
51+
annual_addition = nuclear_cfg.get("max_annual_capacity_addition")
52+
if not annual_addition:
53+
logger.warning("Nuclear growth limit enabled but max_annual_capacity_addition missing")
54+
return
55+
56+
base_year = nuclear_cfg.get("base_year", 2020)
57+
n_years = planning_year - base_year
58+
if n_years <= 0:
59+
logger.info(
60+
"Planning year %s is not after base year %s; skipping nuclear expansion limit",
61+
planning_year,
62+
base_year,
63+
)
64+
return
65+
66+
base_capacity = nuclear_cfg.get("base_capacity")
67+
if base_capacity is None:
68+
base_path = network_path.replace(f"ntwk_{planning_year}.nc", f"ntwk_{base_year}.nc")
69+
if os.path.exists(base_path):
70+
n_base = pypsa.Network(base_path)
71+
base_capacity = n_base.generators[n_base.generators.carrier == "nuclear"]["p_nom"].sum()
72+
else:
73+
base_capacity = n.generators[n.generators.carrier == "nuclear"]["p_nom"].sum()
74+
75+
max_capacity = base_capacity + annual_addition * n_years
76+
logger.info(
77+
f"Adding nuclear expansion limit for {planning_year}: {max_capacity:.0f} MW "
78+
f"[{base_capacity:.0f} + {annual_addition:.0f} × {n_years} years]"
79+
)
80+
81+
nuclear_gens_ext = n.generators[
82+
(n.generators.carrier == "nuclear") & (n.generators.p_nom_extendable == True)
83+
].index
84+
85+
if len(nuclear_gens_ext) == 0:
86+
logger.warning("No extendable nuclear generators found")
87+
return
88+
89+
n.generators.loc[nuclear_gens_ext, "p_nom_max"] = max_capacity
90+
nuclear_cfg["expansion_limit"] = max_capacity
91+
logger.info(
92+
f"Nuclear expansion limit set: {max_capacity:.0f} MW for {len(nuclear_gens_ext)} generators"
93+
)
94+
95+
3296
def set_transmission_limit(n: pypsa.Network, kind: str, factor: float, n_years=1):
3397
"""
3498
Set global transimission limit constraints - adapted from pypsa-eur
@@ -264,6 +328,30 @@ def prepare_network(
264328
return n
265329

266330

331+
def add_nuclear_expansion_constraints(n: pypsa.Network):
332+
"""
333+
Add nuclear expansion limit constraint if configured.
334+
335+
Args:
336+
n (pypsa.Network): the network object
337+
"""
338+
limit = n.config.get("nuclear_reactors", {}).get("expansion_limit")
339+
if limit is None:
340+
return
341+
342+
nuclear_gens_ext = n.generators[
343+
(n.generators.carrier == "nuclear") & (n.generators.p_nom_extendable == True)
344+
].index
345+
346+
if len(nuclear_gens_ext) == 0:
347+
return
348+
349+
# Add global constraint: sum of all nuclear p_nom <= limit
350+
lhs = n.model["Generator-p_nom"].loc[nuclear_gens_ext].sum()
351+
n.model.add_constraints(lhs <= limit, name="nuclear_expansion_limit")
352+
logger.info(f"Applied global nuclear constraint: sum(p_nom) <= {limit:.0f} MW")
353+
354+
267355
def add_battery_constraints(n: pypsa.Network):
268356
"""
269357
Add constraint ensuring that charger = discharger, i.e.
@@ -743,6 +831,8 @@ def extra_functionality(n: pypsa.Network, _) -> None:
743831
config = n.config
744832
add_battery_constraints(n)
745833
add_transmission_constraints(n)
834+
add_nuclear_expansion_constraints(n)
835+
746836
if config["heat_coupling"]:
747837
add_water_tank_charger_constraints(n, config)
748838
add_chp_constraints(n)
@@ -831,6 +921,7 @@ def solve_network(
831921
configfiles="resources/tmp/pseudo-coupled.yaml",
832922
)
833923
configure_logging(snakemake)
924+
config = snakemake.config
834925

835926
opts = snakemake.wildcards.get("opts", "")
836927
if "sector_opts" in snakemake.wildcards.keys():
@@ -868,6 +959,13 @@ def solve_network(
868959
# # TODO: remove ugly hack
869960
# n.storage_units.p_nom_max = n.storage_units.p_nom * 1.05**exp_years
870961

962+
calc_nuclear_expansion_limit(
963+
n=n,
964+
config=config,
965+
planning_year=int(snakemake.wildcards.planning_horizons),
966+
network_path=snakemake.input.network_name,
967+
)
968+
871969
if tunnel:
872970
logger.info(f"tunnel process alive? {tunnel.poll()}")
873971

@@ -880,7 +978,7 @@ def solve_network(
880978

881979
n = solve_network(
882980
n,
883-
config=snakemake.config,
981+
config=config,
884982
solving=snakemake.params.solving,
885983
opts=opts,
886984
log_fn=snakemake.log.solver,

0 commit comments

Comments
 (0)