From 9d2d3a4d3b2864a309351c49c3ba91f959ffa861 Mon Sep 17 00:00:00 2001 From: Koen van Greevenbroek Date: Fri, 3 Jul 2026 10:52:30 -0700 Subject: [PATCH 1/3] perf: improve LP numerical conditioning (kt gas buses + config-driven clipping) Remove Gurobi's "large matrix coefficient range" warning on the food-system model by tightening two sources of extreme coefficients: - Denominate the CH4 and N2O emission buses in kilotonnes instead of tonnes, so their flow coefficients (rice/enteric methane, fertilizer N2O) sit within a few orders of the MtCO2 bus. Updates the source-link writers, the GWP aggregation links, the analysis converters, and the carrier unit labels. - Add a `numerics` config block and a build-time clip pass (clip_negligible_coefficients) that zeroes physically-negligible coefficients: sub-hectare land areas (the source of ~1e-21 bounds/RHS), trace irrigation water, near-zero spared-land carbon fluxes, and rounding-level cost corrections. The former `land.filtering` thresholds are folded into `numerics` so all build-time conditioning knobs live together. Central A/B (barrier + crossover): matrix range [6e-08, 1e+05] -> [5e-06, 3e+04], warning gone, solver work units 24.9 -> 22.3 (~10%); objective unchanged to 1.5e-4. Emission totals are identical. Calibration provenance stamps are refreshed (artefacts are inert to the change: calibration solves run GHG-off and the pruning thresholds keep their values). --- CHANGELOG.md | 9 ++ config/default.yaml | 24 +++- config/schemas/config.schema.yaml | 62 +++++++--- .../calibration/default/provenance.yaml | 14 ++- .../calibration/gbd-anchored/provenance.yaml | 14 ++- .../calibration/gdd-ia/provenance.yaml | 14 ++- docs/model_framework.rst | 6 +- workflow/rules/model.smk | 1 + .../analysis/extract_ghg_attribution.py | 6 +- .../scripts/analysis/extract_net_emissions.py | 16 ++- workflow/scripts/build_model.py | 15 ++- workflow/scripts/build_model/animals.py | 10 +- workflow/scripts/build_model/crops.py | 14 +-- .../scripts/build_model/infrastructure.py | 4 +- .../scripts/build_model/primary_resources.py | 8 +- workflow/scripts/build_model/utils.py | 109 ++++++++++++++++++ workflow/scripts/constants.py | 7 ++ 17 files changed, 263 insertions(+), 70 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 223e94be..6cf2ca65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,6 +50,15 @@ introduce breaking changes to configuration and outputs. ### Changed +- Improved the optimisation model's **numerical conditioning** to remove + Gurobi's "large matrix coefficient range" warning. The CH₄ and N₂O emission + buses are now denominated in kilotonnes (previously tonnes) so their flow + coefficients sit within a few orders of the CO₂ bus, and a new `numerics` + config block clips physically-negligible coefficients at build time + (sub-hectare areas, trace irrigation/carbon fluxes, rounding-level cost + corrections). The former `land.filtering` thresholds now live under + `numerics`. Emission totals and the objective are unchanged (to within + solver tolerance); only reported CH₄/N₂O bus flows change units. - The baseline diet is now derived from **FAOSTAT Food Balance Sheets** by default (`diet.source: fbs`), computed from per-country food supply energy at model-basis densities and corrected for consumer waste. The GDD-IA diff --git a/config/default.yaml b/config/default.yaml index 85bdc055..3af6bbce 100644 --- a/config/default.yaml +++ b/config/default.yaml @@ -76,6 +76,26 @@ netcdf: zlib: true complevel: 4 +# --- section: numerics --- +# Build-time numerical conditioning. A few upstream datasets carry values +# many orders of magnitude below anything that affects results (sub-hectare +# disaggregated areas, trace yields/irrigation/carbon fluxes, rounding-level +# cost corrections); leaving them in widens the solver's coefficient/bounds/RHS +# ranges to ~20 orders and triggers numerical-scaling warnings. Every threshold +# is deliberately conservative -- far below any value that changes the solution. +# Set any to 0 to disable that clip. +numerics: + # Structural pruning: drop links/rows whose defining input is implausibly + # small (these components would be inert or agronomically non-viable). + min_land_area_ha: 100 # drop resource classes below this area + min_crop_yield_t_per_ha: 0.01 # drop crop links below this yield (~1% of entries) + min_grassland_yield_t_per_ha: 0.05 # drop grassland links below this yield (~6% of entries) + # Coefficient clipping: zero negligible coefficients on retained components. + min_link_area_mha: 0.000001 # ~1 ha; smaller land areas -> 0 + min_water_requirement_m3_per_ha: 0.1 # <0.1 mm/ha irrigation -> no water draw + min_co2_coefficient_tco2_per_ha: 0.001 # near-zero land carbon flux -> 0 + min_cost_correction_bnusd: 0.000001 # ~0.001 USD per unit flow -> 0 + # --- section: validation --- validation: use_actual_yields: true @@ -227,10 +247,6 @@ land: conversion_cost_nonforest_usd_per_ha: 2000 # Overnight investment cost for converting non-forested land to agriculture (2024 USD/ha); sources in docs/land_use.rst investment_horizon: 30 # Years over which to annualize land conversion investment costs (harmonized with luc.horizon_years and the ~30-year Cook-Patton regrowth window) discount_rate: 0.05 # Annual discount rate for annualizing land conversion investment costs - filtering: - min_crop_yield_t_per_ha: 0.01 # Minimum yield for crop links (t/ha); filters ~1% of entries - min_grassland_yield_t_per_ha: 0.05 # Minimum yield for grassland links (t/ha); filters ~6% of entries - min_area_ha: 100 # Minimum land area (ha); filters very small resource classes # --- section: water --- water: diff --git a/config/schemas/config.schema.yaml b/config/schemas/config.schema.yaml index d2856ab6..c203bb3b 100644 --- a/config/schemas/config.schema.yaml +++ b/config/schemas/config.schema.yaml @@ -16,6 +16,7 @@ required: - paths - calibration - netcdf + - numerics - validation - consumer_values - food_incentives @@ -160,6 +161,48 @@ properties: - type: "null" description: "Compression settings passed to xarray.Dataset.to_netcdf; null disables compression" + numerics: + type: object + description: "Build-time numerical-conditioning thresholds (structural pruning + coefficient clipping)" + required: + - min_land_area_ha + - min_crop_yield_t_per_ha + - min_grassland_yield_t_per_ha + - min_link_area_mha + - min_water_requirement_m3_per_ha + - min_co2_coefficient_tco2_per_ha + - min_cost_correction_bnusd + additionalProperties: false + properties: + min_land_area_ha: + type: number + minimum: 0 + description: "Resource classes below this land area (ha) are dropped" + min_crop_yield_t_per_ha: + type: number + minimum: 0 + description: "Crop production links below this yield (t/ha) are dropped" + min_grassland_yield_t_per_ha: + type: number + minimum: 0 + description: "Grassland feed links below this yield (t/ha) are dropped" + min_link_area_mha: + type: number + minimum: 0 + description: "Land areas (p_nom_max, baseline) below this many Mha are zeroed" + min_water_requirement_m3_per_ha: + type: number + minimum: 0 + description: "Irrigation water coefficients below this many m3/ha are zeroed" + min_co2_coefficient_tco2_per_ha: + type: number + minimum: 0 + description: "Land carbon-flux coefficients below this many tCO2/ha are zeroed" + min_cost_correction_bnusd: + type: number + minimum: 0 + description: "Cost-calibration corrections below this many bnUSD per unit flow are zeroed" + validation: type: object required: @@ -306,7 +349,7 @@ properties: land: type: object - required: [regional_limit, reforestation_cap, land_use_cost_usd_per_ha, conversion_cost_forest_usd_per_ha, conversion_cost_nonforest_usd_per_ha, investment_horizon, discount_rate, filtering] + required: [regional_limit, reforestation_cap, land_use_cost_usd_per_ha, conversion_cost_forest_usd_per_ha, conversion_cost_nonforest_usd_per_ha, investment_horizon, discount_rate] additionalProperties: false properties: regional_limit: @@ -361,23 +404,6 @@ properties: minimum: 0 maximum: 1 description: "Annual discount rate for annualizing land conversion investment costs" - filtering: - type: object - required: [min_crop_yield_t_per_ha, min_grassland_yield_t_per_ha, min_area_ha] - additionalProperties: false - properties: - min_crop_yield_t_per_ha: - type: number - minimum: 0 - description: "Minimum yield threshold for crop production links (t/ha)" - min_grassland_yield_t_per_ha: - type: number - minimum: 0 - description: "Minimum yield threshold for grassland feed links (t/ha)" - min_area_ha: - type: number - minimum: 0 - description: "Minimum land area threshold (ha)" water: type: object diff --git a/data/curated/calibration/default/provenance.yaml b/data/curated/calibration/default/provenance.yaml index a83af675..f633a6d3 100644 --- a/data/curated/calibration/default/provenance.yaml +++ b/data/curated/calibration/default/provenance.yaml @@ -4,8 +4,8 @@ # tools/calibrate; do not edit by hand. # Licensing: see the annotation in REUSE.toml. base_config: config/default.yaml -generated_at: '2026-07-02T21:35:52+00:00' -git_commit: 08a68eccbce283e7a4b01940e002fd266f000cef +generated_at: '2026-07-03T17:40:28+00:00' +git_commit: 22281c512133c39d1fd438f77f076a0b931c3d33 source: default structural_config: aggregation.irrigated_area_source: current @@ -801,9 +801,6 @@ structural_config: land.conversion_cost_forest_usd_per_ha: 8000 land.conversion_cost_nonforest_usd_per_ha: 2000 land.discount_rate: 0.05 - land.filtering.min_area_ha: 100 - land.filtering.min_crop_yield_t_per_ha: 0.01 - land.filtering.min_grassland_yield_t_per_ha: 0.05 land.investment_horizon: 30 land.land_use_cost_usd_per_ha: 0.0 luc.cropland_source: gaez @@ -833,6 +830,13 @@ structural_config: - alfalfa - silage-maize - biomass-sorghum + numerics.min_co2_coefficient_tco2_per_ha: 0.001 + numerics.min_cost_correction_bnusd: 1.0e-06 + numerics.min_crop_yield_t_per_ha: 0.01 + numerics.min_grassland_yield_t_per_ha: 0.05 + numerics.min_land_area_ha: 100 + numerics.min_link_area_mha: 1.0e-06 + numerics.min_water_requirement_m3_per_ha: 0.1 optimal_taxes.enabled: false residues.max_feed_fraction: 0.3 residues.max_feed_fraction_by_region.Asia: 0.7 diff --git a/data/curated/calibration/gbd-anchored/provenance.yaml b/data/curated/calibration/gbd-anchored/provenance.yaml index 9d9f7ace..6326ace6 100644 --- a/data/curated/calibration/gbd-anchored/provenance.yaml +++ b/data/curated/calibration/gbd-anchored/provenance.yaml @@ -4,8 +4,8 @@ # tools/calibrate; do not edit by hand. # Licensing: see the annotation in REUSE.toml. base_config: config/gsa.yaml -generated_at: '2026-07-02T22:28:37+00:00' -git_commit: 08a68eccbce283e7a4b01940e002fd266f000cef +generated_at: '2026-07-03T17:40:31+00:00' +git_commit: 22281c512133c39d1fd438f77f076a0b931c3d33 source: gbd-anchored structural_config: aggregation.irrigated_area_source: current @@ -799,9 +799,6 @@ structural_config: land.conversion_cost_forest_usd_per_ha: 8000 land.conversion_cost_nonforest_usd_per_ha: 2000 land.discount_rate: 0.05 - land.filtering.min_area_ha: 100 - land.filtering.min_crop_yield_t_per_ha: 0.01 - land.filtering.min_grassland_yield_t_per_ha: 0.05 land.investment_horizon: 30 land.land_use_cost_usd_per_ha: 0.0 luc.cropland_source: gaez @@ -831,6 +828,13 @@ structural_config: - alfalfa - silage-maize - biomass-sorghum + numerics.min_co2_coefficient_tco2_per_ha: 0.001 + numerics.min_cost_correction_bnusd: 1.0e-06 + numerics.min_crop_yield_t_per_ha: 0.01 + numerics.min_grassland_yield_t_per_ha: 0.05 + numerics.min_land_area_ha: 100 + numerics.min_link_area_mha: 1.0e-06 + numerics.min_water_requirement_m3_per_ha: 0.1 optimal_taxes.enabled: false residues.max_feed_fraction: 0.3 residues.max_feed_fraction_by_region.Asia: 0.7 diff --git a/data/curated/calibration/gdd-ia/provenance.yaml b/data/curated/calibration/gdd-ia/provenance.yaml index 787abf73..f91c8858 100644 --- a/data/curated/calibration/gdd-ia/provenance.yaml +++ b/data/curated/calibration/gdd-ia/provenance.yaml @@ -4,8 +4,8 @@ # tools/calibrate; do not edit by hand. # Licensing: see the annotation in REUSE.toml. base_config: config/central.yaml -generated_at: '2026-07-02T21:56:54+00:00' -git_commit: 08a68eccbce283e7a4b01940e002fd266f000cef +generated_at: '2026-07-03T17:40:30+00:00' +git_commit: 22281c512133c39d1fd438f77f076a0b931c3d33 source: gdd-ia structural_config: aggregation.irrigated_area_source: current @@ -799,9 +799,6 @@ structural_config: land.conversion_cost_forest_usd_per_ha: 8000 land.conversion_cost_nonforest_usd_per_ha: 2000 land.discount_rate: 0.05 - land.filtering.min_area_ha: 100 - land.filtering.min_crop_yield_t_per_ha: 0.01 - land.filtering.min_grassland_yield_t_per_ha: 0.05 land.investment_horizon: 30 land.land_use_cost_usd_per_ha: 0.0 luc.cropland_source: gaez @@ -831,6 +828,13 @@ structural_config: - alfalfa - silage-maize - biomass-sorghum + numerics.min_co2_coefficient_tco2_per_ha: 0.001 + numerics.min_cost_correction_bnusd: 1.0e-06 + numerics.min_crop_yield_t_per_ha: 0.01 + numerics.min_grassland_yield_t_per_ha: 0.05 + numerics.min_land_area_ha: 100 + numerics.min_link_area_mha: 1.0e-06 + numerics.min_water_requirement_m3_per_ha: 0.1 optimal_taxes.enabled: false residues.max_feed_fraction: 0.3 residues.max_feed_fraction_by_region.Asia: 0.7 diff --git a/docs/model_framework.rst b/docs/model_framework.rst index fabb356a..aff7f830 100644 --- a/docs/model_framework.rst +++ b/docs/model_framework.rst @@ -177,9 +177,9 @@ The model uses consistent units throughout: * Nutritional energy (calories): kcal/person/day → PJ/year **Emissions** - * Methane (CH₄) and nitrous oxide (N₂O): tonnes of gas, converted downstream to Mt CO₂-eq - * Aggregate greenhouse gases: tCO₂-eq (tonnes CO₂-equivalent) - * Conversion factors: CH₄ (28 GWP100), N₂O (265 GWP100) + * Methane (CH₄) and nitrous oxide (N₂O): kilotonnes of gas (kept close in magnitude to the CO₂ bus for numerical conditioning), converted downstream to Mt CO₂-eq + * Carbon dioxide (CO₂) and aggregate greenhouse gases: Mt CO₂-eq + * Conversion factors: CH₄ (27 GWP100), N₂O (273 GWP100) **Water** * Water use: Mm³ (million cubic meters) diff --git a/workflow/rules/model.smk b/workflow/rules/model.smk index 1927216b..d058b7be 100644 --- a/workflow/rules/model.smk +++ b/workflow/rules/model.smk @@ -217,6 +217,7 @@ rule build_model: baseline_year=config["baseline_year"], validation=config["validation"], deviation_penalty=config["deviation_penalty"], + numerics=config["numerics"], netcdf=config["netcdf"], # Add health-cluster stores when health is enabled in the base config or # any scenario (the build is shared across scenarios). diff --git a/workflow/scripts/analysis/extract_ghg_attribution.py b/workflow/scripts/analysis/extract_ghg_attribution.py index 8415c088..a03e2315 100644 --- a/workflow/scripts/analysis/extract_ghg_attribution.py +++ b/workflow/scripts/analysis/extract_ghg_attribution.py @@ -44,7 +44,7 @@ import pandas as pd import pypsa -from workflow.scripts.constants import TONNE_TO_MEGATONNE +from workflow.scripts.constants import KILOTONNE_TO_MEGATONNE _BUS_COL_PATTERN = re.compile(r"^bus(\d+)$") @@ -136,8 +136,8 @@ def build_ghg_links_dataframe( gwp = pd.Series( { "emission:co2": 1.0, - "emission:ch4": ch4_gwp * TONNE_TO_MEGATONNE, - "emission:n2o": n2o_gwp * TONNE_TO_MEGATONNE, + "emission:ch4": ch4_gwp * KILOTONNE_TO_MEGATONNE, + "emission:n2o": n2o_gwp * KILOTONNE_TO_MEGATONNE, } ) diff --git a/workflow/scripts/analysis/extract_net_emissions.py b/workflow/scripts/analysis/extract_net_emissions.py index 6a477fa1..6bcf6bec 100644 --- a/workflow/scripts/analysis/extract_net_emissions.py +++ b/workflow/scripts/analysis/extract_net_emissions.py @@ -18,6 +18,8 @@ import pandas as pd import pypsa +from workflow.scripts.constants import KILOTONNE_TO_MEGATONNE + logger = logging.getLogger(__name__) @@ -144,8 +146,10 @@ def extract_net_emissions( gwp_factor = gwp_factors[bus_carrier] - # CH4 and N2O flows are in tonnes; convert to Mt before applying GWP - value_mt = value * 1e-6 if bus_carrier in ("ch4", "n2o") else value + # CH4 and N2O flows are in kilotonnes; convert to Mt before applying GWP + value_mt = ( + value * KILOTONNE_TO_MEGATONNE if bus_carrier in ("ch4", "n2o") else value + ) emission_co2eq = value_mt * gwp_factor category = categorize_emission_carrier(carrier, bus_carrier) @@ -161,10 +165,10 @@ def extract_net_emissions( p4 = n.links.dynamic["p4"].loc[:, produce_mask] weights = n.snapshot_weightings["objective"] - pasture_t_n2o = -( + pasture_kt_n2o = -( p4.multiply(pasture_share, axis=1).multiply(weights, axis=0).sum().sum() ) - pasture_mtco2eq = pasture_t_n2o * n2o_gwp * 1e-6 + pasture_mtco2eq = pasture_kt_n2o * n2o_gwp * KILOTONNE_TO_MEGATONNE total_mtco2eq = emissions["n2o"].get("Manure management & application", 0.0) raw_managed = total_mtco2eq - pasture_mtco2eq @@ -193,10 +197,10 @@ def extract_net_emissions( p2 = n.links.dynamic["p2"].loc[:, produce_mask] weights = n.snapshot_weightings["objective"] - manure_t_ch4 = -( + manure_kt_ch4 = -( p2.multiply(manure_share, axis=1).multiply(weights, axis=0).sum().sum() ) - manure_mtco2eq = manure_t_ch4 * ch4_gwp * 1e-6 + manure_mtco2eq = manure_kt_ch4 * ch4_gwp * KILOTONNE_TO_MEGATONNE total_mtco2eq = emissions["ch4"].get( "Enteric fermentation & Manure management", 0.0 diff --git a/workflow/scripts/build_model.py b/workflow/scripts/build_model.py index 6cb5a24f..e1c729c1 100644 --- a/workflow/scripts/build_model.py +++ b/workflow/scripts/build_model.py @@ -185,7 +185,7 @@ def filter(self, record: logging.LogRecord) -> bool: residue_lookup = {} residue_fue_lookup = {} - # Build per-residue soil-N2O coefficient (t N2O / Mt DM) once here so + # Build per-residue soil-N2O coefficient (kt N2O / Mt DM) once here so # both crop_production (mandatory (1-FUE) N2O on bus6) and # residue_incorporation (LP-controlled N2O on the net residue bus) # use the same numbers. Requires the IPCC residue-decomposition @@ -865,10 +865,10 @@ def _apply_yield_corrections(corr_df: pd.DataFrame, source_label: str) -> None: * USD_TO_BNUSD ) - filtering_cfg = land_cfg["filtering"] - min_crop_yield = float(filtering_cfg["min_crop_yield_t_per_ha"]) - min_grassland_yield = float(filtering_cfg["min_grassland_yield_t_per_ha"]) - min_area_ha = float(filtering_cfg["min_area_ha"]) + numerics_cfg = snakemake.params.numerics + min_crop_yield = float(numerics_cfg["min_crop_yield_t_per_ha"]) + min_grassland_yield = float(numerics_cfg["min_grassland_yield_t_per_ha"]) + min_area_ha = float(numerics_cfg["min_land_area_ha"]) land.add_land_components( n, land_class_df, @@ -1218,6 +1218,11 @@ def _apply_yield_corrections(corr_df: pd.DataFrame, source_label: str) -> None: # Store build-time regional_limit in metadata so solve_model can rescale n.meta["land_regional_limit"] = float(land_cfg["regional_limit"]) + # Zero out physically-negligible coefficients (sub-hectare areas, trace + # water/carbon fluxes, rounding-level cost corrections) so the solver's + # coefficient/bounds/RHS ranges stay well conditioned. + utils.clip_negligible_coefficients(n, snakemake.params.numerics, logger) + # ═══════════════════════════════════════════════════════════════ # EXPORT # ═══════════════════════════════════════════════════════════════ diff --git a/workflow/scripts/build_model/animals.py b/workflow/scripts/build_model/animals.py index b8e5076b..3d4ec6aa 100644 --- a/workflow/scripts/build_model/animals.py +++ b/workflow/scripts/build_model/animals.py @@ -584,11 +584,15 @@ def add_feed_to_animal_product_links( ) link_df = df.set_index(names, drop=False).copy() link_df["bus3"] = "fertilizer:" + link_df["country"] - # Convert per-tonne emissions to per-Mt flows (CH4, N2O in t; feed in Mt) + # Convert per-tonne emissions to per-Mt flows (CH4, N2O buses in kt; feed in Mt) # Manure N needs no conversion: t N / t feed = Mt N / Mt feed (ratio is scale-invariant) - link_df["efficiency2"] = link_df["ch4_per_t_feed"] * constants.MEGATONNE_TO_TONNE + link_df["efficiency2"] = ( + link_df["ch4_per_t_feed"] * constants.MEGATONNE_TO_KILOTONNE + ) link_df["efficiency3"] = link_df["n_fert_per_t_feed"] - link_df["efficiency4"] = link_df["n2o_per_t_feed"] * constants.MEGATONNE_TO_TONNE + link_df["efficiency4"] = ( + link_df["n2o_per_t_feed"] * constants.MEGATONNE_TO_KILOTONNE + ) # Animal-production co-products (e.g. rendered-fat tallow/lard). # Each co-product is attached as an additional output bus on the diff --git a/workflow/scripts/build_model/crops.py b/workflow/scripts/build_model/crops.py index 5d6e58ee..16fd27a3 100644 --- a/workflow/scripts/build_model/crops.py +++ b/workflow/scripts/build_model/crops.py @@ -32,7 +32,7 @@ def compute_residue_n2o_efficiency_per_dm( indirect_ef5: float, frac_leach: float, ) -> dict[str, float]: - """Per-residue-feed_item soil-incorporation N2O efficiency (t N2O / Mt DM). + """Per-residue-feed_item soil-incorporation N2O efficiency (kt N2O / Mt DM). Combines direct (IPCC eq. 11.1) and indirect-leaching (eq. 11.10) pathways. Used for both the optional ``residue_incorporation`` link @@ -77,9 +77,9 @@ def compute_residue_n2o_efficiency_per_dm( # Total N2O-N per kg residue-N: direct decomposition + leaching/runoff. total_n2o_n = incorporation_n2o_factor + frac_leach * indirect_ef5 - # t N2O per Mt residue DM: - # = (kg N / kg DM) * (kg N2O-N / kg N) * (44/28 N2O/N2O-N) * (1e6 t/Mt) - coeff = total_n2o_n * (44.0 / 28.0) * constants.MEGATONNE_TO_TONNE + # kt N2O per Mt residue DM: + # = (kg N / kg DM) * (kg N2O-N / kg N) * (44/28 N2O/N2O-N) * (1e3 kt/Mt) + coeff = total_n2o_n * (44.0 / 28.0) * constants.MEGATONNE_TO_KILOTONNE return { item: float(n_content_lookup[item]) / 1000.0 * coeff for item in residue_feed_items @@ -311,9 +311,9 @@ def add_regional_crop_production_links( ch4_bus = np.full(len(df), "emission:ch4", dtype=object) ch4_eff = np.full( len(df), - rice_methane_factor - * scaling_factor - * 1e3, # kg CH4/ha -> t CH4/Mha + # kg CH4/ha == kt CH4/Mha (both scale by 1e6), so the + # emission factor carries straight onto the kt CH4 bus. + rice_methane_factor * scaling_factor, dtype=float, ) else: diff --git a/workflow/scripts/build_model/infrastructure.py b/workflow/scripts/build_model/infrastructure.py index da64a06e..1e996303 100644 --- a/workflow/scripts/build_model/infrastructure.py +++ b/workflow/scripts/build_model/infrastructure.py @@ -198,8 +198,8 @@ def add_carriers_and_buses( for carrier, unit in [ ("fertilizer", "Mt"), ("co2", "MtCO2"), - ("ch4", "tCH4"), - ("n2o", "tN2O"), + ("ch4", "ktCH4"), + ("n2o", "ktN2O"), ("ghg", "MtCO2e"), ]: n.carriers.add(carrier, unit=unit) diff --git a/workflow/scripts/build_model/primary_resources.py b/workflow/scripts/build_model/primary_resources.py index a0a18db1..fa90d4f0 100644 --- a/workflow/scripts/build_model/primary_resources.py +++ b/workflow/scripts/build_model/primary_resources.py @@ -129,7 +129,7 @@ def add_primary_resources( bus0="emission:ch4", bus1="emission:ghg", carrier="emission_aggregation", - efficiency=ch4_to_co2_factor * constants.TONNE_TO_MEGATONNE, + efficiency=ch4_to_co2_factor * constants.KILOTONNE_TO_MEGATONNE, p_nom_extendable=True, ) n.links.add( @@ -137,7 +137,7 @@ def add_primary_resources( bus0="emission:n2o", bus1="emission:ghg", carrier="emission_aggregation", - efficiency=n2o_to_co2_factor * constants.TONNE_TO_MEGATONNE, + efficiency=n2o_to_co2_factor * constants.KILOTONNE_TO_MEGATONNE, p_nom_extendable=True, ) @@ -195,9 +195,9 @@ def add_fertilizer_distribution_links( emission_mt_per_mt = total_n2o_n * constants.N2O_N_TO_N2O if emission_mt_per_mt > 0.0: - emission_t_per_mt = emission_mt_per_mt * constants.MEGATONNE_TO_TONNE + emission_kt_per_mt = emission_mt_per_mt * constants.MEGATONNE_TO_KILOTONNE params["bus2"] = "emission:n2o" - params["efficiency2"] = emission_t_per_mt + params["efficiency2"] = emission_kt_per_mt n.links.add(link_df.index, **params) diff --git a/workflow/scripts/build_model/utils.py b/workflow/scripts/build_model/utils.py index ec859357..173af895 100644 --- a/workflow/scripts/build_model/utils.py +++ b/workflow/scripts/build_model/utils.py @@ -12,12 +12,121 @@ import numpy as np import pandas as pd +import pypsa from .. import constants logger = logging.getLogger(__name__) +def _clip_below( + df: pd.DataFrame, col: str, floor: float, mask: pd.Series | None = None +) -> int: + """Zero entries of ``df[col]`` whose magnitude is below ``floor``. + + Restricted to rows selected by ``mask`` when given. NaN entries are left + untouched. Returns the number of entries zeroed. + """ + values = df[col] + small = (values.abs() < floor) & (values != 0) + if mask is not None: + small &= mask + small = small.fillna(False) + df.loc[small, col] = 0.0 + return int(small.sum()) + + +def _clip_port_coefficients( + links: pd.DataFrame, bus_carrier: pd.Series, target_carrier: str, floor: float +) -> int: + """Zero multi-bus-link efficiencies feeding ``target_carrier`` buses. + + Scans every output port (``bus1``/``efficiency`` .. ``busN``/``efficiencyN``) + and zeroes the efficiency where the port's bus has carrier + ``target_carrier`` and the coefficient magnitude is below ``floor``. + Returns the number of coefficients zeroed. + """ + n_clipped = 0 + port_cols = [c for c in links.columns if c.startswith("bus") and c != "bus0"] + for bus_col in port_cols: + suffix = bus_col[len("bus") :] + eff_col = "efficiency" if suffix == "1" else f"efficiency{suffix}" + if eff_col not in links.columns: + continue + port_carrier = links[bus_col].map(bus_carrier).fillna("") + eff = links[eff_col] + small = (port_carrier == target_carrier) & (eff.abs() < floor) & (eff != 0) + small = small.fillna(False) + links.loc[small, eff_col] = 0.0 + n_clipped += int(small.sum()) + return n_clipped + + +def clip_negligible_coefficients( + n: pypsa.Network, + numerics_cfg: dict, + logger: logging.Logger = logger, +) -> None: + """Zero physically-negligible coefficients to keep the LP well conditioned. + + A few upstream datasets carry values orders of magnitude below anything + that affects results -- sub-hectare disaggregated crop areas, trace + irrigation requirements, near-zero carbon fluxes on arid spared land, + and rounding-level cost-calibration corrections. Left in place they widen + the solver's coefficient/bounds/RHS ranges to ~20 orders of magnitude and + trigger numerical-scaling warnings. Every threshold in ``config['numerics']`` + sits far below any value that influences the solution, so clipping them to + zero only removes noise. + + Operates in place, filtering by carrier and bus-carrier columns. + """ + area_floor = float(numerics_cfg["min_link_area_mha"]) + water_floor = float(numerics_cfg["min_water_requirement_m3_per_ha"]) + co2_floor = float(numerics_cfg["min_co2_coefficient_tco2_per_ha"]) + cost_floor = float(numerics_cfg["min_cost_correction_bnusd"]) + + links = n.links.static + gens = n.generators.static + bus_carrier = n.buses.static["carrier"].astype(str) + land_buses = set(bus_carrier.index[bus_carrier.str.startswith("land")]) + + # 1. Land areas below ~1 ha: the sub-Mha disaggregation noise that drives + # the tiny end of the bounds/RHS range. + n_area = _clip_below( + links, "p_nom_max", area_floor, mask=links["bus0"].isin(land_buses) + ) + if "baseline_area_mha" in links.columns: + n_area += _clip_below(links, "baseline_area_mha", area_floor) + n_area += _clip_below( + gens, "p_nom_max", area_floor, mask=gens["bus"].isin(land_buses) + ) + + # 2. Trace irrigation water requirements and 3. near-zero carbon fluxes, + # both carried as link efficiencies onto the water / CO2 buses. + n_water = _clip_port_coefficients(links, bus_carrier, "water", water_floor) + n_co2 = _clip_port_coefficients(links, bus_carrier, "co2", co2_floor) + + # 4. Rounding-level cost-calibration corrections (bnUSD per Mha or Mt flow). + n_cost = 0 + for col in ( + "bounded_subsidy_bnusd_per_mha", + "bounded_penalty_bnusd_per_mha", + "bounded_subsidy_bnusd_per_mt", + "bounded_penalty_bnusd_per_mt", + ): + if col in links.columns: + n_cost += _clip_below(links, col, cost_floor) + + logger.info( + "Numerics clipping: zeroed %d land areas, %d water coefficients, " + "%d CO2 coefficients, %d cost corrections", + n_area, + n_water, + n_co2, + n_cost, + ) + + def _per_capita_mass_to_mt_per_year( value_per_person_per_day: float, population: float ) -> float: diff --git a/workflow/scripts/constants.py b/workflow/scripts/constants.py index ed16fde2..ae7c3806 100644 --- a/workflow/scripts/constants.py +++ b/workflow/scripts/constants.py @@ -13,6 +13,13 @@ TONNE_TO_MEGATONNE = 1e-6 # convert tonnes to megatonnes MEGATONNE_TO_TONNE = 1e6 # convert megatonnes to tonnes KG_TO_MEGATONNE = 1e-9 # convert kilograms to megatonnes +# Non-CO2 gas buses (emission:ch4, emission:n2o) are denominated in +# kilotonnes so their flow coefficients sit within a few orders of the +# CO2 bus (MtCO2), which keeps the optimisation matrix well conditioned. +# The GWP aggregation links and all analysis converters use these factors +# to translate the kt gas flows to/from MtCO2e. +KILOTONNE_TO_MEGATONNE = 1e-3 # convert kilotonnes to megatonnes +MEGATONNE_TO_KILOTONNE = 1e3 # convert megatonnes to kilotonnes GRAMS_PER_MEGATONNE = 1e12 # grams per megatonne of mass YLL_TO_MILLION_YLL = 1e-6 # convert years of life lost to million YLL PER_100K = 100_000 # epidemiological rate denominator (per 100,000 population) From 54d4b80b030299692ee6447bb2a23ae2718ba51d Mon Sep 17 00:00:00 2001 From: Koen van Greevenbroek Date: Fri, 3 Jul 2026 15:16:54 -0700 Subject: [PATCH 2/3] perf: reformulate L1 deviation penalties as equality splits The L1 stability penalties (crop, grassland, animal feed, diet) encoded |deviation| with an auxiliary abs_dev variable and two inequality rows per link (abs_dev >= +dev, abs_dev >= -dev). Gurobi's presolve cannot fold that pair, so on a full-resolution health solve the stability machinery accounted for 150k of 368k presolved rows. Encode instead dev_pos - dev_neg == deviation with dev_pos, dev_neg >= 0 and price their sum: one equality row per link, same optimum for any positive L1 cost. The land-conversion penalty (zero baseline, non-negative flows, |p - 0| = p) is priced directly on the link flows with no auxiliary variables at all. On the central health-on config this cuts presolved rows 368k -> 212k and total solver work 41 -> 28 work units (-33%); objectives agree to within the 0.1% MIP gap and calibrated L1 costs remain valid. The objective-breakdown bookkeeping in core.py reads dev_pos + dev_neg (and link flows for land conversion) in place of abs_dev. --- CHANGELOG.md | 9 +++ workflow/scripts/solve_model/core.py | 38 +++++++--- .../scripts/solve_model/diet_stability.py | 18 ++--- .../solve_model/production_stability.py | 72 +++++++++---------- 4 files changed, 79 insertions(+), 58 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6cf2ca65..95666a76 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,6 +50,15 @@ introduce breaking changes to configuration and outputs. ### Changed +- Reformulated the **L1 deviation penalties** (production, animal-feed, diet + stability) from an absolute-value auxiliary variable with two inequality + rows per link to an equivalent equality split into non-negative + positive/negative deviation parts, and priced the zero-baseline + land-conversion penalty directly on link flows. Together with a faster + nodal-balance construction in the vendored PyPSA fork, this cuts + full-resolution solve times by roughly a third (about 40% fewer + constraint rows after presolve) with identical optima up to solver + tolerance. - Improved the optimisation model's **numerical conditioning** to remove Gurobi's "large matrix coefficient range" warning. The CH₄ and N₂O emission buses are now denominated in kilotonnes (previously tonnes) so their flow diff --git a/workflow/scripts/solve_model/core.py b/workflow/scripts/solve_model/core.py index 281d4ad0..c2e175a6 100644 --- a/workflow/scripts/solve_model/core.py +++ b/workflow/scripts/solve_model/core.py @@ -33,6 +33,7 @@ evaluate_health_posthoc, ) from workflow.scripts.solve_model.production_stability import ( + LAND_CONVERSION_CARRIERS, add_animal_growth_cap_constraints, add_bounded_subsidy_constraints, add_crop_growth_cap_constraints, @@ -1804,10 +1805,13 @@ def run_solve( # # The L1 coefficient mirrors what _add_animal_l1_penalty applies in # production_stability.py: when feed.l1_cost is set, the penalty - # uses that value directly (animal_scale=1.0) and abs_dev is in - # native Mt DM; when null, the penalty uses the cropland l1_cost on - # Mha-equivalent units. In both cases the per-component coefficient - # times sum(abs_dev.solution) reproduces the actual objective term. + # uses that value directly (animal_scale=1.0) and the deviation is + # in native Mt DM; when null, the penalty uses the cropland l1_cost + # on Mha-equivalent units. Each L1 term splits the deviation into + # dev_pos/dev_neg parts whose solution sum equals |deviation|, so + # the per-component coefficient times that sum reproduces the + # actual objective term. The land-conversion L1 is priced directly + # on the (non-negative, zero-baseline) link flows. if dp_cfg["enabled"]: penalty_mode = dp_cfg.get("penalty_mode") stability_cost = 0.0 @@ -1818,15 +1822,27 @@ def run_solve( animal_l1 = ( float(feed_l1_override) if feed_l1_override is not None else crop_l1 ) - for var_name, cost in [ - ("crop_stability_abs_dev", crop_l1), - ("grassland_stability_abs_dev", grassland_l1), - ("animal_stability_abs_dev", animal_l1), - ("land_conversion_stability_abs_dev", crop_l1), + for var_stem, cost in [ + ("crop_stability_dev", crop_l1), + ("grassland_stability_dev", grassland_l1), + ("animal_stability_dev", animal_l1), ]: - if var_name in n.model.variables: - sol = n.model.variables[var_name].solution + if f"{var_stem}_pos" in n.model.variables: + sol = ( + n.model.variables[f"{var_stem}_pos"].solution + + n.model.variables[f"{var_stem}_neg"].solution + ) stability_cost += cost * float(sol.sum()) + land_cfg = dp_cfg["land"] + if land_cfg["enabled"] and land_cfg["land_conversion"]["enabled"]: + conv_links = n.links.static.index[ + n.links.static["carrier"].isin(LAND_CONVERSION_CARRIERS) + ] + if not conv_links.empty: + conv_flow = n.model.variables["Link-p"].solution.sel( + name=conv_links.tolist() + ) + stability_cost += crop_l1 * float(conv_flow.sum()) quad_var_names = [ "crop_stability_dev", "grassland_stability_dev", diff --git a/workflow/scripts/solve_model/diet_stability.py b/workflow/scripts/solve_model/diet_stability.py index 4737c37b..8a16b7c1 100644 --- a/workflow/scripts/solve_model/diet_stability.py +++ b/workflow/scripts/solve_model/diet_stability.py @@ -108,21 +108,23 @@ def add_diet_stability_constraints( if penalty_mode == "l1": cost = float(diet_cfg["l1_cost"]) - abs_dev = n.model.add_variables( + dev_pos = n.model.add_variables( lower=0, coords=[consume_links.index], dims=["name"], - name="diet_stability_abs_dev", + name="diet_stability_dev_pos", ) - n.model.add_constraints( - abs_dev >= deviation, - name="GlobalConstraint-diet_stability_pos", + dev_neg = n.model.add_variables( + lower=0, + coords=[consume_links.index], + dims=["name"], + name="diet_stability_dev_neg", ) n.model.add_constraints( - abs_dev >= -deviation, - name="GlobalConstraint-diet_stability_neg", + dev_pos - dev_neg == deviation, + name="GlobalConstraint-diet_stability_dev_split", ) - n.model.objective += cost * abs_dev.sum() + n.model.objective += cost * (dev_pos.sum() + dev_neg.sum()) logger.info( "Added %d per-(food, country) diet L1 penalties " "(cost=%.4f bn USD/Mt, mode=%s)", diff --git a/workflow/scripts/solve_model/production_stability.py b/workflow/scripts/solve_model/production_stability.py index 45116d45..6f5bb2e8 100644 --- a/workflow/scripts/solve_model/production_stability.py +++ b/workflow/scripts/solve_model/production_stability.py @@ -618,10 +618,13 @@ def _add_production_l1_penalty( ) -> None: """Add L1 (absolute-value) penalty on area deviations. - Creates a linopy variable ``abs_dev >= 0`` per constrained link and adds: - abs_dev >= +(area - baseline) - abs_dev >= -(area - baseline) - objective += l1_cost * sum(abs_dev) + Splits the deviation into non-negative parts per constrained link: + dev_pos - dev_neg == (area - baseline), dev_pos, dev_neg >= 0 + objective += l1_cost * sum(dev_pos + dev_neg) + With positive ``l1_cost``, ``dev_pos + dev_neg == |area - baseline|`` + at any optimum. The single equality row per link presolves to a much + smaller LP than an equivalent pair of ``abs_dev >= +/-deviation`` + inequalities (~40% fewer presolved rows on the full model). """ result = _production_and_baselines( link_p, links_df, carrier, min_baseline, include_all_links=True @@ -636,22 +639,24 @@ def _add_production_l1_penalty( area, baselines, deviation_type, min_baseline ) - abs_dev = m.add_variables( + dev_pos = m.add_variables( lower=0, coords=[link_names], dims=["name"], - name=f"{label}_stability_abs_dev", + name=f"{label}_stability_dev_pos", ) - - m.add_constraints( - abs_dev >= deviation, - name=f"GlobalConstraint-{label}_stability_pos", + dev_neg = m.add_variables( + lower=0, + coords=[link_names], + dims=["name"], + name=f"{label}_stability_dev_neg", ) + m.add_constraints( - abs_dev >= -deviation, - name=f"GlobalConstraint-{label}_stability_neg", + dev_pos - dev_neg == deviation, + name=f"GlobalConstraint-{label}_stability_dev_split", ) - m.objective += l1_cost * abs_dev.sum() + m.objective += l1_cost * (dev_pos.sum() + dev_neg.sum()) logger.info( "Added %d per-link %s L1 stability penalties (cost=%.4f, mode=%s)", @@ -837,22 +842,24 @@ def _add_animal_l1_penalty( if animal_scale != 1.0: deviation = deviation * animal_scale - abs_dev = m.add_variables( + dev_pos = m.add_variables( lower=0, coords=[link_names], dims=["name"], - name="animal_stability_abs_dev", + name="animal_stability_dev_pos", ) - - m.add_constraints( - abs_dev >= deviation, - name="GlobalConstraint-animal_stability_pos", + dev_neg = m.add_variables( + lower=0, + coords=[link_names], + dims=["name"], + name="animal_stability_dev_neg", ) + m.add_constraints( - abs_dev >= -deviation, - name="GlobalConstraint-animal_stability_neg", + dev_pos - dev_neg == deviation, + name="GlobalConstraint-animal_stability_dev_split", ) - m.objective += l1_cost * abs_dev.sum() + m.objective += l1_cost * (dev_pos.sum() + dev_neg.sum()) logger.info( "Added %d per-link animal L1 stability penalties (cost=%.4f, mode=%s)", @@ -922,7 +929,9 @@ def _add_land_conversion_l1_penalty( """Add L1 penalty on land conversion link flows (zero baseline). Since baseline is zero and all flows are non-negative, the absolute - deviation equals the flow itself: ``|p - 0| = p``. + deviation equals the flow itself: ``|p - 0| = p``. The penalty is + therefore priced directly on the flow variables; no auxiliary + variables or constraints are needed. """ conv_links = links_df[links_df["carrier"].isin(LAND_CONVERSION_CARRIERS)] if conv_links.empty: @@ -933,22 +942,7 @@ def _add_land_conversion_l1_penalty( link_names = conv_links.index flow = link_p.sel(name=link_names) - abs_dev = m.add_variables( - lower=0, - coords=[link_names], - dims=["name"], - name="land_conversion_stability_abs_dev", - ) - - m.add_constraints( - abs_dev >= flow, - name="GlobalConstraint-land_conversion_stability_pos", - ) - m.add_constraints( - abs_dev >= -flow, - name="GlobalConstraint-land_conversion_stability_neg", - ) - m.objective += l1_cost * abs_dev.sum() + m.objective += l1_cost * flow.sum() logger.info( "Added %d per-link land conversion L1 stability penalties (cost=%.4f)", From 38e92754aa6ce78d2f4545722fd51ce800da944d Mon Sep 17 00:00:00 2001 From: Koen van Greevenbroek Date: Fri, 3 Jul 2026 15:17:04 -0700 Subject: [PATCH 3/3] perf: bump pypsa fork to v1.2.0+glade3 for faster model construction Picks up the balance-arg grouping fix in _iter_balance_args: grouping on a two-column delay/cyclic frame instead of a deep copy of the full wide static frame per output port. create_model on the full-resolution model drops from ~13.4s to ~8.7s; the constructed model is verified element-for-element identical. --- pixi.lock | 12 ++++++------ pixi.toml | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/pixi.lock b/pixi.lock index c42ce96b..d93f5ac4 100644 --- a/pixi.lock +++ b/pixi.lock @@ -378,7 +378,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/ab/4c/b888e6cf58bd9db9c93f40d1c6be8283ff49d88919231afe93a6bcf61626/pydeck-0.9.1-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d9/dd/e0aa7ebef5168c75b772eda64978c597a9129b46be17779054652a7999e4/pyOpenSSL-24.2.1-py3-none-any.whl - - pypi: git+https://github.com/koen-vg/PyPSA.git?tag=v1.2.0%2Bglade2#02181af0507140246a4aa581948246c4440a52cd + - pypi: git+https://github.com/koen-vg/PyPSA.git?tag=v1.2.0%2Bglade3#4661ba4de82c42c3810a81fac1f86fe319de7fa6 - pypi: https://files.pythonhosted.org/packages/29/b5/c1209e6cb77647bc2c9a6a1a953355720f34f3b006b725e303c70f3c0786/pyu2f-0.1.5.tar.gz - pypi: https://files.pythonhosted.org/packages/32/00/ec8597a64f2be301ce1ee3290d067f49f6a7afb226b67d5f15b56d772ba5/rapidfuzz-3.14.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/7b/18/22316545b712dbed0119c7d3b8683a566c7da26353e344a4188b99f12692/remotezip-0.12.3-py3-none-any.whl @@ -905,7 +905,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/ab/4c/b888e6cf58bd9db9c93f40d1c6be8283ff49d88919231afe93a6bcf61626/pydeck-0.9.1-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d9/dd/e0aa7ebef5168c75b772eda64978c597a9129b46be17779054652a7999e4/pyOpenSSL-24.2.1-py3-none-any.whl - - pypi: git+https://github.com/koen-vg/PyPSA.git?tag=v1.2.0%2Bglade2#02181af0507140246a4aa581948246c4440a52cd + - pypi: git+https://github.com/koen-vg/PyPSA.git?tag=v1.2.0%2Bglade3#4661ba4de82c42c3810a81fac1f86fe319de7fa6 - pypi: https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/29/b5/c1209e6cb77647bc2c9a6a1a953355720f34f3b006b725e303c70f3c0786/pyu2f-0.1.5.tar.gz - pypi: https://files.pythonhosted.org/packages/32/00/ec8597a64f2be301ce1ee3290d067f49f6a7afb226b67d5f15b56d772ba5/rapidfuzz-3.14.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl @@ -1435,7 +1435,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/ab/4c/b888e6cf58bd9db9c93f40d1c6be8283ff49d88919231afe93a6bcf61626/pydeck-0.9.1-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d9/dd/e0aa7ebef5168c75b772eda64978c597a9129b46be17779054652a7999e4/pyOpenSSL-24.2.1-py3-none-any.whl - - pypi: git+https://github.com/koen-vg/PyPSA.git?tag=v1.2.0%2Bglade2#02181af0507140246a4aa581948246c4440a52cd + - pypi: git+https://github.com/koen-vg/PyPSA.git?tag=v1.2.0%2Bglade3#4661ba4de82c42c3810a81fac1f86fe319de7fa6 - pypi: https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/29/b5/c1209e6cb77647bc2c9a6a1a953355720f34f3b006b725e303c70f3c0786/pyu2f-0.1.5.tar.gz - pypi: https://files.pythonhosted.org/packages/32/00/ec8597a64f2be301ce1ee3290d067f49f6a7afb226b67d5f15b56d772ba5/rapidfuzz-3.14.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl @@ -1847,7 +1847,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl - pypi: https://files.pythonhosted.org/packages/ab/4c/b888e6cf58bd9db9c93f40d1c6be8283ff49d88919231afe93a6bcf61626/pydeck-0.9.1-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d9/dd/e0aa7ebef5168c75b772eda64978c597a9129b46be17779054652a7999e4/pyOpenSSL-24.2.1-py3-none-any.whl - - pypi: git+https://github.com/koen-vg/PyPSA.git?tag=v1.2.0%2Bglade2#02181af0507140246a4aa581948246c4440a52cd + - pypi: git+https://github.com/koen-vg/PyPSA.git?tag=v1.2.0%2Bglade3#4661ba4de82c42c3810a81fac1f86fe319de7fa6 - pypi: https://files.pythonhosted.org/packages/29/b5/c1209e6cb77647bc2c9a6a1a953355720f34f3b006b725e303c70f3c0786/pyu2f-0.1.5.tar.gz - pypi: https://files.pythonhosted.org/packages/32/00/ec8597a64f2be301ce1ee3290d067f49f6a7afb226b67d5f15b56d772ba5/rapidfuzz-3.14.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/7b/18/22316545b712dbed0119c7d3b8683a566c7da26353e344a4188b99f12692/remotezip-0.12.3-py3-none-any.whl @@ -7379,9 +7379,9 @@ packages: - pkg:pypi/pyproj?source=hash-mapping size: 534602 timestamp: 1757954997735 -- pypi: git+https://github.com/koen-vg/PyPSA.git?tag=v1.2.0%2Bglade2#02181af0507140246a4aa581948246c4440a52cd +- pypi: git+https://github.com/koen-vg/PyPSA.git?tag=v1.2.0%2Bglade3#4661ba4de82c42c3810a81fac1f86fe319de7fa6 name: pypsa - version: 1.2.0+glade2 + version: 1.2.0+glade3 requires_dist: - numpy - scipy diff --git a/pixi.toml b/pixi.toml index f1c5ce1e..97e6037d 100644 --- a/pixi.toml +++ b/pixi.toml @@ -66,7 +66,7 @@ glade-workflow = { path = ".", editable = true } snakemake-logger-plugin-compact = { path = "tools/snakemake-logger-plugin-compact" } # Optimization and modeling; own branches of pypsa and linopy with custom features needed by GLADE -pypsa = { git = "https://github.com/koen-vg/PyPSA.git", tag = "v1.2.0+glade2" } +pypsa = { git = "https://github.com/koen-vg/PyPSA.git", tag = "v1.2.0+glade3" } linopy = { git = "https://github.com/koen-vg/linopy.git", tag = "v0.8.0+glade2" } # Geospatial processing