diff --git a/CHANGELOG.md b/CHANGELOG.md index abea82cb..8fd1ae39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ |changed| `template:` can now be used anywhere within YAML definition files, not just in the `nodes`, `techs` and `data_tables` sections. +|fixed| Area-based parameters have appropriate documented units of `area` rather than `area^2` (#701). + +|fixed| Technology capacity lower bound constraints so that `[cap-type]_min` (e.g., `flow_cap_min`) is not always enforced if the `purchased_units` variable is active (#643). + |changed| Single data entries defined in YAML indexed parameters will not be automatically broadcast along indexed dimensions. To achieve the same functionality as in `- Sets `area_use` to a parameter in operate mode. NOTE: this parameter cannot be used in `plan` mode as it clashes with the decision variable of the same name. - x-unit: $\text{area}^{2}$. + x-unit: $\text{area}$. area_use_max: $ref: "#/$defs/TechParamNullNumber" @@ -529,7 +529,7 @@ properties: title: Maximum usable area. description: >- If set to a finite value, limits the upper bound of the `area_use` decision variable to this value. - x-unit: $\text{area}^{2}$. + x-unit: $\text{area}$. area_use_min: $ref: "#/$defs/TechParamNullNumber" @@ -538,7 +538,7 @@ properties: title: Minimum usable area. description: >- Limits the lower bound of the `area_use` decision variable to this value. - x-unit: $\text{area}^{2}$. + x-unit: $\text{area}$. area_use_per_flow_cap: $ref: "#/$defs/TechParamNullNumber" @@ -547,7 +547,7 @@ properties: title: Area use per flow capacity description: >- If set, forces `area_use` to follow `flow_cap` with the given numerical ratio (e.g. setting to 1.5 means that `area_use == 1.5 * flow_cap`). - x-unit: $\frac{\text{area}^{2}}{\text{power}}$. + x-unit: $\frac{\text{area}}{\text{power}}$. storage_cap: $ref: "#/$defs/TechParamNullNumber" @@ -558,7 +558,7 @@ properties: description: >- Sets `storage_cap` to a parameter in operate mode. NOTE: this parameter cannot be used in `plan` mode as it clashes with the decision variable of the same name. - x-unit: $\text{area}^{2}$. + x-unit: $\text{area}$. storage_cap_max: $ref: "#/$defs/TechParamNullNumber" @@ -950,7 +950,7 @@ properties: title: Cost of area use. description: >- Cost per unit `area_use`. - x-unit: $\text{area}^{-2}$. + x-unit: $\text{area}^{-1}$. cost_source_cap: $ref: "#/$defs/TechCostNullNumber" @@ -1016,3 +1016,7 @@ properties: minimum: 0 default: .inf x-resample_method: mean + title: Available area at the given node. + description: >- + Limits the total area that can be occupied by all technologies which have the `area_use` decision variable activated. + x-unit: $\text{area}$. diff --git a/src/calliope/math/plan.yaml b/src/calliope/math/plan.yaml index cc84d6fb..cf8f1399 100644 --- a/src/calliope/math/plan.yaml +++ b/src/calliope/math/plan.yaml @@ -369,7 +369,7 @@ constraints: storage_capacity_units_milp: description: >- Fix the storage capacity of any technology using integer units to define its capacity. - foreach: [nodes, techs, carriers] + foreach: [nodes, techs] where: "storage AND purchased_units AND storage_cap_per_unit" equations: - expression: storage_cap == purchased_units * storage_cap_per_unit @@ -378,7 +378,7 @@ constraints: description: >- Fix the flow capacity of any technology using integer units to define its capacity. foreach: [nodes, techs, carriers] - where: "operating_units AND flow_cap_per_unit" + where: "purchased_units AND flow_cap_per_unit" equations: - expression: flow_cap == purchased_units * flow_cap_per_unit @@ -394,14 +394,18 @@ constraints: - where: NOT flow_cap_max expression: flow_cap <= bigM * purchased_units - flow_capacity_min_purchase_milp: + flow_capacity_minimum: description: >- Set the lower bound on a technology's flow capacity, - for any technology with integer capacity purchasing. + for any technology with a non-zero lower bound, + with or without integer capacity purchasing. foreach: [nodes, techs, carriers] - where: "purchased_units AND flow_cap_min" + where: "flow_cap_min" equations: - - expression: flow_cap >= flow_cap_min * purchased_units + - where: NOT purchased_units + expression: flow_cap >= flow_cap_min + - where: purchased_units + expression: flow_cap >= flow_cap_min * purchased_units storage_capacity_max_purchase_milp: description: >- @@ -412,14 +416,44 @@ constraints: equations: - expression: storage_cap <= storage_cap_max * purchased_units - storage_capacity_min_purchase_milp: + storage_capacity_minimum: description: >- - Set the lower bound on a technology's storage capacity, - for any technology with integer capacity purchasing. + Set the lower bound on a technology's storage capacity + for any technology with a non-zero lower bound, + with or without integer capacity purchasing. + foreach: [nodes, techs] + where: "storage_cap_min" + equations: + - where: NOT purchased_units + expression: storage_cap >= storage_cap_min + - where: purchased_units + expression: storage_cap >= storage_cap_min * purchased_units + + area_use_minimum: + description: >- + Set the lower bound on a technology's area use + for any technology with a non-zero lower bound, + with or without integer capacity purchasing. + foreach: [nodes, techs] + where: "area_use_min" + equations: + - where: NOT purchased_units + expression: area_use >= area_use_min + - where: purchased_units + expression: area_use >= area_use_min * purchased_units + + source_capacity_minimum: + description: >- + Set the lower bound on a technology's source capacity + for any supply technology with a non-zero lower bound, + with or without integer capacity purchasing. foreach: [nodes, techs] - where: "purchased_units AND storage_cap_min" + where: "base_tech=supply AND source_cap_min" equations: - - expression: storage_cap >= storage_cap_min * purchased_units + - where: NOT purchased_units + expression: source_cap >= source_cap_min + - where: purchased_units + expression: source_cap >= source_cap_min * purchased_units unit_capacity_max_systemwide_milp: description: >- @@ -496,7 +530,7 @@ variables: unit: power foreach: [nodes, techs, carriers] bounds: - min: flow_cap_min + min: 0 # set in a distinct constraint to handle the integer purchase variable max: flow_cap_max link_flow_cap: @@ -561,7 +595,7 @@ variables: foreach: [nodes, techs] where: "(area_use_min OR area_use_max OR area_use_per_flow_cap OR sink_unit=per_area OR source_unit=per_area)" bounds: - min: area_use_min + min: 0 # set in a distinct constraint to handle the integer purchase variable max: area_use_max source_use: @@ -586,7 +620,7 @@ variables: foreach: [nodes, techs] where: "base_tech=supply" bounds: - min: source_cap_min + min: 0 # set in a distinct constraint to handle the integer purchase variable max: source_cap_max # --8<-- [start:variable] @@ -601,7 +635,7 @@ variables: where: "include_storage=True OR base_tech=storage" domain: real # optional; defaults to real. bounds: - min: storage_cap_min + min: 0 # set in a distinct constraint to handle the integer purchase variable max: storage_cap_max active: true # optional; defaults to true. # --8<-- [end:variable] @@ -662,6 +696,10 @@ variables: description: >- Flow capacity that will be set to zero if the technology is not operating in a given timestep and will be set to the value of the decision variable `flow_cap` otherwise. + This is useful when you want to set a minimum flow capacity for any technology investment, but also want to allow the model to decide the capacity. + It is expected to only be used when `purchased_units_max == 1`, + i.e., the `purchased_units` decision variable is binary. + If `purchased_units_max > 1`, you may get strange results and should instead use the less flexible `flow_cap_per_unit`. default: 0 unit: power foreach: [nodes, techs, carriers, timesteps] diff --git a/tests/common/lp_files/area_use.lp b/tests/common/lp_files/area_use.lp new file mode 100644 index 00000000..83f3f879 --- /dev/null +++ b/tests/common/lp_files/area_use.lp @@ -0,0 +1,17 @@ +\* Source Pyomo model name=None *\ + +min +objectives(foo)(0): ++1 variables(area_use)(a__test_supply_elec) ++1 variables(area_use)(b__test_supply_elec) + +s.t. + +c_l_constraints(area_use_minimum)(a__test_supply_elec)_: ++1 variables(area_use)(a__test_supply_elec) +>= 1.0 + +bounds + 0 <= variables(area_use)(a__test_supply_elec) <= +inf + 0 <= variables(area_use)(b__test_supply_elec) <= 100.0 +end diff --git a/tests/common/lp_files/flow_cap.lp b/tests/common/lp_files/flow_cap.lp index 8b54cc2d..b97e95e0 100644 --- a/tests/common/lp_files/flow_cap.lp +++ b/tests/common/lp_files/flow_cap.lp @@ -7,12 +7,11 @@ objectives(foo)(0): s.t. -c_e_ONE_VAR_CONSTANT: -+1 ONE_VAR_CONSTANT -= 1 +c_l_constraints(flow_capacity_minimum)(a__test_supply_elec__electricity)_: ++1 variables(flow_cap)(a__test_supply_elec__electricity) +>= 1.0 bounds - 1 <= ONE_VAR_CONSTANT <= 1 - 1.0 <= variables(flow_cap)(a__test_supply_elec__electricity) <= +inf + 0 <= variables(flow_cap)(a__test_supply_elec__electricity) <= +inf 0 <= variables(flow_cap)(b__test_supply_elec__electricity) <= 100.0 end diff --git a/tests/common/lp_files/flow_out_max.lp b/tests/common/lp_files/flow_out_max.lp index 31e0c6c0..856d27f6 100644 --- a/tests/common/lp_files/flow_out_max.lp +++ b/tests/common/lp_files/flow_out_max.lp @@ -75,7 +75,7 @@ bounds 0 <= variables(flow_cap)(a__test_link_a_b_heat__heat) <= 5.0 0 <= variables(flow_out)(a__test_link_a_b_heat__heat__2005_01_01_01_00) <= +inf 0 <= variables(flow_out)(a__test_supply_elec__electricity__2005_01_01_00_00) <= +inf - 100.0 <= variables(flow_cap)(a__test_supply_elec__electricity) <= 100.0 + 0 <= variables(flow_cap)(a__test_supply_elec__electricity) <= 10.0 0 <= variables(flow_out)(a__test_supply_elec__electricity__2005_01_01_01_00) <= +inf 0 <= variables(flow_out)(b__test_link_a_b_elec__electricity__2005_01_01_00_00) <= +inf 0 <= variables(flow_cap)(b__test_link_a_b_elec__electricity) <= 10.0 diff --git a/tests/common/lp_files/source_cap.lp b/tests/common/lp_files/source_cap.lp new file mode 100644 index 00000000..7a3b9c52 --- /dev/null +++ b/tests/common/lp_files/source_cap.lp @@ -0,0 +1,17 @@ +\* Source Pyomo model name=None *\ + +min +objectives(foo)(0): ++1 variables(source_cap)(a__test_supply_elec) ++1 variables(source_cap)(b__test_supply_elec) + +s.t. + +c_l_constraints(source_capacity_minimum)(a__test_supply_elec)_: ++1 variables(source_cap)(a__test_supply_elec) +>= 1.0 + +bounds + 0 <= variables(source_cap)(a__test_supply_elec) <= +inf + 0 <= variables(source_cap)(b__test_supply_elec) <= 100.0 +end diff --git a/tests/common/lp_files/storage_cap.lp b/tests/common/lp_files/storage_cap.lp new file mode 100644 index 00000000..12c25edb --- /dev/null +++ b/tests/common/lp_files/storage_cap.lp @@ -0,0 +1,17 @@ +\* Source Pyomo model name=None *\ + +min +objectives(foo)(0): ++1 variables(storage_cap)(a__test_supply_elec) ++1 variables(storage_cap)(b__test_supply_elec) + +s.t. + +c_l_constraints(storage_capacity_minimum)(a__test_supply_elec)_: ++1 variables(storage_cap)(a__test_supply_elec) +>= 1.0 + +bounds + 0 <= variables(storage_cap)(a__test_supply_elec) <= +inf + 0 <= variables(storage_cap)(b__test_supply_elec) <= 100.0 +end diff --git a/tests/test_backend_general.py b/tests/test_backend_general.py index 8b42fa70..9f9d3d81 100644 --- a/tests/test_backend_general.py +++ b/tests/test_backend_general.py @@ -666,11 +666,11 @@ def test_update_variable_single_bound_multi_val(self, caplog, solved_model_func) def test_update_variable_error_update_parameter_instead(self, solved_model_func): """Check that expected error is raised if trying to update a variable bound that was set by a parameter.""" with pytest.raises(calliope.exceptions.BackendError) as excinfo: - solved_model_func.backend.update_variable_bounds("flow_cap", min=1) + solved_model_func.backend.update_variable_bounds("flow_cap", max=1) assert check_error_or_warning( excinfo, "Cannot update variable bounds that have been set by parameters." - " Use `update_parameter('flow_cap_min')` to update the min bound of flow_cap.", + " Use `update_parameter('flow_cap_max')` to update the max bound of flow_cap.", ) def test_fix_variable_before_solve(self, built_model_cls_longnames): diff --git a/tests/test_math.py b/tests/test_math.py index e3c03195..20b14353 100644 --- a/tests/test_math.py +++ b/tests/test_math.py @@ -50,32 +50,54 @@ class TestBaseMath: def base_math(self): return read_rich_yaml(CALLIOPE_DIR / "math" / "plan.yaml") - def test_flow_cap(self, compare_lps): - self.TEST_REGISTER.add("variables.flow_cap") + @pytest.mark.parametrize( + ("variable", "constraint", "overrides"), + [ + ("flow_cap", "flow_capacity_minimum", {}), + ( + "storage_cap", + "storage_capacity_minimum", + {"techs.test_supply_elec.include_storage": True}, + ), + ("area_use", "area_use_minimum", {}), + ("source_cap", "source_capacity_minimum", {}), + ], + ) + def test_capacity_variables_and_bounds( + self, compare_lps, variable, constraint, overrides + ): + """Check that variables are initiated with the appropriate bounds, + and that the lower bound is updated from zero via a separate constraint if required. + """ + constraint_full = f"constraints.{constraint}" + self.TEST_REGISTER.add(f"variables.{variable}") + self.TEST_REGISTER.add(constraint_full) model = build_test_model( { - "nodes.b.techs.test_supply_elec.flow_cap_max": 100, - "nodes.a.techs.test_supply_elec.flow_cap_min": 1, - "nodes.a.techs.test_supply_elec.flow_cap_max": np.nan, + f"nodes.b.techs.test_supply_elec.{variable}_max": 100, + f"nodes.a.techs.test_supply_elec.{variable}_min": 1, + f"nodes.a.techs.test_supply_elec.{variable}_max": np.nan, + **overrides, }, "simple_supply,two_hours,investment_costs", ) - custom_math = { - # need the variable defined in a constraint/objective for it to appear in the LP file bounds - "objectives": { - "foo": { - "equations": [ - { - "expression": "sum(flow_cap[techs=test_supply_elec], over=[nodes, carriers])" - } - ], - "sense": "minimise", - } + # Custom objective ensures that all variables appear in the LP file. + # Variables not found in either an objective or constraint will never appear in the LP. + sum_in_objective = "[nodes]" if variable != "flow_cap" else "[nodes, carriers]" + custom_objective = { + "objectives.foo": { + "equations": [ + { + "expression": f"sum({variable}[techs=test_supply_elec], over={sum_in_objective})" + } + ], + "sense": "minimise", } } - compare_lps(model, custom_math, "flow_cap") - - # "flow_cap" is the name of the lp file + custom_math = AttrDict( + {constraint_full: PLAN_MATH.get_key(constraint_full), **custom_objective} + ) + compare_lps(model, custom_math, variable) def test_storage_max(self, compare_lps): self.TEST_REGISTER.add("constraints.storage_max") @@ -87,13 +109,7 @@ def test_storage_max(self, compare_lps): def test_flow_out_max(self, compare_lps): self.TEST_REGISTER.add("constraints.flow_out_max") - model = build_test_model( - { - "nodes.a.techs.test_supply_elec.flow_cap_min": 100, - "nodes.a.techs.test_supply_elec.flow_cap_max": 100, - }, - "simple_supply,two_hours,investment_costs", - ) + model = build_test_model({}, "simple_supply,two_hours,investment_costs") custom_math = { "constraints": {"flow_out_max": PLAN_MATH.constraints.flow_out_max}