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

# `tools/notation.py` writes this page's block, and it is the same trap: the
Expand Down
169 changes: 169 additions & 0 deletions docs/examples/commitment.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
<!--
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 proved to
**partition** `foreach` before any data binds, which is what makes the quantity
a quantity: exactly one arm applies at every coordinate, so `ramp_up` can use
it the way it uses a parameter. A gap or an overlap is a load error naming a
witness for it.

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. 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: "committable and position(snapshot) == 0"
expression: status_initial
interior:
when: "committable and position(snapshot) > 0"
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. 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{committable}_{g} \wedge \mathrm{pos}(t) = 0 \cr \mathit{status}_{t - 1,g} & \text{if } \mathrm{committable}_{g} \wedge \mathrm{pos}(t) > 0 \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.

Expand Down
78 changes: 78 additions & 0 deletions docs/reference/language/expressions.md
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,84 @@ 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 are one concept with a different value in each regime: the
level a unit carries into a snapshot, the capacity a limit is measured against.
Writing that at the constraint multiplies — three independent regimes become
eight near-identical constraints, and the equation is written eight times.
Writing it here adds:

```yaml
dimensions:
snapshot: { dtype: int }
generator: { dtype: str }
parameters:
committable: { dims: [generator], dtype: bool }
status_initial: { dims: [generator] }
variables:
status: { foreach: [snapshot, generator] }
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: "committable and position(snapshot) == 0"
expression: status_initial
interior:
when: "committable and position(snapshot) > 0"
expression: shift(status, over=snapshot, offset=1)
constraints:
no_restart:
foreach: [snapshot, generator]
expression: status - previous_status <= 1
```

**`when:`, not `where:`.** A case says which value a coordinate takes. It
creates no absence and deletes no row, which is what `where` means on every
other block ([absence](absence.md)) — and a cased expression has no `where` of
its own, so the word would be free to mislead.

**The cases must partition the `foreach`**, and it is a load error when they do
not — checked before any data binds, with the overlap or the gap named:

> the cases do not partition `['generator', 'snapshot']` — no case claims the
> value where `committable` is true, the position of `snapshot` is 1

Two obligations sit behind that. **Disjoint**, because two values at one
coordinate is not a quantity. **Total**, because a gap would leave the
expression undefined there, and absence
[spreads](absence.md#how-absence-travels) — every constraint referencing it
would quietly lose rows it never masked, which is the one thing a mask on a
constraint is supposed to tell you. Totality is what keeps a constraint's rows
readable at the constraint.

Being total is not free: with no mask to narrow the frame, the cases have to
say what an absent parameter or an unnamed label gets. `not capacity` and
`not (kind == 'battery' or kind == 'h2')` are cases like any other.

**`foreach:` is required here and refused elsewhere.** An uncased expression's
dims fall out of its body; a cased one's cannot, because no single case gives
them — `always_on` above is a scalar while its `when` is not. Each case's value
and each `when` must sit **inside** that frame; neither may widen it.

[`examples/commitment.yaml`](../../examples/commitment.md) is the whole model
this comes from, beside the math it prints.

**A reference names the quantity; the block prints once.** A cased expression
is the one kind that does not read well inlined — three arms are three rows
tall, so whatever follows in the equation sits beside the middle one. So a use
prints the symbol,

$$\mathit{status}_{t,g} - \mathit{previous\_status}_{t,g} \le 1 \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$

and the block prints under **Definitions**, which is where a paper states a
quantity defined by region:

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

## Macros

A **parameterised** template. It has no dims until it is called, and each call
Expand Down
64 changes: 64 additions & 0 deletions docs/reference/notation.md
Original file line number Diff line number Diff line change
Expand Up @@ -428,6 +428,30 @@ efficiency:

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

#### `started`

a named expression with cases, substituted where its name stands

```yaml
started:
foreach: [snapshot, generator]
expression: slack >= on * startup_cost
```

$$\mathit{slack}_{t} \ge \mathit{on}_{t,g} \cdot \mathrm{startup\_cost}_{t,g} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$

#### `committed`

the same, for the cased expression the solver decides

```yaml
committed:
foreach: [snapshot, generator]
expression: committed_power <= p_max
```

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

#### `always`

a mask that is only the constant true, which the language says is no mask at all — so none prints
Expand Down Expand Up @@ -467,6 +491,46 @@ never:

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

### Definitions

A named expression is substituted where its name is used, so it normally prints nothing under its own name. A cased one is the exception: its value is defined by region, which is a definition of its own, and the equations using it name it rather than repeating the block.

#### `startup_cost`

a value defined by region: the cases partition the frame, so exactly one arm applies at every coordinate

```yaml
startup_cost:
foreach: [snapshot, generator]
cases:
opening:
when: "position(snapshot) == 0"
expression: cost * p_max
later:
when: "position(snapshot) != 0"
expression: cost
```

$$\mathrm{startup\_cost}_{t,g} = \begin{cases} \mathrm{cost}_{g} \cdot \mathrm{p}^{\mathrm{max}}_{g} & \text{if } \mathrm{pos}(t) = 0 \cr \mathrm{cost}_{g} & \text{if } \mathrm{pos}(t) \neq 0 \end{cases} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$

#### `committed_power`

the other side of the convention: an arm reaches a variable, so the quantity is one the solver decides

```yaml
committed_power:
foreach: [snapshot, generator]
cases:
running:
when: "on"
expression: p
idle:
when: "NOT on"
expression: 0
```

$$\mathit{committed\_power}_{t,g} = \begin{cases} p_{t,g} & \text{if } \mathit{on}_{t,g} \text{ exists} \cr 0 & \text{if } \neg \mathit{on}_{t,g} \text{ exists} \end{cases} \qquad \forall\thinspace t \in \mathcal{T},\enspace g \in \mathcal{G}$$

### Variable domains

#### `p`
Expand Down
Loading
Loading