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
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'] — every stray dim multiplies the rows this constraint builds; add it to dims: if that is intended, or sum it 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.
Next¶
In the next tutorial you attach the same model to data from files.