Skip to content

A storage unit over time#

In this tutorial you build a specification of an optimisation model (a spec) for a battery. The battery buys power when it is cheap and sells it when it is expensive. On the way you declare a set of labels (a dimension) in a fixed order. You then link each hour to the hour before it. You also leave out every battery that has no capacity. This tutorial takes about 20 minutes.

Do your first spec first.

Dimensions#

The spec has two dimensions: the hours of a day, and the batteries. Make a file storage.yaml:

storage.yaml
description: A battery that buys power when it is cheap and sells it when it is expensive.

dimensions:
  snapshot: { dtype: int, description: hours of the day }
  store: { description: batteries }

Check the file:

python -m mathspec check storage.yaml
import mathspec as ms

for note in ms.advice('storage.yaml'):
    print(note)
dimension 'snapshot' is never used: nothing is indexed by it, nothing sums into it, and no relation has a column over it. Remove it, or keep it if the entries that use it are still to be written.
dimension 'store' is never used: nothing is indexed by it, nothing sums into it, and no relation has a column over it. Remove it, or keep it if the entries that use it are still to be written.

mathspec accepts the file. check prints one remark that is not a refusal (a line of advice) for each dimension. No named parameter, variable or constraint (an entry) uses either dimension yet.

Keep this Python session open. Later steps use ms.

Parameters, variables and the objective#

Each battery has an energy capacity and a power rate, and the price of power changes from hour to hour. These three quantities are data the spec requires (parameters). A battery also has three solver decisions (variables). They are the power it takes in, the power it gives out, and the energy it holds after each hour (its state of charge).

The quantity the solver optimises (the objective) is what the batteries pay for power less what they earn from it. Add the parameters, the variables and the objective:

parameters:
  capacity: { dims: [store], description: energy a battery holds when full }
  rate: { dims: [store], description: most power a battery takes in or gives out }
  price: { dims: [snapshot], description: price of power }

variables:
  charge:
    description: power a battery takes in
    dims: [snapshot, store]
    bounds: { lower: 0, upper: rate }
  discharge:
    description: power a battery gives out
    dims: [snapshot, store]
    bounds: { lower: 0, upper: rate }
  soc:
    description: energy a battery holds at the end of an hour
    dims: [snapshot, store]
    bounds: { lower: 0, upper: capacity }

objective:
  sense: minimize
  expression: sum((charge - discharge) * price)

Check the file:

python -m mathspec check storage.yaml
for note in ms.advice('storage.yaml'):
    print(note)

check prints nothing. No rule links the variables yet, so with data attached, a solver could let a battery sell more power than the battery bought.

The state of charge#

The energy balance links each hour of a battery to the hour before. A battery ends each hour with the energy it had an hour before, plus what it took in, less what it gave out. shift takes the value of an expression at another position along a dimension. So shift(soc, along=snapshot, offset=1) is soc one hour earlier. Add the energy balance as a rule the decisions must satisfy (a constraint):

constraints:
  energy_balance:
    dims: [snapshot, store]
    expression: soc == shift(soc, along=snapshot, offset=1) + charge - discharge

Check the file:

python -m mathspec check storage.yaml
for note in ms.advice('storage.yaml'):
    print(note)
Constraint 'energy_balance': shift(along=snapshot) reads the order of 'snapshot', which is not declared ordered, so the order would come from the row order of the data. Declare 'snapshot: {ordered: true}' under dimensions:.

When mathspec first reads the file (at load), it refuses the file. The command exits with status 1, and advice raises SchemaError. shift counts positions along snapshot, and the file does not declare snapshot as ordered.

Declaring the order#

An ordered dimension is one whose order shift may use. Add ordered: true to snapshot:

dimensions:
  snapshot: { dtype: int, ordered: true, description: hours of the day }
  store: { description: batteries }

Check the file:

python -m mathspec check storage.yaml
for note in ms.advice('storage.yaml'):
    print(note)

check prints nothing. With data attached, the first hour has no hour before it, so the row of energy_balance there does not exist (it is absent).

Closing the day on itself#

The edge= argument of shift sets the value of shift at the first hour. With edge='wrap', at the first hour the shift takes soc at the last hour. Each battery then ends the day with the energy it started with. Change the constraint:

constraints:
  energy_balance:
    dims: [snapshot, store]
    expression: soc == shift(soc, along=snapshot, offset=1, edge='wrap') + charge - discharge

Print the math, without the tables of symbols:

python -m mathspec markdown --no-legend storage.yaml
print(ms.to_markdown('storage.yaml', legend=False))

Rendered output

A battery that buys power when it is cheap and sells it when it is expensive.

Variable domains#

charge

\[ 0 \le \mathit{charge}_{t,s} \le \mathrm{rate}_{s} \qquad \forall\, t \in \mathcal{T},\ s \in \mathcal{S} \]

discharge

\[ 0 \le \mathit{discharge}_{t,s} \le \mathrm{rate}_{s} \qquad \forall\, t \in \mathcal{T},\ s \in \mathcal{S} \]

soc

\[ 0 \le \mathit{soc}_{t,s} \le \mathrm{capacity}_{s} \qquad \forall\, t \in \mathcal{T},\ s \in \mathcal{S} \]

Objective#

\[ \min \sum_{t \in \mathcal{T},\ s \in \mathcal{S}} \left( \mathit{charge}_{t,s} - \mathit{discharge}_{t,s} \right) \cdot \mathrm{price}_{t} \]

Subject to#

energy_balance

