Repository: calliope-project/calliope (language: python)

Issue:
Proposed fixes / consistency changes for documentation
### Description

Follow up after a discussion with @sjpfenninger on 18.07., regarding some findings after combing through the current documentation, in no particular order (just numbered to make references in discussion easier):

1. `lookup_cluster_first_time` occurs in the base math; should that be part of the _Inter-cluster storage math_? [[link](https://calliope.readthedocs.io/en/latest/math/base/#balance_supply_with_storage)]
2. Missing links between entries:
	- `flow_cap` [[link](https://calliope.readthedocs.io/en/latest/math/base/#flow_cap)] makes use of, e.g., `flow_cap_min`, but the latter does not show that in it's _Used in_ section [[link](https://calliope.readthedocs.io/en/latest/math/base/#flow_cap_min)]
    - other parameters may be missing too, I assume these relations are built automatically, so probably just a single fix necessary
3. Adding "backlinks" between entries: [`area_use_max`](https://calliope.readthedocs.io/en/latest/math/base/#area_use_max) is used in [`area_use`](https://calliope.readthedocs.io/en/latest/math/base/#area_use), which is easy to follow. The latter makes use of [`source_unit`](https://calliope.readthedocs.io/en/latest/math/base/#source_unit), which may be hard to see on a first look, and further is then "hard" to follow up on. I assume the _"used in"_ section is built automatically, could the same approach also be used to build a _"makes use of"_  section? That would
    - show all used parameters, etc. immediately in a single list, and
    - make it easy to traverse the documentation by just hopping along the "dependency graph".
4. `balance_storage` checks for `where: (include_storage=true or base_tech=storage) ...`, whereas others check for `where: storage ...` instead; it seems to me those should be equivalent, because `storage` only exists with exactly the first condition [[link](https://calliope.readthedocs.io/en/latest/math/base/#storage)] - if that is true this could be unified for consistency, if not, the difference in behavior could be documented
5. `flow_cap_min` [[link](https://calliope.readthedocs.io/en/latest/math/base/#flow_cap_min)] contains a note stating _"this will force `flow_cap` to a minimum value unless `cap_method` is set to `integer`"_, which seems to not relate to the definition of `flow_cap` in a setting where `cap_method == LP` (and maybe more to `flow_cap_per_unit`, etc.).
6. Defaults, like for `cyclic_storage`, are hard to find: The [docs](https://calliope.readthedocs.io/en/latest/math/base/#cyclic_storage) do not mention this, but it can be checked, or found in the [schema description](https://calliope.readthedocs.io/en/latest/reference/model_schema/). Feels like the [base math](https://calliope.readthedocs.io/en/latest/math/base/) section should (?) contain all these.
7. Is there a way to alphabetically sort the entries in the right side-bar of the [base math](https://calliope.readthedocs.io/en/latest/math/base/) section? That would make it easier to find specific entries.

### Related links

Links are included in the description, a few things that I could guess how to fix are in #644.

### Version

latest (4fc6b84fe3f17d6533765c5543192e773deb05a9)

### Proposed change

_No response_


Discussion / hints:
> lookup_cluster_first_time occurs in the base math; should that be part of the Inter-cluster storage math? [[link](https://calliope.readthedocs.io/en/latest/math/base/#balance_supply_with_storage)]

No, if clustering the timeseries then it is needed irrespective of whether inter-cluster storage is used.

> Missing links between entries:
  flow_cap [[link](https://calliope.readthedocs.io/en/latest/math/base/#flow_cap)] makes use of, e.g., flow_cap_min, but the latter does not show that in it's Used in section [[link](https://calliope.readthedocs.io/en/latest/math/base/#flow_cap_min)]
  other parameters may be missing too, I assume these relations are built automatically, so probably just a single fix necessary
 
 They _are_ there, e.g.: 
 
<img width="650" alt="image" src="https://github.com/user-attachments/assets/340842d9-86c5-4cbd-a0ed-50874a382fe4">
 
> Adding "backlinks" between entries: [area_use_max](https://calliope.readthedocs.io/en/latest/math/base/#area_use_max) is used in [area_use](https://calliope.readthedocs.io/en/latest/math/base/#area_use), which is easy to follow. The latter makes use of [source_unit](https://calliope.readthedocs.io/en/latest/math/base/#source_unit), which may be hard to see on a first look, and further is then "hard" to follow up on. I assume the "used in" section is built automatically, could the same approach also be used to build a "makes use of" section? That would
        show all used parameters, etc. immediately in a single list, and
        make it easy to traverse the documentation by just hopping along the "dependency graph".

Yeah, it could be done automatically, just needs a way to store that information so the page doesn't explode even more than it is.

> balance_storage checks for where: (include_storage=true or base_tech=storage) ..., whereas others check for where: storage ... instead; it seems to me those should be equivalent, because storage only exists with exactly the first condition [[link](https://calliope.readthedocs.io/en/latest/math/base/#storage)] - if that is true this could be unified for consistency, if not, the difference in behavior could be documented

Yep, they are the same and could be boiled down to `include_storage=True`.

> flow_cap_min [[link](https://calliope.readthedocs.io/en/latest/math/base/#flow_cap_min)] contains a note stating "this will force flow_cap to a minimum value unless cap_method is set to integer", which seems to not relate to the definition of flow_cap in a setting where cap_method == LP (and maybe more to flow_cap_per_unit, etc.).

I'm a bit confused by this. If `cap_method=continuous` for a given technology then `flow_cap>=flow_cap_min`. If `cap_method=integer` then `flow_cap=0|flow_cap>=flow_cap_min`.

> Defaults, like for cyclic_storage, are hard to find: The [docs](https://calliope.readthedocs.io/en/latest/math/base/#cyclic_storage) do not mention this, but it can be checked, or found in the [schema description](https://calliope.readthedocs.io/en/latest/reference/model_schema/). Feels like the [base math](https://calliope.readthedocs.io/en/latest/math/base/) section should (?) contain all these.

Yeah, I'm planning to move parameter metadata to the math (so they aren't all hidden in the schema). Then we could show them easily in the docs.

> Is there a way to alphabetically sort the entries in the right side-bar of the [base math](https://calliope.readthedocs.io/en/latest/math/base/) section? That would make it easier to find specific entries.

Certainly would, but not possible in the docs engine. We could sort parameters/constraints/variables alphabetically in the source files. I prefer not to do the same with global expressions as their order is important (you can't reference another global expression unless it comes earlier in the set of definitions). 
Thanks for the in-depth answer!

> No, if clustering the timeseries then it is needed irrespective of whether inter-cluster storage is used.

Yeah, that makes sense, I kinda misread the formulation and that got me confused.

> They are there, e.g.:

I have no idea how I messed that up... I'm sorry! I somehow had some tabs in a slightly outdated version ([635](https://calliope--635.org.readthedocs.build/en/635/math/base/#flow_cap_min)) and didn't realize this. I even linked to the version where it's correct :man_facepalming: 

> Yeah, it could be done automatically, just needs a way to store that information so the page doesn't explode even more than it is.

Maybe a standard "details admonition"? Seems like the [mkdocs.yml](https://github.com/calliope-project/calliope/blob/72f95171e2f898925fb4209ad14e0fe826e718e4/mkdocs.yml#L90) activates [pymdownx.details](https://facelessuser.github.io/pymdown-extensions/extensions/details/) already, so maybe that (or the upcoming [blocks alternative](https://facelessuser.github.io/pymdown-extensions/extensions/blocks/plugins/details/#usage)) could be an option? Could also make the already existing long lists (e.g., [`flow_cap`](https://calliope.readthedocs.io/en/latest/math/base/#flow_cap)) more tractable.

> I'm a bit confused by this. If `cap_method=continuous` for a given technology then `flow_cap>=flow_cap_min`. If `cap_method=integer` then `flow_cap=0|flow_cap>=flow_cap_min`.

I'll reply to that separately, I need to think that one through at a less-tired-brain time.
> **Preface (!):** All of this is "unimportant" for me, no reason to prioritize, it's just what I noticed and it's in no way a problem for me right now.

I'm having a hard time wrapping my head around this, so sorry for the underlying confusion... I'll try anyways, although it feels like I'm overlooking something.

> I'm a bit confused by this. If `cap_method=continuous` for a given technology then `flow_cap>=flow_cap_min`. If `cap_method=integer` then `flow_cap=0|flow_cap>=flow_cap_min`.

---

## Documentation

My brain translates _"[...] this will force `flow_cap` to a minimum value unless `cap_method` is set to `integer` [...]"_ to:

- "unless [...]" => if `cap_method != integer`, then
- force the variable `flow_cap` to ~~a~~ THIS minimum value (because this is the thing I'm currently reading, `flow_cap_min`), meaning `flow_cap = flow_cap_min`(as in "force")

**Why?** Because I already know, that `flow_cap_min` is a "limit" for `flow_cap` (my interpretation: _the lower bound of a variable_) from the first sentence, _"Limits `flow_cap` to a minimum."_. And I know that this lower bound is scaled in the case of `cap_method=integer` (last sentence, and the linked constraint `flow_cap >= flow_cap_min * purchased_units`).

So my confusion comes from: Either the note with the italic highlight is redundant (based on the first sentence), which I would not assume (otherwise, why point that out?), or _force_ means something more serious than "the lower bound is as stated". But then I failed to find any obvious relation between `cap_method` and this interpretation?

Am I correct, that your quote above (`|` = $\vee$) is meant as:

1. if `cap_method == continuous` then `flow_cap >= flow_cap_min`
2. if `cap_method == integer` then `flow_cap = 0` OR `flow_cap >= flow_cap_min`

However, what I get from the docs is along the lines of:

1. if `cap_method == continuous` then `flow_cap >= flow_cap_min`
2. if `cap_method == integer` then `flow_cap >= flow_cap_min * purchased_units`

I'm unsure where the part of `flow_cap` being forced to 0 comes from? (even if that may be tied to `operating_units`, that only exists for `integer_dispatch` and not based on `cap_method` alone).

## Distortion of `purchased_units`

1. Let's assume `flow_cap_min > 0`, and `flow_cap_max > flow_cap_min`.
2. We know `flow_cap >= flow_cap_min`.
3. Due to `flow_capacity_max_purchase_milp`, we know `flow_cap <= flow_cap_max * purchased_units`.

Combining (2.) and (3.) we get `flow_cap_min <= flow_cap <= flow_cap_max * purchased_units`, which requires `purchased_units > 0` to be feasible. 

Is that on purpose? I had the feeling no, since stuff like `available_flow_cap_max_binary_continuous_switch` exists to work around a similar thing, just for `available_flow_cap`.

## `available_flow_cap`

I failed figuring that one out from the docs, so just posting current question:

Assuming `flow_cap_max` is set, and `operating_units` exists (so `integer_dispatch = true`, therefore `cap_method = integer` must be set, and both `operating_units` and `purchased_units` exists), then:

1. `available_flow_cap_continuous` sets `flow_cap` as upper bound.
2. `available_flow_cap_binary` enforces the second upper bound as `flow_cap_max * operating_units`, based on whether or not any units are actually operating.
3. `operating_units` is bounded by `purchased_units`.
4. `flow_cap` is already bounded by `flow_cap_max * purchased_units`.

So (2.) sets a bound on `available_flow_cap` (using `operating_units`), that is tighter than the one (1.) constructs, since that in the end comes down to `purchased_units`. This makes me think, this actually imposes a lower bound on `flow_cap`, meaning it could be interpreted as caring about `flow_cap >= available_flow_cap`? Or is that because `flow_cap` is actually the continuous ("variable") cost associated with sizing the unit? But that would only work for units which have _binary_ investment, which these do not need to have? (_and here is where I'm getting lost_)

Refactored comment into separate issues #651 and #652.
@sstroemer: this issue is becoming a bit of a monster. Can you please split it to make discussion easier?

Just a brief note on `flow_cap_min`, since we're going in circles here. 

> Am I correct, that your quote above (| = ∨ ) is meant as:
    if cap_method == continuous then flow_cap >= flow_cap_min
    if cap_method == integer then flow_cap = 0 OR flow_cap >= flow_cap_min

Yes.

What I am trying to get across in the description is that if you set `flow_cap_min` when `cap_method=continuous`, that technology will always exist in the model result, with at least the capacity set by `flow_cap_min`. If `cap_method=integer`, the technology may not exist in the model result, but if it does then its capacity will be at least `flow_cap_min`. The reason this is explicitly mentioned in the description is that users often expect the result of `cap_method=integer` to be the case when `cap_method=continuous`.

>  Let's assume flow_cap_min > 0, and flow_cap_max > flow_cap_min.
    We know flow_cap >= flow_cap_min.
    Due to flow_capacity_max_purchase_milp, we know flow_cap <= flow_cap_max * purchased_units.

You're missing that `flow_cap >= flow_cap_min * purchased_units` (flow_capacity_min_purchase_milp)

> available_flow_cap

(1) and (2) are part of a 3-part constraint (along with `available_flow_cap_max_binary_continuous_switch`) that allows you to effectively multiply a binary decision variable with a continuous one. It enables "if operating units == 0, available capacity == 0. If operating units > 0, available capacity == flow capacity". I see now that it likely needs another constraint as it only works if `purchased_units` has a maximum of 1, i.e. it is a binary decision variable. Because, if `purchased_units`=2, then when `operating_units`=1, `available capacity`<=`flow_cap_max * 1` (since this will be lower than `flow_cap`, which is `flow_cap_max * 2` in this example.
> @sstroemer: this issue is becoming a bit of a monster. Can you please split it to make discussion easier?

Sorry! I separated all topics besides the last clarification below. If that is working as intended then feel free to immediately close this one :smile: 

> Yes.

Can you point me to the constraints that (combined) construct what you described?

> You're missing that `flow_cap >= flow_cap_min * purchased_units` (flow_capacity_min_purchase_milp)

I do not see why this matters? `flow_cap >= flow_cap_min` is enforced in the bounds of [`flow_cap`](https://calliope.readthedocs.io/en/latest/math/base/#flow_cap), and `flow_cap <= flow_cap_max * purchased_units`, therefore `flow_cap_max * purchased_units >= flow_cap_min`. If `flow_cap_min > 0`, then `purchased_units` cannot go to 0.

_(yes, the RHS in `flow_capacity_min_purchase_milp` could go to 0, but since the bound is there, the bound would be binding instead)_
@sstroemer just had a look at the `flow_cap_min * purchased_units` issue again and you're quite right - good spot! I guess this means there needs to be a distinct constraint to set `flow_cap_min`, i.e. it is not set at the variable level at all. Same with `storage_cap_min`.

Fix patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b58be7ca..500cc35f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,8 @@
 
 ### User-facing changes
 
+|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 `<v0.7.dev4`, the user must set the new `init` configuration option `broadcast_param_data` to True (#615).
 
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]


Test patch:
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 35aed4e7..174f40cc 100644
--- a/tests/test_math.py
+++ b/tests/test_math.py
@@ -49,32 +49,54 @@ class TestBaseMath:
     def base_math(self):
         return AttrDict.from_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")
@@ -86,13 +108,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}