Your first model¶
Solve three generators that meet a load at least cost. The next four tutorials grow this same model.
Install¶
This also installs polars, the HiGHS solver and mathspec, the language a model is written in.
The model¶
A model is one YAML file, called a spec. Save this one as dispatch.yaml:
description: Least-cost dispatch of a generator fleet against an hourly load.
dimensions:
snapshot:
description: dispatch periods
dtype: int
ordered: true
generator:
description: generating units
parameters:
p_max:
description: installed capacity
dims: [generator]
load:
description: demand to be met
dims: [snapshot]
cost:
description: marginal cost
dims: [generator]
variables:
p:
description: output of a generator in a snapshot
dims: [snapshot, generator]
where: "p_max > 0"
bounds:
lower: 0
upper: p_max
constraints:
power_balance:
dims: [snapshot]
expression: sum(p, over=generator) == load
objective:
sense: minimize
description: total cost of generation over the horizon
expression: sum(p * cost)
The file holds no numbers, only five parts:
dimensionsare the sets the model runs over: four snapshots (hours) and the generators. Dimensions →parametersare the data you attach later. Parameters →variablesare what the solver decides: the outputpof each generator in each snapshot. Variables →constraintsare the rules: in each snapshot, the generators meet the load. Constraints →objectiveis what the solver minimises: the cost of the output. Objective →
Check the file¶
check tells you whether the file is a model.
It needs no data:
The check passes, so the block prints nothing. Now change the line of
power_balance to expression: p == load, and check again. The message says
how to fix it:
DimensionError: Constraint 'power_balance': the expression carries dims ['generator'] that are not in its dims: ['snapshot']. Add them to dims:, or sum them out.
The sum over generator fits p to one row per snapshot. Put it back.
Attach the data¶
The numbers come in a dict called sources, one entry for each name
the file declares. A parameter is a table with its dimensions and a value
column. A dimension is a list of labels:
import polars as pl
generators = ['wind', 'solar', 'gas']
sources = {
'snapshot': range(4),
'generator': generators,
'p_max': pl.DataFrame({'generator': generators, 'value': [80.0, 0.0, 200.0]}),
'cost': pl.DataFrame({'generator': generators, 'value': [10.0, 25.0, 50.0]}),
'load': pl.DataFrame({'snapshot': range(4), 'value': [60.0, 120.0, 180.0, 90.0]}),
}
Wind is cheap but small, gas large but expensive, and solar has no capacity. The data contract lists every form a source can take.
Solve¶
solve attaches the data, builds the model
and hands it to HiGHS:
The load is met for 10500.
Read the answer¶
primal gives the value of a variable as a polars table:
shape: (8, 3)
┌──────────┬───────────┬───────┐
│ snapshot ┆ generator ┆ value │
│ --- ┆ --- ┆ --- │
│ i64 ┆ str ┆ f64 │
╞══════════╪═══════════╪═══════╡
│ 0 ┆ wind ┆ 60.0 │
│ 0 ┆ gas ┆ 0.0 │
│ 1 ┆ wind ┆ 80.0 │
│ 1 ┆ gas ┆ 40.0 │
│ 2 ┆ wind ┆ 80.0 │
│ 2 ┆ gas ┆ 100.0 │
│ 3 ┆ wind ┆ 80.0 │
│ 3 ┆ gas ┆ 10.0 │
└──────────┴───────────┴───────┘
Wind runs first because it is cheapest, and gas covers the rest. Solar has no
rows: where: "p_max > 0" left it out.
dual gives the price of a constraint. For power_balance, that is the cost
of one more unit of load:
In snapshot 0, wind alone meets the load, so one more unit costs 10. In the other snapshots gas is the last generator on, and one more unit costs 50. What a change is worth reads the other prices an answer holds.
Next¶
In the next tutorial you attach the same model to data from files.