From 2c9638dabae5522d83f8af7908ecc71ac452b9f1 Mon Sep 17 00:00:00 2001 From: BertoGBG Date: Tue, 23 Jun 2026 09:15:37 +0200 Subject: [PATCH 1/4] Add biochar as a carbon dioxide removal technology Ported from the a_CDRs development branch. Adds biochar production (biomass pyrolysis with stable-carbon soil storage) as an optional CDR technology: a single pyrolysis Link per node converting biomass and electricity into stored biochar-carbon, with optional district-heat export. Land potential comes from the shared CORINE-based availability- matrix machinery (determine_carbon_dioxide_removal_availability_matrix + build_available_land), reused here for biochar only. Off by default (sector.biochar.enable: false). Co-Authored-By: Claude Sonnet 4.6 --- config/config.default.yaml | 15 ++ config/plotting.default.yaml | 2 + rules/build_sector.smk | 51 +++++++ scripts/_check_utils.py | 218 +++++++++++++++++++++++++++ scripts/build_available_land.py | 71 +++++++++ scripts/check_biochar_pipeline.py | 240 ++++++++++++++++++++++++++++++ scripts/prepare_sector_network.py | 140 +++++++++++++++++ 7 files changed, 737 insertions(+) create mode 100644 scripts/_check_utils.py create mode 100644 scripts/build_available_land.py create mode 100644 scripts/check_biochar_pipeline.py diff --git a/config/config.default.yaml b/config/config.default.yaml index eee27b2fb3..86ca150284 100644 --- a/config/config.default.yaml +++ b/config/config.default.yaml @@ -418,6 +418,13 @@ renewable: eia_norm_year: false eia_correct_by_capacity: false eia_approximate_missing: false + # land-eligibility settings for alternative carbon dioxide removal technologies, + # feeding determine_availability_matrix.py the same way onwind/solar do above + biochar: + corine: [12, 13, 14] # CORINE Land Cover codes for non-irrigated arable land (potential biochar feedstock land) + natura: false + cutout: default + excluder_resolution: 250 # docs in https://pypsa-eur.readthedocs.io/en/latest/configuration.html#conventional conventional: @@ -836,6 +843,9 @@ sector: methanation: true coal_cc: false dac: true + biochar: + enable: false + heat_output: false co2_vent: false heat_vent: urban central: true @@ -1069,6 +1079,11 @@ industry: reference_year: 2023 oil_refining_emissions: 0.013 +biochar: + application_per_sqkm: 1500 # tonnes of biochar per km² (≈15 t/ha) + max_land_usage: 0.2 # maximum fraction of suitable CORINE area used for biochar + number_years: 25 # amortisation period (years) for biochar CO2 storage + # docs in https://pypsa-eur.readthedocs.io/en/latest/configuration.html#costs costs: year: 2050 diff --git a/config/plotting.default.yaml b/config/plotting.default.yaml index a2d7405123..4409af0225 100644 --- a/config/plotting.default.yaml +++ b/config/plotting.default.yaml @@ -737,3 +737,5 @@ plotting: import NH3: '#e2ed74' import oil: '#93eda2' import methanol: '#87d0e6' + biochar heat: '#228B22' + co2 biochar: '#005000' diff --git a/rules/build_sector.smk b/rules/build_sector.smk index 59b6540fea..43e375a039 100755 --- a/rules/build_sector.smk +++ b/rules/build_sector.smk @@ -920,6 +920,52 @@ rule build_biomass_potentials: scripts("build_biomass_potentials.py") +rule determine_carbon_dioxide_removal_availability_matrix: + message: + "Determining availability matrix for {wildcards.clusters} clusters and {wildcards.technology} carbon dioxide removal technology" + params: + renewable=config_provider("renewable"), + plot_availability_matrix=config_provider("atlite", "plot_availability_matrix"), + input: + corine=ancient(rules.retrieve_corine.output["tif_file"]), + regions=resources("regions_onshore_base_s_{clusters}.geojson"), + cutout=lambda w: input_cutout( + w, config_provider("renewable", w.technology, "cutout")(w) + ), + output: + resources("availability_matrix_carbon_dioxide_removal_{clusters}_{technology}.nc"), + log: + logs("determine_carbon_dioxide_removal_availability_matrix_{clusters}_{technology}.log"), + wildcard_constraints: + technology="biochar", + threads: config["atlite"].get("nprocesses", 4) + resources: + mem_mb=config["atlite"].get("nprocesses", 4) * 5000, + script: + scripts("determine_availability_matrix.py") + + +rule build_available_land: + params: + renewable=config_provider("renewable"), + input: + availability_matrix=resources("availability_matrix_carbon_dioxide_removal_{clusters}_{technology}.nc"), + regions=resources("regions_onshore_base_s_{clusters}.geojson"), + cutout=lambda w: input_cutout( + w, config_provider("renewable", w.technology, "cutout")(w) + ), + output: + csv_file=resources("{technology}_available_land_s_{clusters}.csv"), + log: + logs("build_available_land_{clusters}_{technology}.log"), + wildcard_constraints: + technology="biochar", + resources: + mem_mb=8000, + script: + scripts("build_available_land.py") + + rule build_biomass_transport_costs: input: sc1="data/biomass_transport_costs_supplychain1.csv", @@ -1689,6 +1735,11 @@ rule prepare_sector_network: if config_provider("sector", "district_heating", "ates", "enable")(w) else [] ), + biochar_potentials=lambda w: ( + resources("biochar_available_land_s_{clusters}.csv") + if config_provider("sector", "biochar", "enable")(w) + else [] + ), output: resources( "networks/base_s_{clusters}_{opts}_{sector_opts}_{planning_horizons}.nc" diff --git a/scripts/_check_utils.py b/scripts/_check_utils.py new file mode 100644 index 0000000000..2dc9a6f1a0 --- /dev/null +++ b/scripts/_check_utils.py @@ -0,0 +1,218 @@ +""" +Shared utilities for carbon dioxide removal pipeline check scripts. + +Loads run parameters from the saved pypsa-eur config so check scripts need +only a single input: the run name. + +Usage in any check script: + + from _check_utils import parse_check_args, load_check_params + + args = parse_check_args() # positional run_name, optional overrides + p = load_check_params(args) # returns dict with RDIR, WC, paths, cfg, … + +Run from the pypsa-eur root: + + python scripts/check_EW_pipeline.py EW_2050 + python scripts/check_EW_pipeline.py --config results/EW_2050/EW_2050/configs/config.base_s_90__168h_2050.yaml +""" + +import argparse +import sys +from pathlib import Path + +import yaml + +BASE_DIR = Path(".") # check scripts are run from the pypsa-eur root + + +# ── Saved-config discovery ───────────────────────────────────────────────────── + +def find_saved_config(run_name: str, base_dir: Path) -> Path: + """Find the config saved by pypsa-eur under results//**/configs/.""" + pattern = f"results/{run_name}/**/configs/config.*.yaml" + hits = sorted(base_dir.glob(pattern)) + if not hits: + sys.exit( + f"ERROR: no saved config found for run '{run_name}'.\n" + f" Looked for: {base_dir / pattern}\n" + f" Use --config to point directly at the config file." + ) + if len(hits) > 1: + print( + "WARNING: multiple saved configs found; using first:\n " + + "\n ".join(str(h) for h in hits) + ) + return hits[0] + + +def _load_yaml(path: Path) -> dict: + with open(path) as f: + return yaml.safe_load(f) or {} + + +def load_config(args: argparse.Namespace, base_dir: Path = BASE_DIR) -> tuple: + """ + Load the saved run config. Returns (cfg_dict, config_path). + + Priority: + 1. --config → load that file directly + 2. run_name (positional) → auto-discover under results//**/configs/ + """ + if getattr(args, "config", None): + config_path = Path(args.config) + if not config_path.exists(): + sys.exit(f"ERROR: config file not found: {config_path}") + elif getattr(args, "run_name", None): + config_path = find_saved_config(args.run_name, base_dir) + else: + sys.exit("ERROR: provide a run_name or --config .") + + cfg = _load_yaml(config_path) + print(f"Config: {config_path}") + return cfg, config_path + + +# ── CLI ──────────────────────────────────────────────────────────────────────── + +def parse_check_args(extra_args=None) -> argparse.Namespace: + """ + Common CLI parser for all carbon dioxide removal check scripts. + + Positional: + run_name Run directory name (e.g. rock_weathering_2050). Script finds the saved + config under results//**/configs/ automatically. + + Optional overrides (take precedence over config values): + --config Direct path to a saved config YAML (skips auto-discovery). + --base-dir pypsa-eur root directory (default: current directory). + --run-name Override run name from config. + --clusters Override cluster count. + --opts Override opts wildcard. + --sector-opts Override sector opts wildcard. + --horizon Override planning horizon year. + + extra_args: list of ([flags], kwargs) for script-specific arguments. + """ + p = argparse.ArgumentParser( + description="Carbon dioxide removal pipeline check — reads wildcards from the saved run config.", + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + ) + p.add_argument( + "run_name", nargs="?", + help="Run name (e.g. EW_2050). Auto-discovers saved config under results//.", + ) + p.add_argument( + "--config", "-c", default=None, metavar="PATH", + help="Direct path to a saved config YAML (overrides run_name auto-discovery).", + ) + p.add_argument( + "--base-dir", default=".", metavar="DIR", + help="pypsa-eur root directory (default: current directory).", + ) + p.add_argument("--run-name", default=None, metavar="NAME", + help="Override run name (= results/resources sub-directory).") + p.add_argument("--clusters", default=None, metavar="N", + help="Override cluster count (e.g. 90).") + p.add_argument("--opts", default=None, metavar="OPTS", + help="Override opts wildcard.") + p.add_argument("--sector-opts", default=None, metavar="OPTS", + help="Override sector opts wildcard (e.g. 168h).") + p.add_argument("--horizon", default=None, metavar="YEAR", + help="Override planning horizon year (e.g. 2050).") + + if extra_args: + for flags, kwargs in extra_args: + p.add_argument(*flags, **kwargs) + + args = p.parse_args() + if not args.run_name and not args.config: + p.print_help() + sys.exit(1) + return args + + +# ── Parameter extraction ─────────────────────────────────────────────────────── + +def load_check_params(args: argparse.Namespace) -> dict: + """ + Load the saved config and return a dict with all derived parameters: + + RDIR – run directory name + CLUSTERS – cluster count string (e.g. "90") + OPTS – opts wildcard (e.g. "") + SECTOR_OPTS – sector opts wildcard (e.g. "168h") + PLANNING_HORIZON – planning horizon string (e.g. "2050") + WC – full wildcard stem (e.g. "base_s_90__168h_2050") + SHARED_RES – shared-resources Path (= RES_RUN if no shared policy) + BASE_DIR – Path to pypsa-eur root + RES – Path to resources/ + RES_RUN – Path to resources// + RESULTS – Path to results// + cfg – full config dict (for script-specific lookups) + """ + base_dir = Path(getattr(args, "base_dir", ".")) + cfg, _ = load_config(args, base_dir) + + run_cfg = cfg.get("run", {}) + scenario_cfg = cfg.get("scenario", {}) + + # CLI --run-name > config run.name > positional run_name + RDIR = ( + getattr(args, "run_name_override", None) # --run-name flag (argparse stores as run_name_override below) + or run_cfg.get("name") + or getattr(args, "run_name", None) + or "run" + ) + # Note: argparse stores --run-name as args.run_name which clashes with the + # positional. We use dest="run_name_cli" to separate them. + # In practice, parse_check_args stores --run-name in args.run_name (the flag) + # and the positional in args.run_name too — last one wins in argparse. + # Simpler: just use config value as primary, CLI flags as overrides. + RDIR = run_cfg.get("name") or getattr(args, "run_name", None) or "run" + if getattr(args, "run_name", None) and not run_cfg.get("name"): + RDIR = args.run_name + + CLUSTERS = str( + args.clusters + or (scenario_cfg.get("clusters") or [90])[0] + ) + OPTS = ( + args.opts if args.opts is not None + else str((scenario_cfg.get("opts") or [""])[0]) + ) + SECTOR_OPTS = str( + args.sector_opts + or (scenario_cfg.get("sector_opts") or ["168h"])[0] + ) + PLANNING_HORIZON = str( + args.horizon + or (scenario_cfg.get("planning_horizons") or [2050])[-1] + ) + + WC = f"base_s_{CLUSTERS}_{OPTS}_{SECTOR_OPTS}_{PLANNING_HORIZON}" + + RES = base_dir / "resources" + RES_RUN = base_dir / "resources" / RDIR + RESULTS = base_dir / "results" / RDIR + + # shared resources: if run.shared_resources.policy is a string, resources live there + shared_policy = run_cfg.get("shared_resources", {}).get("policy", False) + SHARED_RES = (base_dir / "resources" / shared_policy) if shared_policy else RES_RUN + + print(f"Run: {RDIR} | WC: {WC}") + + return dict( + RDIR=RDIR, + CLUSTERS=CLUSTERS, + OPTS=OPTS, + SECTOR_OPTS=SECTOR_OPTS, + PLANNING_HORIZON=PLANNING_HORIZON, + WC=WC, + BASE_DIR=base_dir, + RES=RES, + RES_RUN=RES_RUN, + RESULTS=RESULTS, + SHARED_RES=SHARED_RES, + cfg=cfg, + ) diff --git a/scripts/build_available_land.py b/scripts/build_available_land.py new file mode 100644 index 0000000000..a08f3d903b --- /dev/null +++ b/scripts/build_available_land.py @@ -0,0 +1,71 @@ +# SPDX-FileCopyrightText: Contributors to PyPSA-Eur +# +# SPDX-License-Identifier: MIT +""" +Aggregate a per-bus land-eligibility availability matrix (as produced by +``determine_availability_matrix.py`` / ``determine_rock_weathering_availability_matrix.py``) +into eligible land area per network node, in km². + +Shared by biochar, afforestation and rock weathering: the per-bus fractional +eligibility (0-1 per cutout grid cell) is multiplied by the cutout grid cell +area and summed per bus, recovering the same eligible-area-in-km² number the +previous per-technology scripts (``build_corine_potentials.py``, +``build_EW_potentials.py``) each computed independently via +``atlite.gis.shape_availability``. + +Output: CSV with columns ``area [sqkm]`` (total node area) and +``potential [sqkm]`` (eligible area after exclusions), indexed by node name. +The downstream consumers (``add_biochar``, ``build_afforestation_potentials``, +``add_rock_weathering``) apply their own technology-specific rate (t/km², +growth rate, etc.) to the ``potential [sqkm]`` column. +""" + +import logging + +import geopandas as gpd +import pandas as pd +import xarray as xr + +from scripts._helpers import configure_logging, load_cutout, set_scenario_config + +logger = logging.getLogger(__name__) + + +if __name__ == "__main__": + if "snakemake" not in globals(): + from scripts._helpers import mock_snakemake + + snakemake = mock_snakemake( + "build_available_land", clusters="39", technology="biochar" + ) + configure_logging(snakemake) + set_scenario_config(snakemake) + + technology = snakemake.wildcards.technology + + availability = xr.open_dataarray(snakemake.input.availability_matrix) + cutout = load_cutout(snakemake.input.cutout) + regions = ( + gpd.read_file(snakemake.input.regions).set_index("name").rename_axis("bus") + ) + + cell_area = cutout.grid.to_crs(3035).area / 1e6 + cell_area = xr.DataArray( + cell_area.values.reshape(cutout.shape), + [cutout.coords["y"], cutout.coords["x"]], + ) + + potential = (availability * cell_area).sum(dim=["x", "y"]).to_pandas() + area = regions.geometry.to_crs(3035).area / 1e6 + + df = pd.DataFrame({"area [sqkm]": area, "potential [sqkm]": potential}) + df.index.name = "node" + + logger.info( + "Total %s potential: %.0f sqkm (of %.0f sqkm total node area)", + technology, + df["potential [sqkm]"].sum(), + df["area [sqkm]"].sum(), + ) + + df.to_csv(snakemake.output.csv_file) diff --git a/scripts/check_biochar_pipeline.py b/scripts/check_biochar_pipeline.py new file mode 100644 index 0000000000..52fd4dca4b --- /dev/null +++ b/scripts/check_biochar_pipeline.py @@ -0,0 +1,240 @@ +""" +Check script for the biochar pipeline in pypsa-eur. +Run from the pypsa-eur root directory: + + python scripts/check_biochar_pipeline.py biochar_2050 + +Wildcards are read from the saved run config automatically. +Override any value on the CLI: + --run-name NAME --clusters N --horizon YEAR --sector-opts OPTS + --config PATH (direct path to a saved config YAML) +""" + +import sys +from pathlib import Path + +import pandas as pd + +from _check_utils import parse_check_args, load_check_params + +# ── Configuration (from saved run config) ──────────────────────────────────── +_args = parse_check_args() +_p = load_check_params(_args) + +BASE_DIR = _p["BASE_DIR"] +RDIR = _p["RDIR"] +CLUSTERS = _p["CLUSTERS"] +OPTS = _p["OPTS"] +SECTOR_OPTS = _p["SECTOR_OPTS"] +PLANNING_HORIZON = _p["PLANNING_HORIZON"] +WC = _p["WC"] +RES = _p["RES"] +RES_RUN = _p["RES_RUN"] +RESULTS = _p["RESULTS"] + +_assign_capacity_duals = _p["cfg"].get("solving", {}).get("options", {}).get("assign_capacity_duals", False) + +# ── Helpers ─────────────────────────────────────────────────────────────────── +OK = " [OK]" +FAIL = " [MISSING]" +WARN = " [WARN]" + +passed = [] +failed = [] + + +def check_file(path: Path, label: str) -> bool: + exists = path.exists() + status = OK if exists else FAIL + size = f" ({path.stat().st_size / 1e6:.1f} MB)" if exists else "" + print(f"{status} {label}{size}") + print(f" {path}") + if exists: + passed.append(label) + else: + failed.append(label) + return exists + + +def section(title: str): + print(f"\n{'='*60}") + print(f" {title}") + print("=" * 60) + + +# ── 1. BUILD: biochar available land (determine_carbon_dioxide_removal_availability_matrix + build_available_land) ── +section("1. BUILD: biochar available land") + +biochar_csv = RES_RUN / f"biochar_available_land_s_{CLUSTERS}.csv" + +csv_ok = check_file(biochar_csv, f"Biochar available land CSV s_{CLUSTERS}") + +if csv_ok: + df = pd.read_csv(biochar_csv) + print(f" Rows: {len(df)} | Columns: {list(df.columns)}") + if "potential [sqkm]" in df.columns: + total = df["potential [sqkm]"].sum() + print(f" Total potential area: {total:,.0f} km²") + print(f" Per-node [km²] — min: {df['potential [sqkm]'].min():.1f}, " + f"mean: {df['potential [sqkm]'].mean():.1f}, " + f"max: {df['potential [sqkm]'].max():.1f}") + if df.isnull().any().any(): + print(f"{WARN} NaN values detected in biochar potentials CSV.") + +# ── 2. Pre-network (prepare_sector_network) ─────────────────────────────────── +section("2. PRE-NETWORK: sector-coupled (prepare_sector_network)") + +prenet_path = RES_RUN / "networks" / f"{WC}.nc" +prenet_ok = check_file(prenet_path, f"Pre-network {WC}.nc") + +if prenet_ok: + try: + import pypsa + + n = pypsa.Network(str(prenet_path)) + + # --- Carriers --- + biochar_carriers = [c for c in n.carriers.index if "biochar" in c.lower()] + print(f"\n Carriers with 'biochar': {biochar_carriers}") + + if "co2 biochar" in n.carriers.index: + co2_em = n.carriers.at["co2 biochar", "co2_emissions"] + # co2_emissions = 0.0 is correct: sequestration is modelled via the + # biochar link drawing from co2 atmosphere (bus0), not via carrier attribute. + print(f" co2 biochar co2_emissions = {co2_em} (expected 0.0 — sequestration via network flow)") + if abs(co2_em) > 1e-6: + print(f"{WARN} co2_emissions != 0.0 — check add_biochar()!") + + # --- Buses --- + bc_buses = n.buses[n.buses.carrier.str.contains("biochar", case=False, na=False)] + print(f"\n Buses (carrier contains 'biochar'): {len(bc_buses)}") + if not bc_buses.empty: + print(f" Carriers present: {bc_buses['carrier'].unique().tolist()}") + print(f" Sample (first 5):\n{bc_buses[['location','carrier','unit']].head().to_string()}") + + # --- Links (co2 biochar only) --- + co2_bc_links = n.links[n.links.carrier == "co2 biochar"] + print(f"\n Links (carrier == 'co2 biochar'): {len(co2_bc_links)}") + if not co2_bc_links.empty: + cols = [c for c in ["bus0", "bus1", "carrier", "p_nom_extendable", "capital_cost"] if c in co2_bc_links.columns] + print(co2_bc_links[cols].head(10).to_string()) + + # --- Stores (co2 biochar only) --- + co2_bc_stores = n.stores[n.stores.carrier == "co2 biochar"] + n_nodes = len(n.buses[n.buses.carrier == "AC"]) + print(f"\n Stores (carrier == 'co2 biochar'): {len(co2_bc_stores)} (nodes: {n_nodes})") + # Note: stores with carrier 'biochar heat' (heat waste sinks) are separate + # and counted independently below. + if not co2_bc_stores.empty: + cols = [c for c in ["bus", "carrier", "e_nom_max", "capital_cost", "e_nom_extendable"] if c in co2_bc_stores.columns] + print(co2_bc_stores[cols].head(10).to_string()) + if "e_nom_max" in co2_bc_stores.columns: + finite = co2_bc_stores["e_nom_max"][co2_bc_stores["e_nom_max"] < 1e18] + print(f"\n e_nom_max (finite) [tCO2] — " + f"min: {finite.min():.0f}, mean: {finite.mean():.0f}, max: {finite.max():.0f}") + total_max = finite.sum() + print(f" Total e_nom_max (max potential): {total_max:,.0f} tCO2 " + f"({total_max / 1e6:.3f} MtCO2)") + + # --- Stores (biochar heat waste sinks) --- + heat_bc_stores = n.stores[n.stores.carrier == "biochar heat"] + if not heat_bc_stores.empty: + print(f"\n Stores (carrier == 'biochar heat', heat waste sinks): {len(heat_bc_stores)}") + + if not biochar_carriers: + print(f"\n{WARN} No biochar carriers found — add_biochar may NOT have run!") + elif co2_bc_links.empty or co2_bc_stores.empty: + print(f"\n{WARN} Missing co2 biochar links or stores — check add_biochar() execution!") + else: + print(f"\n{OK} Biochar components present in pre-network.") + + except Exception as e: + print(f"\n{WARN} Could not load pre-network: {e}") +else: + print(f"\n{FAIL} Pre-network missing — prepare_sector_network has not run yet.") + +# ── 3. Solved network (solve_sector_network) ────────────────────────────────── +section("3. OPTIMAL SOLUTION: solved network (solve_sector_network)") + +opt_path = RESULTS / "networks" / f"{WC}.nc" +opt_ok = check_file(opt_path, f"Optimal network {WC}.nc") + +if opt_ok: + try: + import pypsa + + _dual_status = "enabled" if _assign_capacity_duals else "disabled (set assign_capacity_duals: true to enable)" + print(f"\n assign_capacity_duals: {_dual_status}") + + n_opt = pypsa.Network(str(opt_path)) + + co2_bc_links = n_opt.links[n_opt.links.carrier == "co2 biochar"] + co2_bc_stores = n_opt.stores[n_opt.stores.carrier == "co2 biochar"] + + # --- Links optimal (co2 biochar) --- + print(f"\n Links (carrier == 'co2 biochar'): {len(co2_bc_links)}") + if not co2_bc_links.empty and "p_nom_opt" in co2_bc_links.columns: + active = co2_bc_links[co2_bc_links["p_nom_opt"] > 0] + total = co2_bc_links["p_nom_opt"].sum() + print(f" Links with p_nom_opt > 0: {len(active)}") + print(f" Total p_nom_opt (co2 biochar links): {total:,.2f} MW") + if active.empty: + print(f"{WARN} All co2 biochar links have p_nom_opt = 0 (not deployed).") + else: + print(f"{OK} co2 biochar links deployed in optimal solution.") + print(active[["bus0", "bus1", "carrier", "p_nom_opt"]].to_string()) + + # --- Stores optimal (co2 biochar only) --- + print(f"\n Stores (carrier == 'co2 biochar'): {len(co2_bc_stores)}") + if not co2_bc_stores.empty and "e_nom_opt" in co2_bc_stores.columns: + active = co2_bc_stores[co2_bc_stores["e_nom_opt"] > 0] + total = co2_bc_stores["e_nom_opt"].sum() + print(f" Stores with e_nom_opt > 0: {len(active)}") + print(f" Total e_nom_opt: {total:,.0f} tCO2 ({total/1e6:.3f} MtCO2)") + if active.empty: + print(f"{WARN} All co2 biochar stores have e_nom_opt = 0 (not deployed).") + else: + print(f"{OK} Biochar stores deployed. Total CO2 stored: {total/1e6:.3f} MtCO2") + print(f"\n Per-node e_nom_opt [tCO2] stats:") + print(f" min: {co2_bc_stores['e_nom_opt'].min():,.0f}") + print(f" mean: {co2_bc_stores['e_nom_opt'].mean():,.0f}") + print(f" max: {co2_bc_stores['e_nom_opt'].max():,.0f}") + + # --- Shadow price of e_nom_max (assign_capacity_duals) --- + if _assign_capacity_duals: + if "mu_ext_e_nom_upper" in co2_bc_stores.columns: + mu = co2_bc_stores["mu_ext_e_nom_upper"].dropna() + binding = mu[mu.abs() > 1e-6] + print(f"\n mu_ext_e_nom_upper (shadow price of e_nom_max) [{len(mu)} stores]:") + print(f" non-zero (binding): {len(binding)}") + if not mu.empty: + print(f" min: {mu.min():,.4f}") + print(f" mean: {mu.mean():,.4f}") + print(f" max: {mu.max():,.4f}") + if not binding.empty: + print(f"\n Binding nodes (top 10):") + print(binding.sort_values().tail(10).to_string()) + else: + print(f"\n{WARN} mu_ext_e_nom_upper not found in stores — was assign_capacity_duals: true when solving?") + + if co2_bc_links.empty and co2_bc_stores.empty: + print(f"\n{WARN} No co2 biochar components in optimal network!") + + except Exception as e: + print(f"\n{WARN} Could not load optimal network: {e}") +else: + print(f"\n{FAIL} Optimal network missing — solve_sector_network has not run yet.") + +# ── Summary ─────────────────────────────────────────────────────────────────── +section("SUMMARY") +print(f" Run: {RDIR if RDIR else '(none)'}") +print(f" Wildcard: {WC}") +print(f" Resources dir: {RES_RUN.resolve()}") +print(f" Results dir: {RESULTS.resolve()}") +print() +print(f" Passed: {len(passed)}") +print(f" Failed: {len(failed)}") +if failed: + print(f"\n Missing files:") + for f in failed: + print(f" - {f}") diff --git a/scripts/prepare_sector_network.py b/scripts/prepare_sector_network.py index 1f84f038b4..02fa6c2dec 100755 --- a/scripts/prepare_sector_network.py +++ b/scripts/prepare_sector_network.py @@ -1247,6 +1247,143 @@ def add_dac(n, costs): ) +def add_biochar(n, costs): + """ + Add biochar (CDR via biomass pyrolysis and stable-carbon soil storage) + to the network as Bus, Store, and Link components, all sharing a single + "co2 biochar" carrier; optionally exports pyrolysis waste heat to urban + central heating. + + A single pyrolysis Link per node converts solid biomass (bus2) and + electricity (bus3) into stored biochar-carbon (bus1, drawn from the + atmosphere via bus0) and, if enabled, district heat (bus4, with an + overflow Store for heat that cannot be absorbed by the local heat + network). The store's capacity is dimensioned from the eligible CORINE + land area (see ``determine_availability_matrix.py`` and + ``build_available_land.py``) multiplied by a per-km2 application rate + and the stable-carbon fraction (``co2_per_tonne``), amortised over an + assumed storage horizon. + + Parameters + ---------- + n : pypsa.Network + The PyPSA network container object + costs : pd.DataFrame + Costs and parameters for different technologies. Must contain a + 'biochar pyrolysis' entry with 'biomass-input', 'yield-biochar', + 'electricity-input', 'heat-output', 'capital_cost', and 'VOM' + parameters + + Returns + ------- + None + Modifies the network object in-place by adding the biochar Bus, + Store, and Link (and, if enabled, heat-output Bus/Link/Store) + + Notes + ----- + Reads ``snakemake.input.biochar_potentials`` (eligible area in km2 per + node) and ``snakemake.config["biochar"]["application_per_sqkm"]`` / + ``["max_land_usage"]`` / ``["number_years"]``. Heat output is gated by + ``sector.heating`` and ``sector.biochar.heat_output``. + """ + logger.info("Adding biochar.") + + biochar_potentials = pd.read_csv(snakemake.input.biochar_potentials).set_index("node") + + n.add("Carrier", "co2 biochar") + + n.add( + "Bus", + spatial.nodes + " co2 biochar", + location=spatial.nodes, + carrier="co2 biochar", + unit="t_co2", + ) + + co2_per_tonne = ( + 1 / costs.at["biochar pyrolysis", "biomass-input"] + * 1 / costs.at["biochar pyrolysis", "yield-biochar"] + ) # tCO2 / t_biochar + + n.add( + "Store", + spatial.nodes + " co2 biochar", + bus=spatial.nodes + " co2 biochar", + carrier="co2 biochar", + e_nom_extendable=True, + e_nom_max=( + biochar_potentials["potential [sqkm]"].values + * co2_per_tonne + * snakemake.config["biochar"]["application_per_sqkm"] + * snakemake.config["biochar"]["max_land_usage"] + / snakemake.config["biochar"]["number_years"] + ), + ) + + if len(spatial.biomass.nodes) == 1: + biomass_buses = spatial.biomass.nodes + else: + biomass_buses = spatial.nodes + " solid biomass" + + # heat output into urban central heat system + if snakemake.config["sector"]["heating"] and snakemake.config["sector"]["biochar"]["heat_output"]: + logger.info("Adding biochar heat output to urban central heat system.") + biochar_heat_buses = [] + for node in spatial.nodes: + if (node + " urban central heat") in n.buses.index: + biochar_heat_bus = node + " biochar heat" + biochar_heat_buses.append(biochar_heat_bus) + n.add("Bus", biochar_heat_bus, carrier="biochar heat") + n.add( + "Link", + biochar_heat_bus, + bus0=biochar_heat_bus, + bus1=node + " urban central heat", + p_nom_extendable=True, + carrier="biochar heat", + ) + biochar_heat_bus_waste = node + " biochar heat waste" + n.add("Bus", biochar_heat_bus_waste, carrier="biochar heat") + n.add( + "Store", + biochar_heat_bus_waste, + bus=biochar_heat_bus_waste, + e_nom_extendable=True, + carrier="biochar heat", + ) + n.add( + "Link", + biochar_heat_bus_waste, + bus0=biochar_heat_bus, + bus1=biochar_heat_bus_waste, + p_nom_extendable=True, + carrier="biochar heat", + ) + else: + biochar_heat_buses.append(None) + else: + biochar_heat_buses = [None] + + n.add( + "Link", + spatial.nodes + " biochar", + bus0="co2 atmosphere", + bus1=spatial.nodes + " co2 biochar", + bus2=biomass_buses, + bus3=spatial.nodes, + bus4=biochar_heat_buses, + carrier="co2 biochar", + capital_cost=costs.at["biochar pyrolysis", "capital_cost"], + marginal_cost=costs.at["biochar pyrolysis", "VOM"], + efficiency=1.0, + efficiency2=-costs.at["biochar pyrolysis", "biomass-input"], + efficiency3=-costs.at["biochar pyrolysis", "electricity-input"], + efficiency4=costs.at["biochar pyrolysis", "heat-output"], + p_nom_extendable=True, + ) + + def add_co2limit(n, options, co2_totals_file, countries, nyears, limit): """ Add a global CO2 emissions constraint to the network. @@ -6506,6 +6643,9 @@ def add_import_options( if options["dac"]: add_dac(n, costs) + if options.get("biochar", {}).get("enable"): + add_biochar(n, costs) + if not options["electricity_transmission_grid"]: decentral(n) From 91f6bc5dc17552b793973fab1ff8a846b654d87d Mon Sep 17 00:00:00 2001 From: BertoGBG Date: Tue, 23 Jun 2026 10:05:46 +0200 Subject: [PATCH 2/4] Remove check_biochar_pipeline.py diagnostic script from PR --- scripts/_check_utils.py | 218 ---------------------------------------- 1 file changed, 218 deletions(-) delete mode 100644 scripts/_check_utils.py diff --git a/scripts/_check_utils.py b/scripts/_check_utils.py deleted file mode 100644 index 2dc9a6f1a0..0000000000 --- a/scripts/_check_utils.py +++ /dev/null @@ -1,218 +0,0 @@ -""" -Shared utilities for carbon dioxide removal pipeline check scripts. - -Loads run parameters from the saved pypsa-eur config so check scripts need -only a single input: the run name. - -Usage in any check script: - - from _check_utils import parse_check_args, load_check_params - - args = parse_check_args() # positional run_name, optional overrides - p = load_check_params(args) # returns dict with RDIR, WC, paths, cfg, … - -Run from the pypsa-eur root: - - python scripts/check_EW_pipeline.py EW_2050 - python scripts/check_EW_pipeline.py --config results/EW_2050/EW_2050/configs/config.base_s_90__168h_2050.yaml -""" - -import argparse -import sys -from pathlib import Path - -import yaml - -BASE_DIR = Path(".") # check scripts are run from the pypsa-eur root - - -# ── Saved-config discovery ───────────────────────────────────────────────────── - -def find_saved_config(run_name: str, base_dir: Path) -> Path: - """Find the config saved by pypsa-eur under results//**/configs/.""" - pattern = f"results/{run_name}/**/configs/config.*.yaml" - hits = sorted(base_dir.glob(pattern)) - if not hits: - sys.exit( - f"ERROR: no saved config found for run '{run_name}'.\n" - f" Looked for: {base_dir / pattern}\n" - f" Use --config to point directly at the config file." - ) - if len(hits) > 1: - print( - "WARNING: multiple saved configs found; using first:\n " - + "\n ".join(str(h) for h in hits) - ) - return hits[0] - - -def _load_yaml(path: Path) -> dict: - with open(path) as f: - return yaml.safe_load(f) or {} - - -def load_config(args: argparse.Namespace, base_dir: Path = BASE_DIR) -> tuple: - """ - Load the saved run config. Returns (cfg_dict, config_path). - - Priority: - 1. --config → load that file directly - 2. run_name (positional) → auto-discover under results//**/configs/ - """ - if getattr(args, "config", None): - config_path = Path(args.config) - if not config_path.exists(): - sys.exit(f"ERROR: config file not found: {config_path}") - elif getattr(args, "run_name", None): - config_path = find_saved_config(args.run_name, base_dir) - else: - sys.exit("ERROR: provide a run_name or --config .") - - cfg = _load_yaml(config_path) - print(f"Config: {config_path}") - return cfg, config_path - - -# ── CLI ──────────────────────────────────────────────────────────────────────── - -def parse_check_args(extra_args=None) -> argparse.Namespace: - """ - Common CLI parser for all carbon dioxide removal check scripts. - - Positional: - run_name Run directory name (e.g. rock_weathering_2050). Script finds the saved - config under results//**/configs/ automatically. - - Optional overrides (take precedence over config values): - --config Direct path to a saved config YAML (skips auto-discovery). - --base-dir pypsa-eur root directory (default: current directory). - --run-name Override run name from config. - --clusters Override cluster count. - --opts Override opts wildcard. - --sector-opts Override sector opts wildcard. - --horizon Override planning horizon year. - - extra_args: list of ([flags], kwargs) for script-specific arguments. - """ - p = argparse.ArgumentParser( - description="Carbon dioxide removal pipeline check — reads wildcards from the saved run config.", - formatter_class=argparse.ArgumentDefaultsHelpFormatter, - ) - p.add_argument( - "run_name", nargs="?", - help="Run name (e.g. EW_2050). Auto-discovers saved config under results//.", - ) - p.add_argument( - "--config", "-c", default=None, metavar="PATH", - help="Direct path to a saved config YAML (overrides run_name auto-discovery).", - ) - p.add_argument( - "--base-dir", default=".", metavar="DIR", - help="pypsa-eur root directory (default: current directory).", - ) - p.add_argument("--run-name", default=None, metavar="NAME", - help="Override run name (= results/resources sub-directory).") - p.add_argument("--clusters", default=None, metavar="N", - help="Override cluster count (e.g. 90).") - p.add_argument("--opts", default=None, metavar="OPTS", - help="Override opts wildcard.") - p.add_argument("--sector-opts", default=None, metavar="OPTS", - help="Override sector opts wildcard (e.g. 168h).") - p.add_argument("--horizon", default=None, metavar="YEAR", - help="Override planning horizon year (e.g. 2050).") - - if extra_args: - for flags, kwargs in extra_args: - p.add_argument(*flags, **kwargs) - - args = p.parse_args() - if not args.run_name and not args.config: - p.print_help() - sys.exit(1) - return args - - -# ── Parameter extraction ─────────────────────────────────────────────────────── - -def load_check_params(args: argparse.Namespace) -> dict: - """ - Load the saved config and return a dict with all derived parameters: - - RDIR – run directory name - CLUSTERS – cluster count string (e.g. "90") - OPTS – opts wildcard (e.g. "") - SECTOR_OPTS – sector opts wildcard (e.g. "168h") - PLANNING_HORIZON – planning horizon string (e.g. "2050") - WC – full wildcard stem (e.g. "base_s_90__168h_2050") - SHARED_RES – shared-resources Path (= RES_RUN if no shared policy) - BASE_DIR – Path to pypsa-eur root - RES – Path to resources/ - RES_RUN – Path to resources// - RESULTS – Path to results// - cfg – full config dict (for script-specific lookups) - """ - base_dir = Path(getattr(args, "base_dir", ".")) - cfg, _ = load_config(args, base_dir) - - run_cfg = cfg.get("run", {}) - scenario_cfg = cfg.get("scenario", {}) - - # CLI --run-name > config run.name > positional run_name - RDIR = ( - getattr(args, "run_name_override", None) # --run-name flag (argparse stores as run_name_override below) - or run_cfg.get("name") - or getattr(args, "run_name", None) - or "run" - ) - # Note: argparse stores --run-name as args.run_name which clashes with the - # positional. We use dest="run_name_cli" to separate them. - # In practice, parse_check_args stores --run-name in args.run_name (the flag) - # and the positional in args.run_name too — last one wins in argparse. - # Simpler: just use config value as primary, CLI flags as overrides. - RDIR = run_cfg.get("name") or getattr(args, "run_name", None) or "run" - if getattr(args, "run_name", None) and not run_cfg.get("name"): - RDIR = args.run_name - - CLUSTERS = str( - args.clusters - or (scenario_cfg.get("clusters") or [90])[0] - ) - OPTS = ( - args.opts if args.opts is not None - else str((scenario_cfg.get("opts") or [""])[0]) - ) - SECTOR_OPTS = str( - args.sector_opts - or (scenario_cfg.get("sector_opts") or ["168h"])[0] - ) - PLANNING_HORIZON = str( - args.horizon - or (scenario_cfg.get("planning_horizons") or [2050])[-1] - ) - - WC = f"base_s_{CLUSTERS}_{OPTS}_{SECTOR_OPTS}_{PLANNING_HORIZON}" - - RES = base_dir / "resources" - RES_RUN = base_dir / "resources" / RDIR - RESULTS = base_dir / "results" / RDIR - - # shared resources: if run.shared_resources.policy is a string, resources live there - shared_policy = run_cfg.get("shared_resources", {}).get("policy", False) - SHARED_RES = (base_dir / "resources" / shared_policy) if shared_policy else RES_RUN - - print(f"Run: {RDIR} | WC: {WC}") - - return dict( - RDIR=RDIR, - CLUSTERS=CLUSTERS, - OPTS=OPTS, - SECTOR_OPTS=SECTOR_OPTS, - PLANNING_HORIZON=PLANNING_HORIZON, - WC=WC, - BASE_DIR=base_dir, - RES=RES, - RES_RUN=RES_RUN, - RESULTS=RESULTS, - SHARED_RES=SHARED_RES, - cfg=cfg, - ) From 1bde5387baf29981a2406301a9bd792f23eaa0c4 Mon Sep 17 00:00:00 2001 From: BertoGBG Date: Tue, 23 Jun 2026 10:09:37 +0200 Subject: [PATCH 3/4] Remove check_biochar_pipeline.py diagnostic script from PR --- scripts/check_biochar_pipeline.py | 240 ------------------------------ 1 file changed, 240 deletions(-) delete mode 100644 scripts/check_biochar_pipeline.py diff --git a/scripts/check_biochar_pipeline.py b/scripts/check_biochar_pipeline.py deleted file mode 100644 index 52fd4dca4b..0000000000 --- a/scripts/check_biochar_pipeline.py +++ /dev/null @@ -1,240 +0,0 @@ -""" -Check script for the biochar pipeline in pypsa-eur. -Run from the pypsa-eur root directory: - - python scripts/check_biochar_pipeline.py biochar_2050 - -Wildcards are read from the saved run config automatically. -Override any value on the CLI: - --run-name NAME --clusters N --horizon YEAR --sector-opts OPTS - --config PATH (direct path to a saved config YAML) -""" - -import sys -from pathlib import Path - -import pandas as pd - -from _check_utils import parse_check_args, load_check_params - -# ── Configuration (from saved run config) ──────────────────────────────────── -_args = parse_check_args() -_p = load_check_params(_args) - -BASE_DIR = _p["BASE_DIR"] -RDIR = _p["RDIR"] -CLUSTERS = _p["CLUSTERS"] -OPTS = _p["OPTS"] -SECTOR_OPTS = _p["SECTOR_OPTS"] -PLANNING_HORIZON = _p["PLANNING_HORIZON"] -WC = _p["WC"] -RES = _p["RES"] -RES_RUN = _p["RES_RUN"] -RESULTS = _p["RESULTS"] - -_assign_capacity_duals = _p["cfg"].get("solving", {}).get("options", {}).get("assign_capacity_duals", False) - -# ── Helpers ─────────────────────────────────────────────────────────────────── -OK = " [OK]" -FAIL = " [MISSING]" -WARN = " [WARN]" - -passed = [] -failed = [] - - -def check_file(path: Path, label: str) -> bool: - exists = path.exists() - status = OK if exists else FAIL - size = f" ({path.stat().st_size / 1e6:.1f} MB)" if exists else "" - print(f"{status} {label}{size}") - print(f" {path}") - if exists: - passed.append(label) - else: - failed.append(label) - return exists - - -def section(title: str): - print(f"\n{'='*60}") - print(f" {title}") - print("=" * 60) - - -# ── 1. BUILD: biochar available land (determine_carbon_dioxide_removal_availability_matrix + build_available_land) ── -section("1. BUILD: biochar available land") - -biochar_csv = RES_RUN / f"biochar_available_land_s_{CLUSTERS}.csv" - -csv_ok = check_file(biochar_csv, f"Biochar available land CSV s_{CLUSTERS}") - -if csv_ok: - df = pd.read_csv(biochar_csv) - print(f" Rows: {len(df)} | Columns: {list(df.columns)}") - if "potential [sqkm]" in df.columns: - total = df["potential [sqkm]"].sum() - print(f" Total potential area: {total:,.0f} km²") - print(f" Per-node [km²] — min: {df['potential [sqkm]'].min():.1f}, " - f"mean: {df['potential [sqkm]'].mean():.1f}, " - f"max: {df['potential [sqkm]'].max():.1f}") - if df.isnull().any().any(): - print(f"{WARN} NaN values detected in biochar potentials CSV.") - -# ── 2. Pre-network (prepare_sector_network) ─────────────────────────────────── -section("2. PRE-NETWORK: sector-coupled (prepare_sector_network)") - -prenet_path = RES_RUN / "networks" / f"{WC}.nc" -prenet_ok = check_file(prenet_path, f"Pre-network {WC}.nc") - -if prenet_ok: - try: - import pypsa - - n = pypsa.Network(str(prenet_path)) - - # --- Carriers --- - biochar_carriers = [c for c in n.carriers.index if "biochar" in c.lower()] - print(f"\n Carriers with 'biochar': {biochar_carriers}") - - if "co2 biochar" in n.carriers.index: - co2_em = n.carriers.at["co2 biochar", "co2_emissions"] - # co2_emissions = 0.0 is correct: sequestration is modelled via the - # biochar link drawing from co2 atmosphere (bus0), not via carrier attribute. - print(f" co2 biochar co2_emissions = {co2_em} (expected 0.0 — sequestration via network flow)") - if abs(co2_em) > 1e-6: - print(f"{WARN} co2_emissions != 0.0 — check add_biochar()!") - - # --- Buses --- - bc_buses = n.buses[n.buses.carrier.str.contains("biochar", case=False, na=False)] - print(f"\n Buses (carrier contains 'biochar'): {len(bc_buses)}") - if not bc_buses.empty: - print(f" Carriers present: {bc_buses['carrier'].unique().tolist()}") - print(f" Sample (first 5):\n{bc_buses[['location','carrier','unit']].head().to_string()}") - - # --- Links (co2 biochar only) --- - co2_bc_links = n.links[n.links.carrier == "co2 biochar"] - print(f"\n Links (carrier == 'co2 biochar'): {len(co2_bc_links)}") - if not co2_bc_links.empty: - cols = [c for c in ["bus0", "bus1", "carrier", "p_nom_extendable", "capital_cost"] if c in co2_bc_links.columns] - print(co2_bc_links[cols].head(10).to_string()) - - # --- Stores (co2 biochar only) --- - co2_bc_stores = n.stores[n.stores.carrier == "co2 biochar"] - n_nodes = len(n.buses[n.buses.carrier == "AC"]) - print(f"\n Stores (carrier == 'co2 biochar'): {len(co2_bc_stores)} (nodes: {n_nodes})") - # Note: stores with carrier 'biochar heat' (heat waste sinks) are separate - # and counted independently below. - if not co2_bc_stores.empty: - cols = [c for c in ["bus", "carrier", "e_nom_max", "capital_cost", "e_nom_extendable"] if c in co2_bc_stores.columns] - print(co2_bc_stores[cols].head(10).to_string()) - if "e_nom_max" in co2_bc_stores.columns: - finite = co2_bc_stores["e_nom_max"][co2_bc_stores["e_nom_max"] < 1e18] - print(f"\n e_nom_max (finite) [tCO2] — " - f"min: {finite.min():.0f}, mean: {finite.mean():.0f}, max: {finite.max():.0f}") - total_max = finite.sum() - print(f" Total e_nom_max (max potential): {total_max:,.0f} tCO2 " - f"({total_max / 1e6:.3f} MtCO2)") - - # --- Stores (biochar heat waste sinks) --- - heat_bc_stores = n.stores[n.stores.carrier == "biochar heat"] - if not heat_bc_stores.empty: - print(f"\n Stores (carrier == 'biochar heat', heat waste sinks): {len(heat_bc_stores)}") - - if not biochar_carriers: - print(f"\n{WARN} No biochar carriers found — add_biochar may NOT have run!") - elif co2_bc_links.empty or co2_bc_stores.empty: - print(f"\n{WARN} Missing co2 biochar links or stores — check add_biochar() execution!") - else: - print(f"\n{OK} Biochar components present in pre-network.") - - except Exception as e: - print(f"\n{WARN} Could not load pre-network: {e}") -else: - print(f"\n{FAIL} Pre-network missing — prepare_sector_network has not run yet.") - -# ── 3. Solved network (solve_sector_network) ────────────────────────────────── -section("3. OPTIMAL SOLUTION: solved network (solve_sector_network)") - -opt_path = RESULTS / "networks" / f"{WC}.nc" -opt_ok = check_file(opt_path, f"Optimal network {WC}.nc") - -if opt_ok: - try: - import pypsa - - _dual_status = "enabled" if _assign_capacity_duals else "disabled (set assign_capacity_duals: true to enable)" - print(f"\n assign_capacity_duals: {_dual_status}") - - n_opt = pypsa.Network(str(opt_path)) - - co2_bc_links = n_opt.links[n_opt.links.carrier == "co2 biochar"] - co2_bc_stores = n_opt.stores[n_opt.stores.carrier == "co2 biochar"] - - # --- Links optimal (co2 biochar) --- - print(f"\n Links (carrier == 'co2 biochar'): {len(co2_bc_links)}") - if not co2_bc_links.empty and "p_nom_opt" in co2_bc_links.columns: - active = co2_bc_links[co2_bc_links["p_nom_opt"] > 0] - total = co2_bc_links["p_nom_opt"].sum() - print(f" Links with p_nom_opt > 0: {len(active)}") - print(f" Total p_nom_opt (co2 biochar links): {total:,.2f} MW") - if active.empty: - print(f"{WARN} All co2 biochar links have p_nom_opt = 0 (not deployed).") - else: - print(f"{OK} co2 biochar links deployed in optimal solution.") - print(active[["bus0", "bus1", "carrier", "p_nom_opt"]].to_string()) - - # --- Stores optimal (co2 biochar only) --- - print(f"\n Stores (carrier == 'co2 biochar'): {len(co2_bc_stores)}") - if not co2_bc_stores.empty and "e_nom_opt" in co2_bc_stores.columns: - active = co2_bc_stores[co2_bc_stores["e_nom_opt"] > 0] - total = co2_bc_stores["e_nom_opt"].sum() - print(f" Stores with e_nom_opt > 0: {len(active)}") - print(f" Total e_nom_opt: {total:,.0f} tCO2 ({total/1e6:.3f} MtCO2)") - if active.empty: - print(f"{WARN} All co2 biochar stores have e_nom_opt = 0 (not deployed).") - else: - print(f"{OK} Biochar stores deployed. Total CO2 stored: {total/1e6:.3f} MtCO2") - print(f"\n Per-node e_nom_opt [tCO2] stats:") - print(f" min: {co2_bc_stores['e_nom_opt'].min():,.0f}") - print(f" mean: {co2_bc_stores['e_nom_opt'].mean():,.0f}") - print(f" max: {co2_bc_stores['e_nom_opt'].max():,.0f}") - - # --- Shadow price of e_nom_max (assign_capacity_duals) --- - if _assign_capacity_duals: - if "mu_ext_e_nom_upper" in co2_bc_stores.columns: - mu = co2_bc_stores["mu_ext_e_nom_upper"].dropna() - binding = mu[mu.abs() > 1e-6] - print(f"\n mu_ext_e_nom_upper (shadow price of e_nom_max) [{len(mu)} stores]:") - print(f" non-zero (binding): {len(binding)}") - if not mu.empty: - print(f" min: {mu.min():,.4f}") - print(f" mean: {mu.mean():,.4f}") - print(f" max: {mu.max():,.4f}") - if not binding.empty: - print(f"\n Binding nodes (top 10):") - print(binding.sort_values().tail(10).to_string()) - else: - print(f"\n{WARN} mu_ext_e_nom_upper not found in stores — was assign_capacity_duals: true when solving?") - - if co2_bc_links.empty and co2_bc_stores.empty: - print(f"\n{WARN} No co2 biochar components in optimal network!") - - except Exception as e: - print(f"\n{WARN} Could not load optimal network: {e}") -else: - print(f"\n{FAIL} Optimal network missing — solve_sector_network has not run yet.") - -# ── Summary ─────────────────────────────────────────────────────────────────── -section("SUMMARY") -print(f" Run: {RDIR if RDIR else '(none)'}") -print(f" Wildcard: {WC}") -print(f" Resources dir: {RES_RUN.resolve()}") -print(f" Results dir: {RESULTS.resolve()}") -print() -print(f" Passed: {len(passed)}") -print(f" Failed: {len(failed)}") -if failed: - print(f"\n Missing files:") - for f in failed: - print(f" - {f}") From d091e7c8ed63bdca8da91dca59683d21a657e329 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 13 Jul 2026 11:23:23 +0000 Subject: [PATCH 4/4] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- rules/build_sector.smk | 26 ++++++++++++++++---------- scripts/prepare_sector_network.py | 15 +++++++++++---- 2 files changed, 27 insertions(+), 14 deletions(-) diff --git a/rules/build_sector.smk b/rules/build_sector.smk index 43e375a039..4b5502eef8 100755 --- a/rules/build_sector.smk +++ b/rules/build_sector.smk @@ -921,11 +921,6 @@ rule build_biomass_potentials: rule determine_carbon_dioxide_removal_availability_matrix: - message: - "Determining availability matrix for {wildcards.clusters} clusters and {wildcards.technology} carbon dioxide removal technology" - params: - renewable=config_provider("renewable"), - plot_availability_matrix=config_provider("atlite", "plot_availability_matrix"), input: corine=ancient(rules.retrieve_corine.output["tif_file"]), regions=resources("regions_onshore_base_s_{clusters}.geojson"), @@ -933,23 +928,32 @@ rule determine_carbon_dioxide_removal_availability_matrix: w, config_provider("renewable", w.technology, "cutout")(w) ), output: - resources("availability_matrix_carbon_dioxide_removal_{clusters}_{technology}.nc"), + resources( + "availability_matrix_carbon_dioxide_removal_{clusters}_{technology}.nc" + ), log: - logs("determine_carbon_dioxide_removal_availability_matrix_{clusters}_{technology}.log"), + logs( + "determine_carbon_dioxide_removal_availability_matrix_{clusters}_{technology}.log" + ), wildcard_constraints: technology="biochar", threads: config["atlite"].get("nprocesses", 4) resources: mem_mb=config["atlite"].get("nprocesses", 4) * 5000, + params: + renewable=config_provider("renewable"), + plot_availability_matrix=config_provider("atlite", "plot_availability_matrix"), + message: + "Determining availability matrix for {wildcards.clusters} clusters and {wildcards.technology} carbon dioxide removal technology" script: scripts("determine_availability_matrix.py") rule build_available_land: - params: - renewable=config_provider("renewable"), input: - availability_matrix=resources("availability_matrix_carbon_dioxide_removal_{clusters}_{technology}.nc"), + availability_matrix=resources( + "availability_matrix_carbon_dioxide_removal_{clusters}_{technology}.nc" + ), regions=resources("regions_onshore_base_s_{clusters}.geojson"), cutout=lambda w: input_cutout( w, config_provider("renewable", w.technology, "cutout")(w) @@ -962,6 +966,8 @@ rule build_available_land: technology="biochar", resources: mem_mb=8000, + params: + renewable=config_provider("renewable"), script: scripts("build_available_land.py") diff --git a/scripts/prepare_sector_network.py b/scripts/prepare_sector_network.py index 1b3c940a12..95dbedd3e9 100755 --- a/scripts/prepare_sector_network.py +++ b/scripts/prepare_sector_network.py @@ -1289,7 +1289,9 @@ def add_biochar(n, costs): """ logger.info("Adding biochar.") - biochar_potentials = pd.read_csv(snakemake.input.biochar_potentials).set_index("node") + biochar_potentials = pd.read_csv(snakemake.input.biochar_potentials).set_index( + "node" + ) n.add("Carrier", "co2 biochar") @@ -1302,8 +1304,10 @@ def add_biochar(n, costs): ) co2_per_tonne = ( - 1 / costs.at["biochar pyrolysis", "biomass-input"] - * 1 / costs.at["biochar pyrolysis", "yield-biochar"] + 1 + / costs.at["biochar pyrolysis", "biomass-input"] + * 1 + / costs.at["biochar pyrolysis", "yield-biochar"] ) # tCO2 / t_biochar n.add( @@ -1327,7 +1331,10 @@ def add_biochar(n, costs): biomass_buses = spatial.nodes + " solid biomass" # heat output into urban central heat system - if snakemake.config["sector"]["heating"] and snakemake.config["sector"]["biochar"]["heat_output"]: + if ( + snakemake.config["sector"]["heating"] + and snakemake.config["sector"]["biochar"]["heat_output"] + ): logger.info("Adding biochar heat output to urban central heat system.") biochar_heat_buses = [] for node in spatial.nodes: