Skip to content

Your first model

Solve three generators that meet a load at least cost. The next four tutorials grow this same model.

Install

pip install specsolve

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:

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:

  • dimensions are the sets the model runs over: four snapshots (hours) and the generators. Dimensions →
  • parameters are the data you attach later. Parameters →
  • variables are what the solver decides: the output p of each generator in each snapshot. Variables →
  • constraints are the rules: in each snapshot, the generators meet the load. Constraints →
  • objective is 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:

import specsolve as sps

program = sps.check('dispatch.yaml')

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:

result = sps.solve('dispatch.yaml', sources)
print(result.objective)
10500.0

The load is met for 10500.

Read the answer

primal gives the value of a variable as a polars table:

print(result.primal('p'))
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:

print(result.dual('power_balance'))
shape: (4, 2)
┌──────────┬───────┐
│ snapshot ┆ value │
│ ---      ┆ ---   │
│ i64      ┆ f64   │
╞══════════╪═══════╡
│ 0        ┆ 10.0  │
│ 1        ┆ 50.0  │
│ 2        ┆ 50.0  │
│ 3        ┆ 50.0  │
└──────────┴───────┘

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.