Skip to content

Sweep a model

One call, solve_over, solves a model once per slice of its data and reads the answers back as one table. This page runs it once per scenario, and then window by window with state carried between windows.

Every block on this page runs when the site is built, and what you see under it is what it printed on this commit. A block that raises fails the build.

1. One solve per scenario

The dispatch model of Run a model, with two load levels. The load table carries a scenario column, which the spec does not declare. EachCoordinate('scenario') solves the model once per label of that column:

import polars as pl

import specsolve as sps

GENERATORS = ['wind', 'solar', 'gas']
LOW = [60.0, 110.0, 170.0, 90.0]

sources = {
    'snapshot': range(4),
    'generator': GENERATORS,
    'p_max': pl.DataFrame({'generator': GENERATORS, 'value': [80.0, 40.0, 200.0]}),
    'cost': pl.DataFrame({'generator': GENERATORS, 'value': [10.0, 25.0, 50.0]}),
    'load': pl.DataFrame(
        {
            'scenario': ['low'] * 4 + ['high'] * 4,
            'snapshot': [0, 1, 2, 3] * 2,
            'value': LOW + [1.5 * v for v in LOW],
        }
    ),
}

sweep = sps.solve_over('examples/dispatch.yaml', sources, sps.EachCoordinate('scenario'))

print(sweep.record.select('scenario', 'termination_condition', 'objective'))
shape: (2, 3)
┌──────────┬───────────────────────┬───────────┐
│ scenario ┆ termination_condition ┆ objective │
│ ---      ┆ ---                   ┆ ---       │
│ str      ┆ str                   ┆ f64       │
╞══════════╪═══════════════════════╪═══════════╡
│ high     ┆ optimal               ┆ 16200.0   │
│ low      ┆ optimal               ┆ 7500.0    │
└──────────┴───────────────────────┴───────────┘

sweep.record has one row per scenario. The readers of a result read a sweep too, with the scenario column in front:

print(sweep.primal('p').pivot(on='generator', index=['scenario', 'snapshot'], values='value'))
shape: (8, 5)
┌──────────┬──────────┬──────┬───────┬───────┐
│ scenario ┆ snapshot ┆ wind ┆ solar ┆ gas   │
│ ---      ┆ ---      ┆ ---  ┆ ---   ┆ ---   │
│ str      ┆ i64      ┆ f64  ┆ f64   ┆ f64   │
╞══════════╪══════════╪══════╪═══════╪═══════╡
│ high     ┆ 0        ┆ 80.0 ┆ 10.0  ┆ 0.0   │
│ high     ┆ 1        ┆ 80.0 ┆ 40.0  ┆ 45.0  │
│ high     ┆ 2        ┆ 80.0 ┆ 40.0  ┆ 135.0 │
│ high     ┆ 3        ┆ 80.0 ┆ 40.0  ┆ 15.0  │
│ low      ┆ 0        ┆ 60.0 ┆ 0.0   ┆ 0.0   │
│ low      ┆ 1        ┆ 80.0 ┆ 30.0  ┆ 0.0   │
│ low      ┆ 2        ┆ 80.0 ┆ 40.0  ┆ 50.0  │
│ low      ┆ 3        ┆ 80.0 ┆ 10.0  ┆ 0.0   │
└──────────┴──────────┴──────┴───────┴───────┘

Gas covers what wind and solar cannot, and the high scenario needs it at three snapshots of four.

2. Window by window

A rolling horizon solves consecutive windows of a time dimension, and hands the state of a store from one window to the next. The data below covers eight hours. Wind blows in the first two hours of every four, and the load is higher when it does not.

HOURS = list(range(8))
WIND = [70.0, 70.0, 0.0, 0.0] * 2
LOAD = [25.0, 25.0, 85.0, 85.0] * 2

hourly = {
    'generator': ['wind', 'gas'],
    'cost': pl.DataFrame({'generator': ['wind', 'gas'], 'value': [0.0, 40.0]}),
    'p_max': pl.DataFrame({'hour': HOURS * 2, 'generator': ['wind'] * 8 + ['gas'] * 8, 'value': WIND + [200.0] * 8}),
    'load': pl.DataFrame({'hour': HOURS, 'value': LOAD}),
    'soc_initial': 0.0,
}

EachWindow cuts hour into windows and numbers each one from zero, into the dimension that into names. Before it cuts, it asks the model how its rows couple along that dimension. examples/storage.yaml closes its store into a cycle, so its first snapshot reads its last. solve_over refuses it before a window is solved:

try:
    sps.solve_over('examples/storage.yaml', hourly, sps.EachWindow('hour', steps=3, lookahead=3, into='snapshot'))
except sps.SpecsolveError as exc:
    print(exc)
EachWindow('hour', …, into='snapshot') slices 'snapshot', which the model ties together, so no window holds every row whole:
  constraint 'soc_balance': wraps around snapshot, so its first row reads its last — an opening-state seed at position(snapshot) == 0 is what a rolling horizon replaces the wrap with
Each names the change that would lift it.

examples/rolling/horizon.yaml is a store written for one window, over a dimension t. Its first row starts from the parameter soc_initial instead of the last row. Each window below keeps three hours and sees three more. carry copies the last level each window keeps into the soc_initial of the next window. The first window takes soc_initial from the sources:

rolling = sps.solve_over(
    'examples/rolling/horizon.yaml',
    hourly,
    sps.EachWindow('hour', steps=3, lookahead=3, into='t'),
    carry={'soc_initial': 'soc'},
)

print(rolling.record.select('hour_start', 'termination_condition', 'objective'))
shape: (3, 3)
┌────────────┬───────────────────────┬───────────┐
│ hour_start ┆ termination_condition ┆ objective │
│ ---        ┆ ---                   ┆ ---       │
│ i64        ┆ str                   ┆ f64       │
╞════════════╪═══════════════════════╪═══════════╡
│ 0          ┆ optimal               ┆ 5000.0    │
│ 3          ┆ optimal               ┆ 7600.0    │
│ 6          ┆ optimal               ┆ 4800.0    │
└────────────┴───────────────────────┴───────────┘

Each window is keyed by the hour it starts at. original_index=True keeps the hours each window owns, and reads them back over hour as one schedule:

print(rolling.primal('soc', original_index=True))
shape: (8, 2)
┌──────┬───────┐
│ hour ┆ value │
│ ---  ┆ ---   │
│ i64  ┆ f64   │
╞══════╪═══════╡
│ 0    ┆ 22.5  │
│ 1    ┆ 45.0  │
│ 2    ┆ 20.0  │
│ 3    ┆ 5.0   │
│ 4    ┆ 27.5  │
│ 5    ┆ 50.0  │
│ 6    ┆ 25.0  │
│ 7    ┆ 0.0   │
└──────┴───────┘

The store charges in the windy hours and discharges in the calm ones. The window that starts at hour 3 opens from the level the first window left at hour 2.

3. Myopic pathways

A myopic pathway is the same call over investment periods: EachCoordinate('year') with carry={'existing': 'total'} hands each period the fleet the one before it left. examples/myopic/run.py runs one.

Where next

Sweeps and rolling horizons every axis, carry, keep and spill_to=, and how a sweep is read
Running a sweep in parallel one slice per worker
Archiving a solve the spec, its data and every slice kept as one archive