From 3812c68ebc7656d18656a22aaaf7a30fa55f1ac5 Mon Sep 17 00:00:00 2001 From: Jan Tautorus Date: Mon, 27 Jul 2026 14:18:37 +0200 Subject: [PATCH 1/6] fix: use configured cost_year in supply curve target paths --- rules/supply_curves.smk | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/rules/supply_curves.smk b/rules/supply_curves.smk index 194711e..c673cd7 100644 --- a/rules/supply_curves.smk +++ b/rules/supply_curves.smk @@ -37,11 +37,12 @@ def _product_uses_renewables(product): def _all_supply_curve_targets(): targets = [] + cost_year = config["trade_chains"].get("cost_year", 2050) for region in config["regions"]: wacc = config["trade_chains"].get("wacc", "uniform") for product in SUPPLY_CURVE_PRODUCTS: targets.append( - f"resources/supply_curves/cost_year~2050/wacc~{wacc}/{region}_marginal_cost_{product}.csv" + f"resources/supply_curves/cost_year~{cost_year}/wacc~{wacc}/{region}_marginal_cost_{product}.csv" ) return targets From 3c69941090da2fe8bc73e5699cc9a60494001053 Mon Sep 17 00:00:00 2001 From: Jan Tautorus Date: Mon, 27 Jul 2026 14:18:51 +0200 Subject: [PATCH 2/6] perf: skip redundant demand sweep for grid-only supply curve routes --- rules/supply_curves.smk | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/rules/supply_curves.smk b/rules/supply_curves.smk index c673cd7..3ee7c8d 100644 --- a/rules/supply_curves.smk +++ b/rules/supply_curves.smk @@ -35,6 +35,24 @@ def _product_uses_renewables(product): return True +def _demand_levels_for_product(product): + """Demand levels to sweep when solving LCoX for a product. + + Renewable-fed routes (e.g. hbi/dri) get costlier at scale as cheaper + resource classes are exhausted, so the full sweep is needed to trace + the curve. Grid-connected routes (e.g. steel/eaf) draw on grid + electricity at a fixed, uncapped marginal cost in this stage, so their + LCoX is flat with respect to volume (verified: identical across all + demand levels for every region). Solving all levels for those products + is redundant; one point at the top of the range is enough and keeps + capacity headroom for the trade model. + """ + demand_levels = config.get("steel_demand_levels") + if _product_uses_renewables(product): + return demand_levels + return [max(demand_levels)] + + def _all_supply_curve_targets(): targets = [] cost_year = config["trade_chains"].get("cost_year", 2050) @@ -162,12 +180,12 @@ if config["enable"].get("run_supply_curve", True): scenario=config.get("supply_curve", {}).get( "default_scenario", "allocated_share" ), - product_demand_mt=config.get("steel_demand_levels"), + product_demand_mt=_demand_levels_for_product(wildcards.product), ), lco_unreserved=lambda wildcards: ( expand( f"resources/lco-{wildcards.product}/cost_year~{wildcards.cost_year}/wacc~{wildcards.wacc}/{wildcards.region}_unreserved/results_{{product_demand_mt}}.csv", - product_demand_mt=config.get("steel_demand_levels"), + product_demand_mt=_demand_levels_for_product(wildcards.product), ) if config.get("supply_curve", {}).get("generate_unreserved", False) and _product_uses_renewables(wildcards.product) From 0ea16e529f618428c720864f1b45c8ee750424ef Mon Sep 17 00:00:00 2001 From: Jan Tautorus Date: Mon, 27 Jul 2026 14:18:57 +0200 Subject: [PATCH 3/6] feat: configure 2030 supply-curve run for TRACE regions --- config/config.yaml | 50 ++++++++++++++++++++++++++++------------------ 1 file changed, 31 insertions(+), 19 deletions(-) diff --git a/config/config.yaml b/config/config.yaml index 4b6d3a0..3153fd3 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -1,7 +1,7 @@ enable: - run_supply_chain: False # Enable for first run - run_supply_curve: False # Enable for first run - cluster_renewables: False # Enable for first run + run_supply_chain: True # Enable for first run + run_supply_curve: True # Enable for first run + cluster_renewables: False # Renewable clustering already done for these regions run: disable_progressbar: false @@ -30,7 +30,7 @@ supply_curve: # Defines the commodity transformation chain with ordered stages and process labels trade_chains: id: supplyconstraint - cost_year: 2050 + cost_year: 2030 final_product: steel wacc: regional #regional or uniform labour_cost: True #Include labour cost @@ -143,21 +143,33 @@ embodied_energy_steel: 2.1 #TWh/Mt or MWh/t, see iron oxide reduction # Region definitions (ISO3 codes matching renewable clusters metadata) regions: - "Europe": ["ALB","AUT","BLR","BEL","BIH","BGR","HRV","CYP","CZE","DNK","EST","FIN","DEU","GRC","HUN","ITA","XKX","LVA","LTU","LUX","MKD","MDA","MNE","NLD","NOR","POL","ROU","SRB","SVK","SVN","SWE","CHE","TUR","UKR"] - "Far_West_Europe": ["FRA","GRL","ISL","IRL","PRT","ESP","GBR"] - "Middle_East": ["BHR","IRN","IRQ","ISR","JOR","KWT","LBN","OMN","PSE","QAT","SAU","SYR","ARE","YEM"] - "North_West_Africa": ["BEN","BFA","CIV","GMB","GHA","GIN","GNB","LBR","NGA","SEN","SLE","TGO","DZA","TCD","EGY","ERI","LBY","MLI","MRT","MAR","NER","SDN","TUN","ESH"] - "Subsaharan_Africa": ["AGO","BWA","BDI","CMR","CAF","COD","ETH","KEN","GAB","MDG","MWI","MOZ","NAM","COG","RWA","SOM","ZAF","SSD","TZA","UGA","ZMB","ZWE"] - "North_America": ["CAN","USA"] - "Eurasia": ["ARM","AZE","GEO","KAZ","KGZ","TJK","TKM","UZB","RUS"] - "South_America": ["BOL","BRA","COL","ECU","GUF","GUY","PRY","PER","SUR","VEN"] - "South_South_America": ["ARG","CHL","URY"] - "Central_America": ["CRI","SLV","GTM","HND","MEX","NIC","PAN","CUB","DOM","JAM","HTI"] - "West_Asia": ["AFG","BGD","BTN","IND","NPL","PAK","LKA"] - "East_Asia": ["CHN","HKG","MNG","TWN"] - "Pacific_Asia": ["BRN","KHM","IDN","LAO","MYS","MMR","PNG","PHL","SGP","THA","VNM"] - "East_East_Asia": ["JPN","KOR","PRK"] - "Oceania": ["AUS","NZL"] + "BRA": ["BRA"] + "CAM": ["ABW", "AIA", "ATG", "BES", "BHS", "BLM", "BLZ", "BMU", "BRB", "CRI", "CUB", "CUW", "CYM", "DMA", "DOM", "GLP", "GRD", "GTM", "HND", "HTI", "JAM", "KNA", "LCA", "MAF", "MSR", "MTQ", "NIC", "PAN", "PRI", "SLV", "SXM", "TCA", "TTO", "VCT", "VGB", "VIR"] + "CAN": ["CAN", "SPM"] + "CHA": ["CHN", "HKG", "MAC", "PRK", "TWN"] + "EAF": ["BDI", "DJI", "ETH", "KEN", "RWA", "SOM", "SSD", "TZA", "UGA"] + "EUR": ["ALA", "AUT", "BEL", "BGR", "CYP", "CZE", "DEU", "DNK", "ESP", "EST", "FIN", "FRA", "GRC", "HRV", "HUN", "IRL", "ITA", "LTU", "LUX", "LVA", "MLT", "NLD", "POL", "PRT", "ROU", "SVK", "SVN", "SWE"] + "GCC": ["ARE", "BHR", "KWT", "OMN", "QAT", "SAU"] + "IDN": ["IDN"] + "IND": ["IND"] + "JPN": ["JPN"] + "KOR": ["KOR"] + "MEX": ["MEX"] + "NAC": ["DZA", "EGY", "ESH", "LBY", "MAR", "SDN", "TUN"] + "NEU": ["ALB", "AND", "BIH", "CHE", "FRO", "GBR", "GGY", "GIB", "GRL", "IMN", "ISL", "JEY", "LIE", "MCO", "MKD", "MNE", "NOR", "SJM", "SMR", "SRB", "VAT"] + "OZE": ["ASM", "ATF", "AUS", "CCK", "COK", "CXR", "FJI", "FSM", "GUM", "HMD", "IOT", "KIR", "MDV", "MHL", "MNP", "NCL", "NFK", "NIU", "NRU", "NZL", "PCN", "PLW", "PYF", "SLB", "TKL", "TLS", "TON", "TUV", "UMI", "VUT", "WLF", "WSM"] + "REF": ["ARM", "AZE", "GEO", "KAZ", "KGZ", "MDA", "MNG", "TJK", "TKM", "UZB"] + "RME": ["IRN", "IRQ", "ISR", "JOR", "LBN", "PSE", "SYR", "YEM"] + "RUS": ["BLR", "RUS"] + "SAF": ["AGO", "BWA", "CAF", "CMR", "COD", "COG", "COM", "GAB", "GNQ", "LSO", "MDG", "MOZ", "MUS", "MWI", "MYT", "NAM", "REU", "SHN", "STP", "SWZ", "SYC", "ZAF", "ZMB", "ZWE"] + "SAM": ["BOL", "COL", "ECU", "FLK", "GUF", "GUY", "PER", "PRY", "SGS", "SUR", "VEN"] + "SEA": ["BRN", "KHM", "LAO", "MMR", "MYS", "PHL", "PNG", "SGP", "THA", "VNM"] + "SSM": ["ARG", "ATA", "BVT", "CHL", "URY"] + "TUR": ["TUR"] + "UKR": ["UKR"] + "USA": ["USA"] + "WAS": ["AFG", "BGD", "BTN", "LKA", "NPL", "PAK"] + "WSA": ["BEN", "BFA", "CIV", "CPV", "ERI", "GHA", "GIN", "GMB", "GNB", "LBR", "MLI", "MRT", "NER", "NGA", "SEN", "SLE", "TCD", "TGO"] region_nice_names: "Europe": "Europe" From 55073f67b6be1e87a988c13d19e396afecec2fa5 Mon Sep 17 00:00:00 2001 From: Jan Tautorus Date: Mon, 27 Jul 2026 14:19:04 +0200 Subject: [PATCH 4/6] fix: match World Bank GDP country names via ISO3 metadata instead of pycountry --- workflow/notebooks/prepare-wacc.ipynb | 45 +++++---------------------- 1 file changed, 7 insertions(+), 38 deletions(-) diff --git a/workflow/notebooks/prepare-wacc.ipynb b/workflow/notebooks/prepare-wacc.ipynb index 8663705..3dff537 100644 --- a/workflow/notebooks/prepare-wacc.ipynb +++ b/workflow/notebooks/prepare-wacc.ipynb @@ -8,7 +8,6 @@ "outputs": [], "source": [ "import pandas as pd\n", - "import pycountry\n", "import wbdata" ] }, @@ -97,36 +96,6 @@ "regions = config['regions']" ] }, - { - "cell_type": "code", - "execution_count": null, - "id": "a532a77c", - "metadata": {}, - "outputs": [], - "source": [ - "country_name_corrections = {\n", - " \"Democratic Republic of the Congo\": \"Congo, The Democratic Republic of the\",\n", - " \"Republic of the Congo\": \"Republic of the Congo\",\n", - " \"Kosovo\": \"Republic of Kosovo\", # pycountry not supported\n", - " \"Russia\": \"Russian Federation\",\n", - " \"Turkey\": \"Türkiye\",\n", - " \"Venezuela\": \"Venezuela, Bolivarian Republic of\",\n", - " \"Tanzania\": \"United Republic of Tanzania\",\n", - " \"Bolivia\": \"Plurinational State of Bolivia\",\n", - " \"Vietnam\": \"Viet Nam\",\n", - " \"South Korea\": \"Korea, Republic of\",\n", - " \"North Korea\": \"Korea, Democratic People's Republic of\",\n", - " \"Taiwan\": \"Taiwan, Province of China\",\n", - " \"Laos\": \"Lao People's Democratic Republic\",\n", - " \"Brunei\": \"Brunei Darussalam\",\n", - " \"Equatorial French Guiana\": \"French Guiana\",\n", - " \"Syria\": \"Syrian Arab Republic\", \n", - " \"Palestine\": \"Palestine, State of\",\n", - " \"Moldova\": \"Republic of Moldova\",\n", - " \"North Korea\": \"Korea, Democratic People's Republic of\",\n", - "}" - ] - }, { "cell_type": "code", "execution_count": null, @@ -134,13 +103,13 @@ "metadata": {}, "outputs": [], "source": [ - "# Build a mapping from country name to ISO-3 code (still needed for GDP World Bank lookup)\n", - "country_name_to_iso = {}\n", - "for country in pycountry.countries:\n", - " country_name_to_iso[country.name] = country.alpha_3\n", - " # Add common names\n", - " if hasattr(country, 'official_name'):\n", - " country_name_to_iso[country.official_name] = country.alpha_3\n", + "# Build a mapping from World Bank's own country name to ISO-3 code.\n", + "# Using World Bank's own metadata (rather than pycountry) avoids name-matching\n", + "# mismatches for countries whose WB display name differs from pycountry's\n", + "# (e.g. WB \"Turkiye\" vs pycountry \"Türkiye\", WB \"Korea, Rep.\" vs pycountry\n", + "# \"Korea, Republic of\"), which previously caused those countries' GDP rows to be\n", + "# dropped and, for single-country regions, produced NaN GDP-weighted WACC.\n", + "country_name_to_iso = {c[\"name\"]: c[\"id\"] for c in wbdata.get_countries()}\n", "\n", "# Build a mapping from ISO-3 code to region (config regions now use ISO-3 codes directly)\n", "iso_to_region = {}\n", From 82a1dd67f3af0cc2a3a1116597e11ab30cba6be6 Mon Sep 17 00:00:00 2001 From: Jan Tautorus Date: Mon, 27 Jul 2026 14:19:10 +0200 Subject: [PATCH 5/6] fix: require optimal solver status before accepting LCoX objective --- workflow/scripts/calculate_lcox.py | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/workflow/scripts/calculate_lcox.py b/workflow/scripts/calculate_lcox.py index 47f4eb2..ec55f81 100644 --- a/workflow/scripts/calculate_lcox.py +++ b/workflow/scripts/calculate_lcox.py @@ -408,7 +408,7 @@ def solve_network(network, config): logger.error(f"Solver exception: {e}") raise - return network + return network, status_ok # ============================================================================ @@ -416,7 +416,7 @@ def solve_network(network, config): # ============================================================================ -def extract_lcox(network, product, demands): +def extract_lcox(network, product, demands, status_ok=True): """Extract LCOX from an optimized network. Parameters @@ -428,6 +428,13 @@ def extract_lcox(network, product, demands): demands : dict Must include key `'product_demand_mt'` (float, Mt/year) used to compute annual production and per-unit LCOX. + status_ok : bool + Whether the solver reported a genuinely optimal termination status. + A non-optimal solve (e.g. numerical trouble during the barrier + method) can leave a stale but finite `network.objective` behind even + though the result isn't trustworthy; checking only `None`/`NaN` lets + that garbage value through as if it were a valid LCOX. Requiring + `status_ok` closes that gap. Returns ------- @@ -455,6 +462,9 @@ def extract_lcox(network, product, demands): ) try: + if not status_ok: + raise ValueError("Optimization did not report an optimal status") + obj_value = network.objective if obj_value is None or np.isnan(obj_value): raise ValueError("Optimization failed to return valid objective") @@ -598,16 +608,19 @@ def extract_lcox(network, product, demands): if snakemake.config.get("debug_network_inspection", False): inspect_network(network, product) # Debug inspection try: - solve_network(network, snakemake.config) + network, status_ok = solve_network(network, snakemake.config) optimization_status = ( "optimal" - if network.objective is not None and not np.isnan(network.objective) + if status_ok + and network.objective is not None + and not np.isnan(network.objective) else "infeasible" ) except Exception as e: logger.warning( f"Solver error for product demand {product_demand_mt} Mt/year: {e}" ) + status_ok = False optimization_status = "error" if optimization_status != "optimal": @@ -620,6 +633,7 @@ def extract_lcox(network, product, demands): results_df = extract_lcox( network=network, product=product, + status_ok=status_ok, demands=scaled_demands, ) From ea1e5f49c9c4bd2ac1bad2af637f07476fd21f68 Mon Sep 17 00:00:00 2001 From: energyls Date: Mon, 10 Aug 2026 17:08:19 +0200 Subject: [PATCH 6/6] chore: discard config changes --- config/config.yaml | 50 ++++++++++++++++++---------------------------- 1 file changed, 19 insertions(+), 31 deletions(-) diff --git a/config/config.yaml b/config/config.yaml index 3153fd3..4b6d3a0 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -1,7 +1,7 @@ enable: - run_supply_chain: True # Enable for first run - run_supply_curve: True # Enable for first run - cluster_renewables: False # Renewable clustering already done for these regions + run_supply_chain: False # Enable for first run + run_supply_curve: False # Enable for first run + cluster_renewables: False # Enable for first run run: disable_progressbar: false @@ -30,7 +30,7 @@ supply_curve: # Defines the commodity transformation chain with ordered stages and process labels trade_chains: id: supplyconstraint - cost_year: 2030 + cost_year: 2050 final_product: steel wacc: regional #regional or uniform labour_cost: True #Include labour cost @@ -143,33 +143,21 @@ embodied_energy_steel: 2.1 #TWh/Mt or MWh/t, see iron oxide reduction # Region definitions (ISO3 codes matching renewable clusters metadata) regions: - "BRA": ["BRA"] - "CAM": ["ABW", "AIA", "ATG", "BES", "BHS", "BLM", "BLZ", "BMU", "BRB", "CRI", "CUB", "CUW", "CYM", "DMA", "DOM", "GLP", "GRD", "GTM", "HND", "HTI", "JAM", "KNA", "LCA", "MAF", "MSR", "MTQ", "NIC", "PAN", "PRI", "SLV", "SXM", "TCA", "TTO", "VCT", "VGB", "VIR"] - "CAN": ["CAN", "SPM"] - "CHA": ["CHN", "HKG", "MAC", "PRK", "TWN"] - "EAF": ["BDI", "DJI", "ETH", "KEN", "RWA", "SOM", "SSD", "TZA", "UGA"] - "EUR": ["ALA", "AUT", "BEL", "BGR", "CYP", "CZE", "DEU", "DNK", "ESP", "EST", "FIN", "FRA", "GRC", "HRV", "HUN", "IRL", "ITA", "LTU", "LUX", "LVA", "MLT", "NLD", "POL", "PRT", "ROU", "SVK", "SVN", "SWE"] - "GCC": ["ARE", "BHR", "KWT", "OMN", "QAT", "SAU"] - "IDN": ["IDN"] - "IND": ["IND"] - "JPN": ["JPN"] - "KOR": ["KOR"] - "MEX": ["MEX"] - "NAC": ["DZA", "EGY", "ESH", "LBY", "MAR", "SDN", "TUN"] - "NEU": ["ALB", "AND", "BIH", "CHE", "FRO", "GBR", "GGY", "GIB", "GRL", "IMN", "ISL", "JEY", "LIE", "MCO", "MKD", "MNE", "NOR", "SJM", "SMR", "SRB", "VAT"] - "OZE": ["ASM", "ATF", "AUS", "CCK", "COK", "CXR", "FJI", "FSM", "GUM", "HMD", "IOT", "KIR", "MDV", "MHL", "MNP", "NCL", "NFK", "NIU", "NRU", "NZL", "PCN", "PLW", "PYF", "SLB", "TKL", "TLS", "TON", "TUV", "UMI", "VUT", "WLF", "WSM"] - "REF": ["ARM", "AZE", "GEO", "KAZ", "KGZ", "MDA", "MNG", "TJK", "TKM", "UZB"] - "RME": ["IRN", "IRQ", "ISR", "JOR", "LBN", "PSE", "SYR", "YEM"] - "RUS": ["BLR", "RUS"] - "SAF": ["AGO", "BWA", "CAF", "CMR", "COD", "COG", "COM", "GAB", "GNQ", "LSO", "MDG", "MOZ", "MUS", "MWI", "MYT", "NAM", "REU", "SHN", "STP", "SWZ", "SYC", "ZAF", "ZMB", "ZWE"] - "SAM": ["BOL", "COL", "ECU", "FLK", "GUF", "GUY", "PER", "PRY", "SGS", "SUR", "VEN"] - "SEA": ["BRN", "KHM", "LAO", "MMR", "MYS", "PHL", "PNG", "SGP", "THA", "VNM"] - "SSM": ["ARG", "ATA", "BVT", "CHL", "URY"] - "TUR": ["TUR"] - "UKR": ["UKR"] - "USA": ["USA"] - "WAS": ["AFG", "BGD", "BTN", "LKA", "NPL", "PAK"] - "WSA": ["BEN", "BFA", "CIV", "CPV", "ERI", "GHA", "GIN", "GMB", "GNB", "LBR", "MLI", "MRT", "NER", "NGA", "SEN", "SLE", "TCD", "TGO"] + "Europe": ["ALB","AUT","BLR","BEL","BIH","BGR","HRV","CYP","CZE","DNK","EST","FIN","DEU","GRC","HUN","ITA","XKX","LVA","LTU","LUX","MKD","MDA","MNE","NLD","NOR","POL","ROU","SRB","SVK","SVN","SWE","CHE","TUR","UKR"] + "Far_West_Europe": ["FRA","GRL","ISL","IRL","PRT","ESP","GBR"] + "Middle_East": ["BHR","IRN","IRQ","ISR","JOR","KWT","LBN","OMN","PSE","QAT","SAU","SYR","ARE","YEM"] + "North_West_Africa": ["BEN","BFA","CIV","GMB","GHA","GIN","GNB","LBR","NGA","SEN","SLE","TGO","DZA","TCD","EGY","ERI","LBY","MLI","MRT","MAR","NER","SDN","TUN","ESH"] + "Subsaharan_Africa": ["AGO","BWA","BDI","CMR","CAF","COD","ETH","KEN","GAB","MDG","MWI","MOZ","NAM","COG","RWA","SOM","ZAF","SSD","TZA","UGA","ZMB","ZWE"] + "North_America": ["CAN","USA"] + "Eurasia": ["ARM","AZE","GEO","KAZ","KGZ","TJK","TKM","UZB","RUS"] + "South_America": ["BOL","BRA","COL","ECU","GUF","GUY","PRY","PER","SUR","VEN"] + "South_South_America": ["ARG","CHL","URY"] + "Central_America": ["CRI","SLV","GTM","HND","MEX","NIC","PAN","CUB","DOM","JAM","HTI"] + "West_Asia": ["AFG","BGD","BTN","IND","NPL","PAK","LKA"] + "East_Asia": ["CHN","HKG","MNG","TWN"] + "Pacific_Asia": ["BRN","KHM","IDN","LAO","MYS","MMR","PNG","PHL","SGP","THA","VNM"] + "East_East_Asia": ["JPN","KOR","PRK"] + "Oceania": ["AUS","NZL"] region_nice_names: "Europe": "Europe"