Skip to content

Sweeps and rolling horizons

solve_over runs one model once per slice and folds the answers together. A slice is one set of sources under one key. This page is the reference for its axes, keywords and the Sweep it returns.

import specsolve as sps

sweep = sps.solve_over('spec.yaml', sources, sps.EachCoordinate('scenario'))
sweep.record  # one Record per slice, the scenario column first
sweep.primal('p')  # (scenario, snapshot, generator, value)

The axes

An axis says how the sources split into slices:

Axis Slices
EachCoordinate one per label of a dimension
EachWindow one per window of consecutive labels
a sequence of (key, sources) pairs as written. It must pass key_name=, and its answer is keyed by slice.
sweep = sps.solve_over(
    'window.yaml',
    sources,
    sps.EachWindow('snapshot', steps=24, lookahead=24, into='t', carry={'soc_initial': 'soc'}),
)
sweep.primal('soc')  # (snapshot, value) — the answer over the real labels

into has no default, and a seam's where: "t == 0" matches on it.

A datetime axis is held in microseconds, as a label is. A key finer than a microsecond is refused.

steps as a sequence is one block per window, laid end to end. lookahead is one number, whatever the blocks.

sps.EachWindow('snapshot', steps=[24, 24, 168, 168, 720], lookahead=12, into='t')
sps.EachWindow('snapshot', steps=days_in_each_month, lookahead=48, into='t')

A sequence that stops short of the axis is refused:

DataError: steps keeps 7 coordinate(s) across 2 window(s), and 'snapshot' has 12 —
the last 5 would be solved by no window. List a block for them, or pass an int to
repeat one size to the end.

axis.slices(sources) is the list the axis would run, as (key, sources) pairs:

slices = sps.EachWindow('snapshot', steps=24, lookahead=24, into='t').slices(sources)
sps.build('window.yaml', slices[37][1]).write('window-37.lp')  # the one that was infeasible

An axis is also plain data. axis.to_dict() writes it as a mapping, and solve_over takes that mapping in the axis's place. An archive's axis.json holds the same mapping. A mapping with a key the axis does not take, or without one it needs, is refused, and the message names the keys the axis takes.

axis = sps.EachWindow('snapshot', steps=24, lookahead=24, into='t', carry={'soc_initial': 'soc'})
sps.solve_over('window.yaml', sources, axis.to_dict())

Sources cross a slice in every shape build takes. A parameter or relation whose table carries the axis is filtered. Every other source passes through. An index of another dimension that carries the axis is refused. A table carrying the axis that is short of a coordinate another table has warns, naming both.

Reading a sweep

Every reader returns the answer, with the names and shapes of Result: primal, dual, evaluate, activity, reduced_cost, slack, variable_basis, constraint_basis, scan, to_pandas, to_dataarray and to_dataset. An EachWindow answer is over the real labels of the sliced dimension, each from the window that owns it. Any other answer is keyed by slice.

You name the extra dimension. EachCoordinate('scenario') keys on scenario, so sweep.to_dataarray('p') is (scenario, snapshot, generator).

A sweep holds only the outputs you ask for, such as outputs={'activity', 'reduced_cost'}. A sweep that did not ask refuses the reader. A slice with no duals has no reduced costs, and the reader says why. A spill_to= directory solved with other outputs is refused rather than resumed.

per_window=True reads each window as it was solved, lookahead rows included, on every reader:

sweep.primal('soc', per_window=True)  # (snapshot_start, t, value) — every row every window solved
sweep.to_dataarray('balance', 'dual', per_window=True)  # (snapshot_start, t)

A sweep not cut into windows refuses per_window=True:

SpecsolveError: per_window=True reads an EachWindow sweep one window at a time,
and this sweep was not cut into windows: its answer already is one frame per
slice, keyed by 'scenario'. Read it without per_window.

A quantity that is not over the windowed dimension has no answer. The reader refuses sum(p * cost), and to_dataset() leaves it out. Read it with per_window=True.

save writes a held sweep as a spill, which sps.load_sweep and sps.scan_sweep read back (load_sweep). There is no per-slice reader: one slice is sweep.primal('p').partition_by(sweep.key_name, as_dict=True).

Rule
duals are keyed, never combined A slice with an integer variable contributes no duals, and sweep.record says which.
no aggregate objective sweep.record is keyed by slice, with the objective as one column.
the lookahead is t >= step What each window owns is sweep.primal('soc', per_window=True).filter(pl.col('t') < step).
a slice that did not solve contributes no rows record still holds one row per slice, with a null objective where a slice reached none.
a window keys as <dim>_start EachWindow('snapshot', …) re-indexes to into. Per window, the key column snapshot_start holds where each window began.
key_name= names the key On any axis. A name that collides with a table column, or starts with specsolve_, is refused.
sweep.metrics says what each slice took One Metrics per slice, keyed like record.
on disk, the slice is two text columns slice_axis holds the key name and slice the key as text. Two keys of one text are refused.
a slice that fails says which slice The engine's error carries a note: in slice 'bad' (3 of 3).
memory grows with the answer, unless it is spilled Each slice's model is released; the tables accumulate unless spilled.

