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
  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'] — 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:

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.

Next

In the next tutorial you attach the same model to data from files.