diff --git a/doc/release_notes.md b/doc/release_notes.md index 4431506165..00cc3c3131 100644 --- a/doc/release_notes.md +++ b/doc/release_notes.md @@ -29,6 +29,8 @@ - Add `include_objective_constant` and `assign_all_duals` to solving config validator. - Add `gurobi-simplex` as solver option. +* Change dispatch of biomass and biogas generators in CBA by (a) setting the buses' marginal prices as the generators' marginal costs and (b) removing energy budget constraints ([#719](https://github.com/open-energy-transition/open-tyndp/pull/719)). + **Bugfixes and Compatibility** * Rename bus for `t339` project (Tyrrhenian) from ITSI to ITVI ([#751](https://github.com/open-energy-transition/open-tyndp/pull/751)). diff --git a/scripts/cba/prepare_rolling_horizon.py b/scripts/cba/prepare_rolling_horizon.py index df97d00373..486744a33b 100644 --- a/scripts/cba/prepare_rolling_horizon.py +++ b/scripts/cba/prepare_rolling_horizon.py @@ -135,8 +135,8 @@ def disable_volume_limits(n: pypsa.Network): for c in n.components[{"Generator", "Link"}]: has_e_sum_min = isfinite(c.static.get("e_sum_min", [])) if has_e_sum_min.any(): - c.static["has_volume_limit"] = 0 - c.static.loc[has_e_sum_min, "has_volume_limit"] = 1 + c.static["has_volume_limit"] = False + c.static.loc[has_e_sum_min, "has_volume_limit"] = True c.static.loc[has_e_sum_min, "e_sum_min"] = -inf c.static.loc[has_e_sum_min, "e_sum_max"] = inf @@ -196,6 +196,52 @@ def apply_msv_to_network( n.c[c].dynamic["marginal_cost"].loc[:, s_i] = msv +def apply_biomass_biogas_bus_marginal_prices( + n: pypsa.Network, + n_msv: pypsa.Network, + carriers: list[str] = ["solid biomass", "biogas"], + resample_method: str = "ffill", +): + """ + Set biomass/biogas buses' marginal prices as the generators' marginal costs. + + For each biomass/biogas generator in the rolling horizon network: + use the marginal price time series of its attached bus from the MSV network and + set the marginal price as the generator's marginal cost. + + Parameters + ---------- + n : pypsa.Network + Target network. + n_msv : pypsa.Network + Solved MSV network containing bus marginal prices. + carriers : list[str], optional + Generator carriers to apply bus marginal prices to. Defaults to ["solid biomass", "biogas"]. + resample_method : str, optional + Method for resampling MSV bus marginal prices to target resolution. Default "ffill". + """ + if isinstance(carriers, str): + carriers = [carriers] + + # Get bus marginal prices from MSV network + msv_mp = n_msv.buses_t.marginal_price + + # Resample marginal prices if needed + bus_mp = ( + msv_mp + if n_msv.snapshots.equals(n.snapshots) + else resample_msv_to_target(msv_mp, n.snapshots, method=resample_method) + ) + + # Get index of generators with target carriers + gen_index = n.generators.index[n.generators.carrier.isin(carriers)] + + # Set marginal cost for each generator based on the marginal price of its bus + for g in gen_index: + bus = n.generators.at[g, "bus"] + n.generators_t.marginal_cost[g] = bus_mp[bus].reindex(n.snapshots) + + def set_initial_state_from_pf( n: pypsa.Network, n_msv: pypsa.Network, @@ -359,6 +405,9 @@ def fix_reservoir_soc_at_boundaries( # Apply marginal storage value to all non-cyclic carriers apply_msv_to_network(n, n_msv, cyclic_carriers, resample_method) + # Add bus marginal prices to the marginal costs of the biomass/biogas generators + apply_biomass_biogas_bus_marginal_prices(n, n_msv, resample_method=resample_method) + # Fix reservoir state of charge at window boundaries from perfect foresight soc_boundary_carriers = snakemake.params.get("soc_boundary_carriers", []) cba_solving = snakemake.config.get("cba", {}).get("solving", {}) diff --git a/scripts/cba/solve_cba_network.py b/scripts/cba/solve_cba_network.py index a4e15525cc..781dcefe42 100644 --- a/scripts/cba/solve_cba_network.py +++ b/scripts/cba/solve_cba_network.py @@ -191,24 +191,6 @@ def optimize_with_rolling_horizon( n.storage_units_t.state_of_charge.loc[snapshots[start - 1]] ) - # Set per-window energy budgets for volume-limited components - # (biomass, biogas) based on PF dispatch stored in generators_t.p - for c_name in ["Generator", "Link"]: - c = n.c[c_name] - if "has_volume_limit" not in c.static.columns: - continue - vol_idx = c.static.index[c.static["has_volume_limit"] == 1] - if vol_idx.empty: - continue - p_col = "p" if c_name == "Generator" else "p0" - pf_p = c.dynamic[p_col] - for comp in vol_idx: - if comp not in pf_p.columns: - continue - window_energy = pf_p.loc[sns, comp].sum() - c.static.loc[comp, "e_sum_min"] = window_energy - c.static.loc[comp, "e_sum_max"] = window_energy - status, condition = n.optimize(sns, **kwargs) # type: ignore # If solve is successful, dispose of Model object to release license before next rolling horizon @@ -341,7 +323,7 @@ def solve_network( snakemake = mock_snakemake( "solve_cba_network", run="NT", - cba_project="t4", + cba_project="t16", planning_horizons="2030", configfiles=["config/config.tyndp.yaml"], )