\[ \mathit{soc}_{t,s} = \mathit{soc}_{t \ominus 1,s} + \mathit{charge}_{t,s} - \mathit{discharge}_{t,s} \qquad \forall\, t \in \mathcal{T},\ s \in \mathcal{S} \]

The constraint contains \(\mathit{soc}_{t \ominus 1,s}\), the state of charge at the hour before \(t\), counted around the day. With data attached, every hour now has a row.

Leaving out a battery with no capacity#

Where its condition is false, a where: line leaves out the rows of an entry. Add where: "capacity > 0" to each variable:

variables:
  charge:
    description: power a battery takes in
    dims: [snapshot, store]
    where: "capacity > 0"
    bounds: { lower: 0, upper: rate }
  discharge:
    description: power a battery gives out
    dims: [snapshot, store]
    where: "capacity > 0"
    bounds: { lower: 0, upper: rate }
  soc:
    description: energy a battery holds at the end of an hour
    dims: [snapshot, store]
    where: "capacity > 0"
    bounds: { lower: 0, upper: capacity }

Check the file:

python -m mathspec check storage.yaml
for note in ms.advice('storage.yaml'):
    print(note)

check prints nothing. With data attached, a battery with no capacity has no charge, discharge or soc.

The math#

The whole file is now:

storage.yaml
description: A battery that buys power when it is cheap and sells it when it is expensive.

dimensions:
  snapshot: { dtype: int, ordered: true, description: hours of the day }
  store: { description: batteries }

parameters:
  capacity: { dims: [store], description: energy a battery holds when full }
  rate: { dims: [store], description: most power a battery takes in or gives out }
  price: { dims: [snapshot], description: price of power }

variables:
  charge:
    description: power a battery takes in
    dims: [snapshot, store]
    where: "capacity > 0"
    bounds: { lower: 0, upper: rate }
  discharge:
    description: power a battery gives out
    dims: [snapshot, store]
    where: "capacity > 0"
    bounds: { lower: 0, upper: rate }
  soc:
    description: energy a battery holds at the end of an hour
    dims: [snapshot, store]
    where: "capacity > 0"
    bounds: { lower: 0, upper: capacity }

constraints:
  energy_balance:
    dims: [snapshot, store]
    expression: soc == shift(soc, along=snapshot, offset=1, edge='wrap') + charge - discharge

objective:
  sense: minimize
  expression: sum((charge - discharge) * price)

Print the whole spec:

python -m mathspec markdown storage.yaml
print(ms.to_markdown('storage.yaml'))

Rendered output

A battery that buys power when it is cheap and sells it when it is expensive.

Sets#

Symbol Meaning
\(\mathcal{T}\) index \(t\) — snapshot — hours of the day
\(\mathcal{S}\) index \(s\) — store — batteries

Parameters#

Symbol Meaning
\(\mathrm{capacity}\) capacity over \(\mathcal{S}\) — energy a battery holds when full
\(\mathrm{price}\) price over \(\mathcal{T}\) — price of power
\(\mathrm{rate}\) rate over \(\mathcal{S}\) — most power a battery takes in or gives out

Variables#

Symbol Meaning
\(\mathit{charge}\) charge over \(\mathcal{T} \times \mathcal{S}\) — power a battery takes in
\(\mathit{discharge}\) discharge over \(\mathcal{T} \times \mathcal{S}\) — power a battery gives out
\(\mathit{soc}\) soc over \(\mathcal{T} \times \mathcal{S}\) — energy a battery holds at the end of an hour

Notation#

Symbol Meaning
\(t \ominus k\) edge='wrap' — \(t-k\), cyclic

Variable domains#

charge

\[ 0 \le \mathit{charge}_{t,s} \le \mathrm{rate}_{s} \qquad \forall\, t \in \mathcal{T},\ s \in \mathcal{S} \,:\, \mathrm{capacity}_{s} > 0 \]

discharge

\[ 0 \le \mathit{discharge}_{t,s} \le \mathrm{rate}_{s} \qquad \forall\, t \in \mathcal{T},\ s \in \mathcal{S} \,:\, \mathrm{capacity}_{s} > 0 \]

soc

\[ 0 \le \mathit{soc}_{t,s} \le \mathrm{capacity}_{s} \qquad \forall\, t \in \mathcal{T},\ s \in \mathcal{S} \,:\, \mathrm{capacity}_{s} > 0 \]

Objective#

\[ \min \sum_{t \in \mathcal{T},\ s \in \mathcal{S}} \left( \mathit{charge}_{t,s} - \mathit{discharge}_{t,s} \right) \cdot \mathrm{price}_{t} \]

Subject to#

energy_balance

\[ \mathit{soc}_{t,s} = \mathit{soc}_{t \ominus 1,s} + \mathit{charge}_{t,s} - \mathit{discharge}_{t,s} \qquad \forall\, t \in \mathcal{T},\ s \in \mathcal{S} \]

The notation table lists the meaning of \(t \ominus k\).

mathspec prints the condition on each variable's where: line, \(\mathrm{capacity}_{s} > 0\), at the end of the variable's bounds. energy_balance has no condition of its own, so mathspec prints none for it. With data attached, the rows of energy_balance are absent wherever its variables are.

What you built#

You have built the spec of a battery that trades against an hourly price. The spec's hours are in a fixed order, and energy_balance uses shift to link the state of charge to the hour before. The first hour takes the state of charge of the last. With data attached, a battery with no capacity has no variables and no rows.

Where to next#

See also

  • The shift reference, for the other values of edge=, an offset read from a parameter, and a shift within groups
  • Absence and where, for every way a row becomes absent, and how absence spreads
  • Unit commitment, for start-up and ramping limits written with shift