Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ CHANGELOG.md
# Same rule as CHANGELOG.md above: the generator wins where nobody edits by
# hand. `index.md` is not listed — it carries no generated block.
docs/examples/dispatch.md
docs/examples/commitment.md
docs/examples/operators.md
docs/examples/pypsa.md
docs/examples/pypsa_quadratic.md
Expand Down
168 changes: 168 additions & 0 deletions docs/examples/commitment.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
<!--
SPDX-FileCopyrightText: math-spec contributors
SPDX-License-Identifier: CC-BY-4.0
-->

# Unit commitment

A dispatch model with a commitment decision and a start-up ramp — the
formulation [`cases:`](../reference/language/expressions.md#cases--one-quantity-a-value-per-region)
exists for.

Read `previous_status` and then `ramp_up`. The cases are read **in order**, and
the last one carries no `when:`. So the value at a coordinate is the first arm
whose condition holds there, and the fallback covers every coordinate the others
leave. One value at every coordinate — never two, never none — is what lets
`ramp_up` use the quantity the way it uses a parameter.

It prints the way a paper writes it: `ramp_up` names the quantity, and the
block itself prints once below, under **Definitions**.

<!-- gallery:begin -->
```yaml
description: >-
Unit commitment with a start-up ramp, the formulation `cases:` exists for.
The state a unit carries into a snapshot has three regimes — a unit that is
never off, the first snapshot, and every later one — and writing them at the
constraint would fork `ramp_up` three ways. With the regimes named once, the
inequality is written once.

dimensions:
snapshot: { dtype: int, description: dispatch periods }
generator: { values: [nuclear, gas, oil], description: generating units }

parameters:
committable: { dims: [generator], dtype: bool, description: whether the unit may be switched off }
status_initial: { dims: [generator], description: whether the unit was running before the horizon }
p_max: { dims: [generator], description: installed capacity }
p_min: { dims: [generator], description: output floor while running }
ramp_limit: { dims: [generator], description: how far output may move between snapshots while running }
start_up_limit: { dims: [generator], description: how far it may move in the snapshot it starts in }
load: { dims: [snapshot], description: demand to be met }
cost: { dims: [generator], description: marginal cost }

variables:
p:
description: output of a generator in a snapshot
foreach: [snapshot, generator]
bounds: { lower: 0, upper: p_max }
status:
description: whether the unit is running in a snapshot
foreach: [snapshot, generator]
domain: binary

expressions:
previous_status:
description: the commitment state a unit carries into a snapshot
foreach: [snapshot, generator]
cases:
always_on:
when: "not committable"
expression: 1
boundary:
when: "position(snapshot) == 0"
expression: status_initial
interior:
expression: shift(status, over=snapshot, offset=1)

constraints:
power_balance:
foreach: [snapshot]
expression: sum(p, over=generator) == load
upper:
description: a unit that is not running produces nothing
foreach: [snapshot, generator]
expression: p <= status * p_max
lower:
description: and one that is running produces at least its floor
foreach: [snapshot, generator]
expression: p >= status * p_min
ramp_up:
description: >-
one inequality for both regimes — a unit already running is held to
`ramp_limit`, a unit starting up to `start_up_limit`.
foreach: [snapshot, generator]
expression: >-
p - shift(p, over=snapshot, offset=1, edge=0)
<= ramp_limit * previous_status + start_up_limit * (1 - previous_status)

objective:
sense: minimize
expression: sum(p * cost)
```

Unit commitment with a start-up ramp, the formulation `cases:` exists for. The state a unit carries into a snapshot has three regimes — a unit that is never off, the first snapshot, and every later one — and writing them at the constraint would fork `ramp_up` three ways. With the regimes named once, the inequality is written once.

#### Sets

| Symbol | Meaning |
|---|---|
| $\mathcal{T}$ | index $t$ — `snapshot` — dispatch periods |
| $\mathcal{G}$ | index $g$ — `generator` — generating units |

#### Parameters

| Symbol | Meaning |
|---|---|
| $\mathrm{committable}$ | `committable` over $\mathcal{G}$ — whether the unit may be switched off |
| $\mathrm{status}^{\mathrm{initial}}$ | `status_initial` over $\mathcal{G}$ — whether the unit was running before the horizon |
| $\mathrm{p}^{\mathrm{max}}$ | `p_max` over $\mathcal{G}$ — installed capacity |
| $\mathrm{p}^{\mathrm{min}}$ | `p_min` over $\mathcal{G}$ — output floor while running |
| $\mathrm{ramp\_limit}$ | `ramp_limit` over $\mathcal{G}$ — how far output may move between snapshots while running |
| $\mathrm{start\_up\_limit}$ | `start_up_limit` over $\mathcal{G}$ — how far it may move in the snapshot it starts in |
| $\mathrm{load}$ | `load` over $\mathcal{T}$ — demand to be met |
| $\mathrm{cost}$ | `cost` over $\mathcal{G}$ — marginal cost |

#### Variables

| Symbol | Meaning |
|---|---|
| $p$ | `p` over $\mathcal{T} \times \mathcal{G}$ — output of a generator in a snapshot |
| $\mathit{status}$ | `status` over $\mathcal{T} \times \mathcal{G}$ — whether the unit is running in a snapshot |

Upright is what the model is given — a parameter such as $\mathrm{committable}$, a coordinate map, a label — and italic is what the solver chooses, such as $p$. An index is italic too, being what a quantifier chooses, and a set is script.

$t \boxminus_{v} k$ denotes translation with $v$ standing where index $t-k$ leaves the dimension (`shift(edge=v)`), so the row at that boundary is built and carries $v$ rather than being dropped.

$\mathrm{pos}(t)$ denotes where index $t$ sits along its dimension's own order — the order `shift` walks, not the order labels sort in — counted from $0$. The index itself stays the coordinate, so $t$ compares against labels and $\mathrm{pos}(t)$ against positions.

#### Objective

$$\min \sum_{t \in \mathcal{T},\enspace g \in \mathcal{G}} p_{t,g} \cdot \mathrm{cost}_{g}$$

#### Subject to

**`power_balance`**

$$\sum_{g \in \mathcal{G}} p_{t,g} = \mathrm{load}_{t} \qquad \forall\thinspace t \in \mathcal{T}$$

**`upper`**

$$p_{t,g} \le \mathit{status}_{t,g} \cdot \mathrm{p}^{\mathrm{max}}_{g} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$

**`lower`**

$$p_{t,g} \ge \mathit{status}_{t,g} \cdot \mathrm{p}^{\mathrm{min}}_{g} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$

**`ramp_up`**

$$p_{t,g} - p_{t \boxminus_{0} 1,g} \le \mathrm{ramp\_limit}_{g} \cdot \mathit{previous\_status}_{t,g} + \mathrm{start\_up\_limit}_{g} \cdot \left( 1 - \mathit{previous\_status}_{t,g} \right) \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$

#### Definitions

**`previous_status`**

$$\mathit{previous\_status}_{t,g} = \begin{cases} 1 & \text{if } \neg \mathrm{committable}_{g} \cr \mathrm{status}^{\mathrm{initial}}_{g} & \text{if } \mathrm{pos}(t) = 0 \cr \mathit{status}_{t - 1,g} & \text{otherwise} \end{cases} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$

#### Variable domains

**`p`**

$$0 \le p_{t,g} \le \mathrm{p}^{\mathrm{max}}_{g} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$

**`status`**

$$\mathit{status}_{t,g} \in \{0, 1\} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$
<!-- gallery:end -->

Regenerate with `pixi run python -m tools.gallery`.
2 changes: 2 additions & 0 deletions docs/examples/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ printing different math — fails CI rather than going stale here.

- [Least-cost dispatch](dispatch.md) — the smallest model that is a model: a
balance, a bound, and a cost to minimise.
- [Unit commitment](commitment.md) — a start-up ramp, and the quantity
defined by region that lets one inequality cover both regimes.
- [One construct per model](operators.md) — the operator probes: the smallest
file that declares each built-in, beside the equation it renders.
- [PyPSA in one file](pypsa.md) — the model `n.optimize()` builds, a
Expand Down
88 changes: 88 additions & 0 deletions docs/reference/language/expressions.md
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,94 @@ anything consumes the model, so a reference costs nothing at build time. It is
lowered only when it is _read_, so a model with fifty named expressions that
reads none pays for none.

### `cases:` — one quantity, a value per region

Some quantities have no single expression. The commitment state a unit carries
into a snapshot has three regimes: `1` for a unit that is never switched off, an
initial condition at the first snapshot, and the last snapshot's status
everywhere else. Written at the constraint, those regimes fork the inequality
three ways. Named here, the inequality is written once:

```yaml
expressions:
previous_status:
description: the commitment state a unit carries into a snapshot
foreach: [snapshot, generator]
cases:
always_on:
when: "not committable"
expression: 1
boundary:
when: "position(snapshot) == 0"
expression: status_initial
interior:
expression: shift(status, over=snapshot, offset=1)
constraints:
ramp_up:
foreach: [snapshot, generator]
expression: >-
p - shift(p, over=snapshot, offset=1, edge=0)
<= ramp_limit * previous_status + start_up_limit * (1 - previous_status)
```

**The cases are read in order, and the last one is the fallback.** The value at
a coordinate is the first arm whose `when` holds there, and the last arm
carries no `when` at all. So the arms cannot disagree — only the first to match
is read — and none of them has to cover everything, because the fallback has no
condition to fail. Both guarantees come from the _shape_ of the block; nothing
has to analyse the conditions to establish them.

The fallback is required. Without it a coordinate no `when` matches would have
no value, and absence [spreads](absence.md) — a constraint reading the
expression would lose rows it never masked. The last arm has no mask to narrow
its frame, so it is the arm that has to say what an absent parameter or an
unnamed label gets.

But it picks an arm, not a value, and the arm that wins can have none itself.
`interior` above is the case in point: its `shift` carries no `edge=`, so it has
no value at the first snapshot, and `previous_status` is whole only because
`boundary` sits above it and takes that coordinate first. Close such a hole by
ordering an arm ahead of the one that drops out, by giving the `shift` an
`edge=`, or with `absence: zero` on a masked variable. Nothing catches one left
open at load, because whether an arm has a value there depends on the data.

**`foreach:` is required with cases and refused without.** An uncased
expression's dims fall out of its body. A cased one's cannot, since a case may
be a scalar while the condition selecting it is not — `always_on` above is
exactly that. The declared frame is what each `when` is held to, the way a
variable's or a constraint's mask is, and each case's value must sit inside it.

**The dims of a reference are the declared `foreach`**, not the union of the
arms: an arm narrower than the frame broadcasts, exactly as a parameter with
fewer dims does.

One `expression:` or a set of `cases:`, never both and never neither, and two
cases at least — one case is one value everywhere, which the plain form says.

### A cased expression is the one that keeps its name

Every other named expression is substituted where it is used and prints nothing
under its own name. A cased one is the exception, because it cannot inline
legibly. Three arms are three rows tall, so whatever follows the name at the use
site would sit beside the **middle** arm — and a quantity written once in the
file would print once per use on the page.

So a use prints the symbol, and the block prints once under a **Definitions**
heading between `Subject to` and `Variable domains`, in declaration order,
where a paper states a quantity defined by region:

$$\mathit{previous\_status}_{t,g} = \begin{cases} 1 & \text{if } \neg \mathrm{committable}_{g} \cr \mathrm{status}^{\mathrm{initial}}_{g} & \text{if } \mathrm{pos}(t) = 0 \cr \mathit{status}_{t - 1,g} & \text{otherwise} \end{cases} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$

A cased expression joins the symbol pool like any other quantity, so
`--symbols` can rename one. Uncased ones stay out, since a table entry for one
would never apply.

[The unit commitment example](../../examples/commitment.md) is the whole model
this section is drawn from.

**`cases:` inside a `macros:` template is not supported.** The fallback would
have to cover a frame the macro does not have until it is called.

## Macros

A **parameterised** template. It has no dims until it is called, and each call
Expand Down
29 changes: 29 additions & 0 deletions docs/reference/notation.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,18 @@ $$\max \sum_{t \in \mathcal{T},\enspace g \in \mathcal{G}} p_{t,g} \cdot \mathrm

### Constraints

#### `starts`

names the cased expression: its symbol prints here, its block once below

```yaml
starts:
foreach: [snapshot, generator]
expression: p <= startup_cost
```

$$p_{t,g} \le \mathrm{startup\_cost}_{t,g} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$

#### `balance`

sum over a lookup
Expand Down Expand Up @@ -489,6 +501,23 @@ never:

$$\mathit{slack}_{t} \ge 0 \qquad \forall\thinspace t \in \mathcal{T} \thinspace:\thinspace \bot$$

### Definitions

#### `startup_cost`

a quantity defined by region: read in order, the last arm the fallback

```yaml
startup_cost:
foreach: [snapshot, generator]
cases:
opening: { when: "position(snapshot) == 0", expression: cost }
winter: { when: "season_of == 'winter'", expression: cost * 2 }
rest: { expression: 0 }
```

$$\mathrm{startup\_cost}_{t,g} = \begin{cases} \mathrm{cost}_{g} & \text{if } \mathrm{pos}(t) = 0 \cr \mathrm{cost}_{g} \cdot 2 & \text{if } \mathrm{season\_of}(t) = \text{'}\mathrm{winter}\text{'} \cr 0 & \text{otherwise} \end{cases} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$

### Variable domains

#### `p`
Expand Down
73 changes: 73 additions & 0 deletions examples/commitment.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# SPDX-FileCopyrightText: math-spec Contributors
#
# SPDX-License-Identifier: MIT

description: >-
Unit commitment with a start-up ramp, the formulation `cases:` exists for.
The state a unit carries into a snapshot has three regimes — a unit that is
never off, the first snapshot, and every later one — and writing them at the
constraint would fork `ramp_up` three ways. With the regimes named once, the
inequality is written once.

dimensions:
snapshot: { dtype: int, description: dispatch periods }
generator: { values: [nuclear, gas, oil], description: generating units }

parameters:
committable: { dims: [generator], dtype: bool, description: whether the unit may be switched off }
status_initial: { dims: [generator], description: whether the unit was running before the horizon }
p_max: { dims: [generator], description: installed capacity }
p_min: { dims: [generator], description: output floor while running }
ramp_limit: { dims: [generator], description: how far output may move between snapshots while running }
start_up_limit: { dims: [generator], description: how far it may move in the snapshot it starts in }
load: { dims: [snapshot], description: demand to be met }
cost: { dims: [generator], description: marginal cost }

variables:
p:
description: output of a generator in a snapshot
foreach: [snapshot, generator]
bounds: { lower: 0, upper: p_max }
status:
description: whether the unit is running in a snapshot
foreach: [snapshot, generator]
domain: binary

expressions:
previous_status:
description: the commitment state a unit carries into a snapshot
foreach: [snapshot, generator]
cases:
always_on:
when: "not committable"
expression: 1
boundary:
when: "position(snapshot) == 0"
expression: status_initial
interior:
expression: shift(status, over=snapshot, offset=1)

constraints:
power_balance:
foreach: [snapshot]
expression: sum(p, over=generator) == load
upper:
description: a unit that is not running produces nothing
foreach: [snapshot, generator]
expression: p <= status * p_max
lower:
description: and one that is running produces at least its floor
foreach: [snapshot, generator]
expression: p >= status * p_min
ramp_up:
description: >-
one inequality for both regimes — a unit already running is held to
`ramp_limit`, a unit starting up to `start_up_limit`.
foreach: [snapshot, generator]
expression: >-
p - shift(p, over=snapshot, offset=1, edge=0)
<= ramp_limit * previous_status + start_up_limit * (1 - previous_status)

objective:
sense: minimize
expression: sum(p * cost)
Loading
Loading