From b2d2dd1cd9d180f5dbfaf77aaa0385e52cd86576 Mon Sep 17 00:00:00 2001 From: Bryn Pickering <17178478+brynpickering@users.noreply.github.com> Date: Tue, 15 Oct 2024 19:22:08 +0100 Subject: [PATCH] Update cost expression names & split out cost expressions (#685) * + remove volatile CoinCBC link from docs --- CHANGELOG.md | 2 + docs/installation.md | 2 +- docs/migrating.md | 6 ++ .../examples/piecewise_linear_costs.yaml | 17 ++-- src/calliope/backend/helper_functions.py | 3 +- src/calliope/math/operate.yaml | 10 ++- src/calliope/math/plan.yaml | 88 ++++++++++++------- ...cost_var.lp => cost_operation_variable.lp} | 0 ...=> cost_operation_variable_with_export.lp} | 0 tests/test_backend_general.py | 5 +- tests/test_backend_gurobi.py | 2 +- tests/test_backend_pyomo.py | 24 +++-- tests/test_math.py | 8 +- 13 files changed, 103 insertions(+), 64 deletions(-) rename tests/common/lp_files/{cost_var.lp => cost_operation_variable.lp} (100%) rename tests/common/lp_files/{cost_var_with_export.lp => cost_operation_variable_with_export.lp} (100%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 867ed39b..9aecfda0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ### User-facing changes +|changed| cost expressions in math, to split out investment costs into the capital cost (`cost_investment`), annualised capital cost (`cost_investment_annualised`), fixed operation costs (`cost_operation_fixed`) and variable operation costs (`cost_operation_variable`, previously `cost_var`) (#645). + |new| Math has been removed from `model.math`, and can now be accessed via `model.math.data` (#639). |new| (non-NaN) Default values and data types for parameters appear in math documentation (if they appear in the model definition schema) (#677). diff --git a/docs/installation.md b/docs/installation.md index 555e3a9a..f32ea654 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -68,7 +68,7 @@ This list is not exhaustive; any solvers [supported by Pyomo](https://pyomo.read [CBC](https://github.com/coin-or/Cbc) is our recommended option if you want a free and open-source solver. CBC can be installed via conda on Linux and macOS by running `mamba install -c conda-forge coin-or-cbc`. -Windows binary packages are somewhat more difficult to install, due to limited information on [the CBC website](https://github.com/coin-or/Cbc), but can be found within their [binary archive](https://www.coin-or.org/download/binary/Cbc/) and are included in their [package releases on GitHub](https://github.com/coin-or/Cbc/releases). +Windows binary packages are somewhat more difficult to install, due to limited information on [the CBC website](https://github.com/coin-or/Cbc), but are included in their [package releases on GitHub](https://github.com/coin-or/Cbc/releases). The GitHub releases are more up-to-date. We recommend you download the relevant binary for [CBC 2.10.11](https://github.com/coin-or/Cbc/releases/download/releases%2F2.10.11/Cbc-releases.2.10.11-w64-msvc17-md.zip) and add `cbc.exe` to a directory known to PATH (e.g. an Anaconda environment 'bin' directory). ### GLPK diff --git a/docs/migrating.md b/docs/migrating.md index face9418..f361358d 100644 --- a/docs/migrating.md +++ b/docs/migrating.md @@ -326,6 +326,7 @@ Here are the main changes to parameter/decision variable names that are not link * `energy_cap_min_use` → `flow_out_min_relative` (i.e., the value is relative to `flow_cap`). * `parasitic_eff` → `flow_out_parasitic_eff`. * `force_asynchronous_prod_con` → `force_async_flow`. +* `cost_var` → `cost_operation_variable`. * `exists` → `active`. !!! info "See also" @@ -365,6 +366,11 @@ You can find an example of this change [above](#filedf-→-data_tables-section). We have rolled the integer decision variable `units` and the binary `purchased` into one decision variable `purchased_units`. To achieve the same functionality for `purchased`, set `purchased_units_max: 1`. +### `cost_investment` → `cost_investment_annualised` + `cost_operation_fixed` + +Investment costs are split out into the component caused by annual operation and maintenance (`cost_operation_fixed`) and an annualised equivalent of the initial capital investment (`cost_investment_annualised`). +`cost_investment` still exists in the model results and represents the initial capital investment, i.e., without applying the economic depreciation rate. + ### Explicitly triggering MILP and storage decision variables/constraints In v0.6, we inferred that a mixed-integer linear model was desired based on the user defining certain parameters. diff --git a/docs/user_defined_math/examples/piecewise_linear_costs.yaml b/docs/user_defined_math/examples/piecewise_linear_costs.yaml index 188cb7bd..2baddf06 100644 --- a/docs/user_defined_math/examples/piecewise_linear_costs.yaml +++ b/docs/user_defined_math/examples/piecewise_linear_costs.yaml @@ -47,14 +47,9 @@ global_expressions: OR cost_investment_purchase OR piecewise_cost_investment) equations: - expression: > - $annualisation_weight * ( - $depreciation_rate * ( - sum(default_if_empty(cost_investment_flow_cap, 0), over=carriers) + - default_if_empty(cost_investment_storage_cap, 0) + - default_if_empty(cost_investment_source_cap, 0) + - default_if_empty(cost_investment_area_use, 0) + - default_if_empty(cost_investment_purchase, 0) + - default_if_empty(piecewise_cost_investment, 0) - ) * (1 + cost_om_annual_investment_fraction) - + sum(cost_om_annual * default_if_empty(flow_cap, 0), over=carriers) - ) + sum(default_if_empty(cost_investment_flow_cap, 0), over=carriers) + + default_if_empty(cost_investment_storage_cap, 0) + + default_if_empty(cost_investment_source_cap, 0) + + default_if_empty(cost_investment_area_use, 0) + + default_if_empty(cost_investment_purchase, 0) + + default_if_empty(piecewise_cost_investment, 0) diff --git a/src/calliope/backend/helper_functions.py b/src/calliope/backend/helper_functions.py index 1424cf29..0f1b9505 100644 --- a/src/calliope/backend/helper_functions.py +++ b/src/calliope/backend/helper_functions.py @@ -462,7 +462,8 @@ def as_array(self, array: xr.DataArray, *, over: str | list[str]) -> xr.DataArra NaNs are ignored (xarray.DataArray.sum arg: `skipna: True`) and if all values along the dimension(s) are NaN, the summation will lead to a NaN (xarray.DataArray.sum arg: `min_count=1`). """ - return array.sum(over, min_count=1, skipna=True) + filtered_over = set(self._listify(over)).intersection(array.dims) + return array.sum(filtered_over, min_count=1, skipna=True) class ReduceCarrierDim(ParsingHelperFunction): diff --git a/src/calliope/math/operate.yaml b/src/calliope/math/operate.yaml index f380606f..ea598328 100644 --- a/src/calliope/math/operate.yaml +++ b/src/calliope/math/operate.yaml @@ -25,7 +25,13 @@ variables: global_expressions: cost_investment.active: false + cost_investment_annualised.active: false + cost_investment_flow_cap.active: false + cost_investment_storage_cap.active: false + cost_investment_source_cap.active: false + cost_investment_area_use.active: false + cost_investment_purchase.active: false cost: - where: "cost_export OR cost_flow_in OR cost_flow_out" + where: "cost_operation_variable" equations: - - expression: $cost_var_sum + - expression: sum(cost_operation_variable, over=timesteps) diff --git a/src/calliope/math/plan.yaml b/src/calliope/math/plan.yaml index 0ee9f7db..cc84d6fb 100644 --- a/src/calliope/math/plan.yaml +++ b/src/calliope/math/plan.yaml @@ -265,7 +265,7 @@ constraints: slices: final_step: - expression: get_val_at_index(timesteps=-1) - active: true # optional; defaults to true. + active: true # optional; defaults to true. # --8<-- [end:constraint] balance_transmission: @@ -279,7 +279,7 @@ constraints: symmetric_transmission: description: >- - Fix the flow capacity of two `transmission` technologies representing the same link in the system. + Fix the flow capacity of two `transmission` technologies representing the same link in the system. foreach: [nodes, techs] where: "base_tech=transmission" equations: @@ -599,11 +599,11 @@ variables: unit: energy foreach: [nodes, techs] where: "include_storage=True OR base_tech=storage" - domain: real # optional; defaults to real. + domain: real # optional; defaults to real. bounds: min: storage_cap_min max: storage_cap_max - active: true # optional; defaults to true. + active: true # optional; defaults to true. # --8<-- [end:variable] storage: @@ -721,7 +721,6 @@ variables: min: -.inf max: 0 - objectives: # --8<-- [start:objective] min_cost_optimisation: @@ -754,7 +753,7 @@ objectives: - where: "NOT config.ensure_feasibility=True" expression: "0" sense: minimise - active: true # optional; defaults to true. + active: true # optional; defaults to true. # --8<-- [end:objective] global_expressions: @@ -788,7 +787,7 @@ global_expressions: - where: NOT base_tech=transmission expression: flow_in * flow_in_eff - cost_var: + cost_operation_variable: title: Variable operating costs description: >- The operating costs per timestep of a technology. @@ -874,7 +873,7 @@ global_expressions: cost_investment: title: Total investment costs description: >- - The installation costs of a technology, including annualised investment costs and annual maintenance costs. + The installation costs of a technology, including those linked to the nameplate capacity, land use, storage size, and binary/integer unit purchase. default: 0 unit: cost foreach: [nodes, techs, costs] @@ -882,19 +881,26 @@ global_expressions: cost_investment_flow_cap OR cost_investment_storage_cap OR cost_investment_source_cap OR cost_investment_area_use OR cost_investment_purchase equations: - - expression: > - $annualisation_weight * ( - ($depreciation_rate + cost_om_annual_investment_fraction) * ( - sum(default_if_empty(cost_investment_flow_cap, 0), over=carriers) + - default_if_empty(cost_investment_storage_cap, 0) + - default_if_empty(cost_investment_source_cap, 0) + - default_if_empty(cost_investment_area_use, 0) + - default_if_empty(cost_investment_purchase, 0) - ) - + sum(cost_om_annual * flow_cap, over=carriers) - ) + - expression: >- + sum(default_if_empty(cost_investment_flow_cap, 0), over=carriers) + + default_if_empty(cost_investment_storage_cap, 0) + + default_if_empty(cost_investment_source_cap, 0) + + default_if_empty(cost_investment_area_use, 0) + + default_if_empty(cost_investment_purchase, 0) + + cost_investment_annualised: + title: Equivalent annual investment costs + description: >- + An annuity factor has been applied to scale lifetime investment costs to annual values that can be directly compared to operation costs. + If the modeling period is not equal to one full year, this will be scaled accordingly. + default: 0 + unit: cost + foreach: [nodes, techs, costs] + where: cost_investment + equations: + - expression: $annualisation_weight * $depreciation_rate * cost_investment sub_expressions: - annualisation_weight: + annualisation_weight: &annualisation_weight - expression: sum(timestep_resolution * timestep_weights, over=timesteps) / 8760 depreciation_rate: - where: cost_depreciation_rate @@ -906,6 +912,24 @@ global_expressions: (cost_interest_rate * ((1 + cost_interest_rate) ** lifetime)) / (((1 + cost_interest_rate) ** lifetime) - 1) + cost_operation_fixed: + title: Total fixed operation costs + description: >- + The fixed, annual operation costs of a technology, which are calculated relative to investment costs. + If the modeling period is not equal to one full year, this will be scaled accordingly. + default: 0 + unit: cost + foreach: [nodes, techs, costs] + where: cost_investment AND (cost_om_annual OR cost_om_annual_investment_fraction) + equations: + - expression: >- + $annualisation_weight * ( + sum(cost_om_annual * flow_cap, over=carriers) + + cost_investment * cost_om_annual_investment_fraction + ) + sub_expressions: + annualisation_weight: *annualisation_weight + # --8<-- [start:expression] cost: title: Total costs @@ -915,21 +939,19 @@ global_expressions: default: 0 unit: cost foreach: [nodes, techs, costs] - where: "cost_investment OR cost_var" + where: "cost_investment_annualised OR cost_operation_variable OR cost_operation_fixed" equations: - - expression: $cost_investment + $cost_var_sum + - expression: >- + default_if_empty(cost_investment_annualised, 0) + + $cost_operation_sum + + default_if_empty(cost_operation_fixed, 0) sub_expressions: - cost_investment: - - where: "cost_investment" - expression: cost_investment - - where: "NOT cost_investment" - expression: "0" - cost_var_sum: - - where: "cost_var" - expression: sum(cost_var, over=timesteps) - - where: "NOT cost_var" + cost_operation_sum: + - where: "cost_operation_variable" + expression: sum(cost_operation_variable, over=timesteps) + - where: "NOT cost_operation_variable" expression: "0" - active: true # optional; defaults to true. + active: true # optional; defaults to true. # --8<-- [end:expression] -piecewise_constraints: {} \ No newline at end of file +piecewise_constraints: {} diff --git a/tests/common/lp_files/cost_var.lp b/tests/common/lp_files/cost_operation_variable.lp similarity index 100% rename from tests/common/lp_files/cost_var.lp rename to tests/common/lp_files/cost_operation_variable.lp diff --git a/tests/common/lp_files/cost_var_with_export.lp b/tests/common/lp_files/cost_operation_variable_with_export.lp similarity index 100% rename from tests/common/lp_files/cost_var_with_export.lp rename to tests/common/lp_files/cost_operation_variable_with_export.lp diff --git a/tests/test_backend_general.py b/tests/test_backend_general.py index f61443f5..8b42fa70 100644 --- a/tests/test_backend_general.py +++ b/tests/test_backend_general.py @@ -152,12 +152,11 @@ def test_get_variable_obj_type(self, variable): """Check a decision variable has the correct obj_type.""" assert variable.attrs["obj_type"] == "variables" - def test_get_variable_refs(self, variable): + def test_get_variable_refs(self, variable, solved_model_cls): """Check a decision variable has all expected references to other math components.""" assert variable.attrs["references"] == { "flow_in_max", "flow_out_max", - "cost_investment", "cost_investment_flow_cap", "symmetric_transmission", } @@ -224,7 +223,7 @@ def test_get_global_expression_obj_type(self, global_expression): def test_get_global_expression_refs(self, global_expression): """Check a global expression has all expected math component refs.""" - assert global_expression.attrs["references"] == {"cost"} + assert global_expression.attrs["references"] == {"cost_investment_annualised"} def test_get_global_expression_default(self, global_expression): """Check a global expression has expected default.""" diff --git a/tests/test_backend_gurobi.py b/tests/test_backend_gurobi.py index 95821860..d7c1beff 100755 --- a/tests/test_backend_gurobi.py +++ b/tests/test_backend_gurobi.py @@ -154,7 +154,7 @@ def test_verbose_strings_expression(self, simple_supply_longnames): assert "flow_cap[a, test_supply_elec, electricity]" in obj.sel(dims).item() # parameters are not gurobi objects, so we don't get their names in our strings - assert "parameters[cost_interest_rate]" not in obj.sel(dims).item() + assert "cost_flow_cap" not in obj.sel(dims).item() assert not obj.coords_in_name diff --git a/tests/test_backend_pyomo.py b/tests/test_backend_pyomo.py index df8d6431..710d147a 100755 --- a/tests/test_backend_pyomo.py +++ b/tests/test_backend_pyomo.py @@ -482,7 +482,7 @@ def test_loc_techs_cost_investment_milp_constraint(self): def test_loc_techs_not_cost_var_constraint(self, simple_conversion): """I for i in sets.loc_techs_om_cost if i not in sets.loc_techs_conversion_plus + sets.loc_techs_conversion""" - assert "cost_var" not in simple_conversion.backend.expressions + assert "cost_operation_variable" not in simple_conversion.backend.expressions @pytest.mark.parametrize( ("tech", "scenario", "cost"), @@ -502,7 +502,7 @@ def test_loc_techs_cost_var_constraint(self, tech, scenario, cost): {f"techs.{tech}.costs.monetary.{cost}": 1}, f"{scenario},two_hours" ) m.build() - assert "cost_var" in m.backend.expressions + assert "cost_operation_variable" in m.backend.expressions def test_one_way_om_cost(self): """With one_way transmission, it should still be possible to set an flow_out cost.""" @@ -521,13 +521,17 @@ def test_one_way_om_cost(self): "timesteps": m.backend._dataset.timesteps[1], } assert check_variable_exists( - m.backend.get_expression("cost_var", as_backend_objs=False), "flow_out", idx + m.backend.get_expression("cost_operation_variable", as_backend_objs=False), + "flow_out", + idx, ) idx["nodes"] = "a" idx["techs"] = "test_transmission_elec:b" assert not check_variable_exists( - m.backend.get_expression("cost_var", as_backend_objs=False), "flow_out", idx + m.backend.get_expression("cost_operation_variable", as_backend_objs=False), + "flow_out", + idx, ) @@ -560,17 +564,18 @@ def test_loc_tech_carriers_export_balance_constraint(self, supply_export): def test_loc_techs_update_costs_var_constraint(self, supply_export): """I for i in sets.loc_techs_om_cost if i in sets.loc_techs_export""" - assert "cost_var" in supply_export.backend.expressions + assert "cost_operation_variable" in supply_export.backend.expressions m = build_model( {"techs.test_supply_elec.costs.monetary.flow_out": 0.1}, "supply_export,two_hours,investment_costs", ) m.build() - assert "cost_var" in m.backend.expressions + assert "cost_operation_variable" in m.backend.expressions assert check_variable_exists( - m.backend.get_expression("cost_var", as_backend_objs=False), "flow_export" + m.backend.get_expression("cost_operation_variable", as_backend_objs=False), + "flow_export", ) def test_loc_tech_carriers_export_max_constraint(self): @@ -2001,7 +2006,10 @@ def test_verbose_strings_expression(self, simple_supply_longnames): "variables[flow_cap][a, test_supply_elec, electricity]" in obj.sel(dims).item() ) - assert "parameters[cost_interest_rate]" in obj.sel(dims).item() + assert ( + "parameters[cost_flow_cap][test_supply_elec, monetary]" + in obj.sel(dims).item() + ) assert not obj.coords_in_name diff --git a/tests/test_math.py b/tests/test_math.py index 7887b8a1..35aed4e7 100644 --- a/tests/test_math.py +++ b/tests/test_math.py @@ -150,9 +150,9 @@ def test_balance_storage(self, compare_lps): compare_lps(model, custom_math, "balance_storage") @pytest.mark.parametrize("with_export", [True, False]) - def test_cost_var_with_export(self, compare_lps, with_export): + def test_cost_operation_variable(self, compare_lps, with_export): """Test variable costs in the objective.""" - self.TEST_REGISTER.add("global_expressions.cost_var") + self.TEST_REGISTER.add("global_expressions.cost_operation_variable") override = { "techs.test_conversion_plus.cost_flow_out": { "data": [1, 2], @@ -195,7 +195,7 @@ def test_cost_var_with_export(self, compare_lps, with_export): "foo": { "equations": [ { - "expression": "sum(cost_var, over=[nodes, techs, costs, timesteps])" + "expression": "sum(cost_operation_variable, over=[nodes, techs, costs, timesteps])" } ], "sense": "minimise", @@ -203,7 +203,7 @@ def test_cost_var_with_export(self, compare_lps, with_export): } } suffix = "_with_export" if with_export else "" - compare_lps(model, custom_math, f"cost_var{suffix}") + compare_lps(model, custom_math, f"cost_operation_variable{suffix}") @pytest.mark.xfail(reason="not all base math is in the test config dict yet") def test_all_math_registered(self, base_math):