Spilling a sweep to disk

spill_to= writes each slice's tables to a directory, so memory stays at one slice:

sweep = sps.solve_over(
    'window.yaml', sources, sps.EachWindow('snapshot', steps=24, lookahead=24, into='t'), spill_to='runs/'
)
sweep.scan('soc')  # a LazyFrame: (snapshot, value), the answer
sweep.scan('balance', 'dual', per_window=True).collect()  # the same readers, the same keywords
Rule
scan reads lazily sweep.scan(name, kind='primal') returns a LazyFrame over the files.
one file per slice and name <kind>/<name>/<position>.parquet, plus record/ and metrics/.
an interrupted sweep resumes Run the same call at the same directory. A finished slice is read back, and a slice interrupted part way is solved again.
a directory holds one sweep A different sweep is refused. Changed data or a changed spec is not detected, so delete the directory to solve again.
the parent writes Under executor=, workers send their answers back to the parent.

Archiving a sweep

archive= writes the model, the sources, the axis and the answer, so the sweep runs again from the file alone (archiving):

sweep = sps.solve_over(
    'window.yaml',
    sources,
    sps.EachWindow('snapshot', steps=24, lookahead=24, into='t'),
    archive='roll.zip',
    keep_windows=True,
)
Rule
an archive holds the answer At answer/<kind>/<name>.parquet, as for a single solve. A name with no answer is left out, and the reader raises its reason.
keep_windows=True keeps each window's frames Without them, a per_window=True read of the archive is refused. It needs EachWindow and archive=.

Carrying state between slices

An axis's carry={parameter: variable} copies one slice's answer into the next slice's data. EachCoordinate and EachWindow take it. A hand-built list carries nothing.

sweep = sps.solve_over(
    'window.yaml',
    sources,
    sps.EachWindow('snapshot', steps=24, lookahead=24, into='t', carry={'soc_initial': 'soc'}),
)

You name no coordinate. The row handed on is the last one the slice owns: label 23 of a window keeping 24, not label 47 of the 48 it solved.

Rule
a carry is a copy, never arithmetic Accumulation (existing += built) is a derived variable in the YAML.
the carry collapses the one dimension the variable has and the parameter does not soc over (t, storage) into soc_initial over (storage) drops t. Same dimensions move the whole frame.
that dimension must be EachWindow's into Any other is refused. A carry on EachCoordinate collapses nothing.
a carried value is a boundary condition, never a pin Write soc == soc_initial + charge * 0.9 - discharge at t == 0. soc == soc_initial drops the first row's dynamics, so the window gets free energy at every seam.
the first slice needs a seed It takes the parameter from sources. A sweep whose sources lack it is refused.
a carry is checked before any source is read Two collapsed dimensions, a parameter over more dimensions than the variable, the wrong dimension and no seed all raise first. check cannot see a carry, because it is not part of the spec.
a slice that leaves nothing to carry stops the sweep The error names the slice and how it terminated. Without a carry, the sweep goes on.
carry excludes executor Slice i+1 depends on slice i, so the call is refused.

Starting slices

start= takes what solve takes, or an earlier sweep. A start is cut by the axis, as a source is.

again = sps.solve_over('dispatch.yaml', new_sources, axis, start=earlier)
cold = sps.solve_over('dispatch.yaml', sources, axis, start=None)
Rule
an earlier sweep starts each slice from the slice of its key Under an executor too.
'previous', the default, starts each slice from the one before The first slice, a slice that loads the solver again, and every slice under an executor begin from nothing.
a start is checked before a slice is built A table over an EachWindow sweep's local index alone is refused. A slice its start leaves no row warns and starts from nothing.

Running slices in parallel

executor is any concurrent.futures.Executor: a submit that returns a Future.

Use it when Notes
None (default) always, until measurement says otherwise Sequential.
ThreadPoolExecutor rarely Slices contend with polars' own thread pool, and peak memory adds up.
ProcessPoolExecutor local parallelism Must not use fork. Sources cross as parquet.
anything remote a cluster you already run dask's Client, ray, loky. Paths travel as bytes; pass workers_share_fs=True if workers mount your filesystem.

A forked worker hangs rather than raising. Running a sweep in parallel is the recipe.

Parallel is N × peak. A four-way pool wants four times the memory of one slice.

Pass paths or tables, whichever you have. A source no slice rewrote is encoded once. df.lazy() pickles larger than the table.

How a sweep runs

one model, updated per slice A serial sweep builds once and updates; under executor= each slice builds its own. A remote Gurobi opens one session per serial sweep, and a new one at each slice that loads again.
the model is asked before it is sliced EachWindow refuses a window the model's coupling cannot bear. EachCoordinate is not asked.
a slice is total A slice supplies what the whole model attaches, not what changed. A hand-built list has to keep this rule.