Skip to content

Python API

This page is the reference for running a spec from Python: every public name, rendered from its docstring. A spec is the YAML file; what it may contain is the language.

import specsolve as sps

sps.check('spec.yaml')  # compiles? no data needed

result = sps.solve('spec.yaml', sources)
result.objective
result.primal('p')  # a polars.DataFrame
result.dual('power_balance')

Reference

Every public name, rendered from its docstring. The glossary defines model, result, sink and the other house terms the entries use.

Run a spec

check

check(spec, sink=None)

Parse, validate and lower a spec; attach no data.

With sink, also: will that sink take it? Bare check says nothing about portability. The answer is read off a declared table with no data attached, so it needs no solver installed. The solver-independent advice is issued either way.

PARAMETER DESCRIPTION
spec

A YAML path, a mapping, or a Spec.

TYPE: Buildable

sink

A solver name (highs, gurobi, xpress) or an output suffix (.lp, .mps). None asks only whether the spec is sayable.

TYPE: str | None DEFAULT: None

RETURNS DESCRIPTION
Program

The lowered program: what a build reads rows off, and what every verb

Program

here takes back without parsing the file again. It is the language's

Program

own type — typeset it, or read its declarations, through

Program

mathspec.

RAISES DESCRIPTION
LanguageError

A construct outside the streaming language.

SpecsolveError

A sink that cannot take this spec, or a name belonging to no sink.

ValueError

A schema or expression that does not parse.

WARNS DESCRIPTION
SpecsolveWarning

Advice short of an error — a declared dimension nothing uses as an axis, a variable the objective drives to infinity with nothing to stop it. Issued here and nowhere else.

build

build(spec, sources)

Attach sources to spec and build it — the model with your data on it.

PARAMETER DESCRIPTION
spec

As check takes it.

TYPE: Buildable

sources

Parameter names to parquet paths or in-memory tables, and dimension names to their labels — an index table, a parquet path, or a bare sequence — wherever the YAML declares none.

TYPE: Mapping[str, Source]

RETURNS DESCRIPTION
Model

The built model. It feeds any number of sinks — model.solve() and

Model

model.write(path) on the same object — and model.update(...)

Model

puts new numbers on it.

RAISES DESCRIPTION
LanguageError

A construct outside the streaming language.

DataError

A source that is missing, unreadable, or the wrong shape.

solve

solve(spec, sources, solver_name='highs', *, solver_options=None, archive=None)

Build spec and solve it in one call.

The one-shot spelling: a caller who will solve the same spec again with new numbers wants build and Model.update.

There is no keep here — this builds the model it solves, so the solve is the first of that model's life and kept is always nothing. Choosing what to keep is Model.solve.

PARAMETER DESCRIPTION
spec

As check takes it.

TYPE: Buildable

sources

As build takes them.

TYPE: Mapping[str, Source]

solver_name

highs, which ships with the package, or gurobi, which needs the [gurobi] extra.

TYPE: str DEFAULT: 'highs'

solver_options

Forwarded to the solver verbatim, in its own vocabulary ({'time_limit': 60}).

TYPE: Mapping[str, object] | None DEFAULT: None

archive

Where to write the spec, its data and this answer, as Model.solve takes it — a .zip, or a directory.

TYPE: str | Path | None DEFAULT: None

RETURNS DESCRIPTION
Result

The solution, self-contained: it owns the frames it reads, so the built

Result

model and the solver are released before this returns and there is

Result

nothing to manage. result.close() drops its own hold early.

RAISES DESCRIPTION
SpecsolveError

A solver name nothing serves — checked before the build.

write

write(spec, sources, out)

Build spec and stream it to a file, in the format out's suffix names.

PARAMETER DESCRIPTION
spec

As check takes it.

TYPE: Buildable

sources

As build takes them.

TYPE: Mapping[str, Source]

out

Where to write; .lp and .mps are what ship.

TYPE: str | Path

RETURNS DESCRIPTION
Path

The path written.

RAISES DESCRIPTION
ValueError

A suffix nothing writes — checked before the build.

SpecsolveError

A construct the format has no section for, which is check(spec, sink=out.suffix)'s answer with no data attached.

evaluate

evaluate(spec, sources, expression)

The value of expression over a spec with no variables — arithmetic, no solver.

A spec that declares no variables is a calculation, not an optimisation: dimensions, parameters, relations and expressions:. Each expression reads only the attached data, so it has a value with no solve and no chosen point. This attaches sources and values one expression, the way evaluate does at a solution. The language it is read through — what loads, what is refused, how a construct prints and lowers — is the one a spec that solves is read through; only the variables are absent.

A spec that declares variables is a problem to solve, and belongs to solve: an expression over a decision has no value until the decision is made.

PARAMETER DESCRIPTION
spec

As check takes it — a YAML path, a mapping, or a Spec.

TYPE: Buildable

sources

As build takes them: parameter names to tables or parquet paths, and dimension names to their labels.

TYPE: Mapping[str, Source]

expression

What one expressions: entry takes — a name the spec declares, an expression string, or the mapping carrying cases: with dims: and otherwise:.

TYPE: str | Mapping[str, object]

RETURNS DESCRIPTION
DataFrame

The value, (dims…, value) over the expression's own dims. Only

DataFrame

this expression is compiled: a declared one nothing asks for costs

DataFrame

nothing.

RAISES DESCRIPTION
LanguageError

A construct outside the streaming language, or a name the spec does not declare.

SpecsolveError

A spec that declares variables, constraints or an objective — a problem to solve, not a calculation to evaluate.

DataError

A source that is missing, unreadable, or the wrong shape, or a divisor with no value where the expression divides.

Run it many times

The fold and its two axes; sweeps says how a sweep is cut and read.

solve_over

solve_over(spec, sources, axis, *, carry=None, key_name=None, executor=None, workers_share_fs=None, solver_options=None, solver_name='highs', keep='solver', spill_to=None, archive=None)

Solve spec once per slice of axis and fold the answers together.

The rules — what a carry copies, how the key column is named, which executor to choose — are docs/reference/sweeps.md.

PARAMETER DESCRIPTION
spec

As check takes it. Parsed once, whichever executor runs the slices.

TYPE: Buildable

sources

As build takes them, every shape included; the axis filters the tables that carry it and passes the rest through.

TYPE: Mapping[str, Source]

axis

EachCoordinate, EachWindow, or a list of (key, sources) written by hand.

TYPE: Axis | Sequence[tuple[Label, Mapping[str, Source]]]

carry

{parameter: variable} — one slice's answer copied into the next slice's data. Where the two are over different dimensions the value handed on is the last coordinate the slice owns, which is the only one that meets the next slice at the seam. The first slice takes the parameter from sources, its seed.

TYPE: Mapping[str, str] | None DEFAULT: None

key_name

What to call the slice column; a class axis names its own, a hand-built list has to be told.

TYPE: str | None DEFAULT: None

executor

Any concurrent.futures.Executor; None runs the slices in order on one model. A process pool must be spawn or forkserver — a forked worker hangs.

TYPE: Executor | None DEFAULT: None

workers_share_fs

Whether the executor's workers can read this process's paths. Decided for the stdlib pools; anything else is assumed not to, and paths travel as bytes.

TYPE: bool | None DEFAULT: None

solver_options

As solve takes them.

TYPE: Mapping[str, object] | None DEFAULT: None

solver_name

As solve takes it.

TYPE: str DEFAULT: 'highs'

keep

As solve takes it, reaching every slice. Under an executor every slice is a first solve and keeps nothing, whatever was asked.

TYPE: Keep DEFAULT: 'solver'

spill_to

A directory to write each slice's frames to as the fold goes, so the sweep's memory stays at one slice however many there are. Read back through Sweep.scan. A directory holds one sweep: run the same sweep at it again and the slices already there are not solved again, which is how an interrupted sweep resumes.

TYPE: str | Path | None DEFAULT: None

archive

Where to write the whole thing — the model, the sources the sweep was cut from, the axis that cut them, and every slice's answer — so that sps.load_archive gives all four back and the sweep runs again from the file alone. A .zip suffix packs it into one file and anything else is a directory. Given beside spill_to, the spill is what the archive packs, so a sweep too large to hold is archived without ever being held. The archive is a second copy of the answers on disk; the memory is what spill_to bounds.

TYPE: str | Path | None DEFAULT: None

RETURNS DESCRIPTION
Sweep

Every slice's answers, keyed by slice.

RAISES DESCRIPTION
SpecsolveError

A carry that cannot line up, has no seed, collapses a dimension the axis does not advance along, or is asked together with an executor; a key that collides with a column the frames carry; an axis the program does not allow; a spill_to directory holding another sweep. All refused before a slice is taken, and every one answerable from the declarations before a source is read.

DataError

No source carries the axis, or the axis produced no slices.

WARNS DESCRIPTION
SpecsolveWarning

A source carrying the axis that is short of a coordinate another has — that slice builds it empty — or a position the model counts, which every window restarts.

EachCoordinate dataclass

EachCoordinate(dim)

One slice per coordinate of dim — a column the sources carry.

Scenarios, draws, investment periods. Sources carrying dim are filtered to one coordinate and the column dropped, so the model never mentions it — a dim the spec declares is refused; every other source passes through untouched. The slices run in the coordinates' sorted order, which is the order a carry chains them in.

dim instance-attribute
dim
slices
slices(sources)

The (key, sources) list this axis would run — what axis= takes hand-built.

For building one slice alone: sps.build(spec, axis.slices(sources)[3][1]).

EachWindow dataclass

EachWindow(dim, *, steps, lookahead, into)

One slice per window of consecutive coordinates of dim.

steps is what each window keeps and lookahead is what it sees beyond that, so a window is steps + lookahead coordinates long and a lookahead above zero is overlap. An int keeps the same number every window; a sequence keeps those numbers in order, which is a telescoping horizon or a month at a time. Both count coordinates rather than coordinate values, so dim need only be orderable — datetimes, strings and gapped integers all work. The dimension is re-indexed rather than dropped, into a dense 0..n-1 column the model addresses by the name into gives it, which the spec has to declare.

Whether the model can be sliced this way is asked before it is — the coupling, the reach and the lookahead they need are _check_the_program.

dim instance-attribute
dim
into class-attribute instance-attribute
into = field(kw_only=True)
lookahead class-attribute instance-attribute
lookahead = field(kw_only=True)
steps class-attribute instance-attribute
steps = field(kw_only=True)
slices
slices(sources)

The (key, sources) list this axis would run — what axis= takes hand-built.

For building one window alone: sps.build(spec, axis.slices(sources)[37][1]). Pairs, so a window's ownership is not in them: solved as a list the slices key by key_name=, original_index is refused and a carry cannot collapse a dimension.

What comes back

Model

Model(spec, sources)

A spec with your data attached to it — what build returns.

Three nouns, each arrow adding one thing: a Program is the math, a Model is the math with your data, a Result is one answer: check → Program → build → Model → solve → Result.

One build feeds any number of sinks — solve and write on the same object — update puts new numbers on it without re-reading the YAML or re-lowering the plan, and diagnostics says what it did. Nothing has to be released; close hands a large model back early.

close
close()

Release the built model, and any solver still holding it.

diagnostics
diagnostics()

What this build and its solves did that the answer does not show.

Answerable after close, and after a build that raised: every field is a count, a clock or a small frame the engine keeps, not a read of the model it releases. A raise leaves the sizes at zero — they are taken once a model is whole — and everything measured before it stands.

evaluator
evaluator(primals, duals, no_duals)

An ad-hoc expression reader over a saved solution, put back against this build.

What an archive and a sweep hand evaluate for a quantity the file never named: the saved frames are laid back in this build's label order, and the reader is the one a live solve gives. A build, never a solve.

PARAMETER DESCRIPTION
primals

The saved (dims…, value) frame per variable.

TYPE: Mapping[str, DataFrame]

duals

The same per constraint, or None where the solve left no duals — no_duals then says why, and a read of one raises it.

TYPE: Mapping[str, DataFrame] | None

no_duals

Why there are no duals, or None when duals holds them.

TYPE: str | None

row
row(name, /, **coordinate)

One built constraint row at one coordinate — its terms, sense and right-hand side.

The verb for this row is wrong and I do not know why. to_latex and its siblings render the spec as math before any data, and dual gives a row's number without its terms; this gives the row the build actually produced, at the coordinate you name.

Reads the built model and needs no solve, so it answers on a model that never reached a solver — and it is the built row, so a term whose variable was absent is missing from it and a row a where masked out is not there at all. It shows what the model says rather than what the file appears to say.

PARAMETER DESCRIPTION
name

A declared constraint. Positional, so that a dimension may be called name and still be named in coordinate.

TYPE: str

coordinate

One label per dim of that declaration, all of them — a partial coordinate names a set of rows rather than one.

TYPE: Label DEFAULT: {}

RETURNS DESCRIPTION
ConstraintRow

The terms as (variable, coordinate, coefficient), beside the

ConstraintRow

comparison and the right-hand side.

RAISES DESCRIPTION
KeyError

No constraint is called name.

SpecsolveError

The coordinate names the wrong dims, matches no row the build produced, or the model has been closed.

Example

print(model.row('balance', snapshot=1)) # doctest: +SKIP balance[snapshot=1]: +1 p[1, wind] +50 p[1, gas] >= 60

solve
solve(solver_name='highs', *, solver_options=None, keep='solver', archive=None)

Hand the built model to a solver and solve it.

A solver that can stay loaded is kept between calls, so an updated model skips the hand-off and only its numbers are pushed. Whether the work that solver did is kept too is keep, off by default. How much this solve actually kept is its kept.

PARAMETER DESCRIPTION
solver_name

highs, which ships with the package, or gurobi, which needs the [gurobi] extra.

TYPE: str DEFAULT: 'highs'

solver_options

Forwarded to the solver verbatim, in its own vocabulary ({'time_limit': 60}).

TYPE: Mapping[str, object] | None DEFAULT: None

keep

How much of the session this solve may keep: solver, progress or nothing. solver, the default, reuses the solver holding the model and discards the work it did; progress keeps that work too, which is what an iterating driver moving one step at a time wants; nothing keeps neither, which is what timing a build or comparing against a cold baseline needs and what no solver option can promise. A preference: a model whose structure moved is loaded again whatever was asked.

TYPE: Keep DEFAULT: 'solver'

archive

Where to write the whole thing — the spec, the data attached to it now, and this answer — so that load_archive gives all three back and the model solves again from the file alone. A .zip suffix packs it into one file and anything else is a directory. What the build and its solves have spent goes in beside the answer, as Metrics.

TYPE: str | Path | None DEFAULT: None

RETURNS DESCRIPTION
Result

The solution, holding this model.

RAISES DESCRIPTION
SpecsolveError

A solver name nothing serves, one this environment cannot run, or a keep other than those three.

LayoutError

An archive directory that already holds something, refused before the solve rather than after it.

update
update(sources)

Put new numbers on the same model, in place.

::

model.update({'cap_hat': capacity}).solve()

Any new data is accepted: model.update(x) answers what build(spec, sources | x) answers, whatever changed. Data that moves a mask renumbers labels, so the model is rebuilt and solved cold instead of pushed onto a loaded solver, and loads says which ran.

Results taken before the update keep reading: each owns the frames it reads, and an update builds new ones rather than touching those. A retained result keeps its build's label frames alive until it is dropped or close is called.

PARAMETER DESCRIPTION
sources

Only what changed; the rest keeps what build attached. A dimension's labels as well as a parameter, which is how a coordinate set grows.

TYPE: Mapping[str, Source]

RETURNS DESCRIPTION
Model

This object, so a driver can chain.

RAISES DESCRIPTION
DataError

A name the spec does not declare.

write
write(path)

Stream the built model to path, in the format its suffix names.

RAISES DESCRIPTION
ValueError

A suffix nothing writes.

SpecsolveError

A construct the format has no section for, the same as check's sink= answer.

Result dataclass

Result(_status, _objective, _primals, _duals, _activities, _kept, _expressions=None, _evaluate=None, _no_duals=None, _dual_rays=None, _no_dual_ray=None, _spec_digest=None, _solved_at=None, _model_digest=None, _run=None)

What a solve returned — the outcome, and access to any values.

Returned whatever the solve concluded: test has_primal before reading values, or catch NoSolutionError. The values are this result's own, so a later solve on the same model does not rewrite them, and there is no lifetime to manage — close releases what this result holds early, and nothing breaks without it.

An update is no exception. A result owns everything it reads — one finished frame per declaration, its own values already laid out over the label frames of the build it answered — so it outlives anything done to the model afterwards: an update, another solve, model.close(). What retaining one costs is those label frames staying alive, which matters once a caller keeps several, as a sweep, a rolling horizon and Benders all do.

has_primal property
has_primal

Whether there are values to read — what the accessors gate on.

Narrower than is_ok: a run stopped at a time limit before any incumbent is ok with nothing to read.

is_ok property
is_ok

The linopy rollup: not an error, an abort or a refusal.

kept property
kept

How much of the session this solve kept: solver, progress or nothing.

What happened, not what was asked: keep= is a preference, and a first solve or a structure that moved keeps nothing whatever it requested, the solver having been loaded again. So a driver that asked to keep progress and reads nothing back is being told its labels moved. Advisory, like Diagnostics: no answer depends on it.

objective property
objective

The objective value, or nan when there is no solution.

record property
record

How this solve terminated, as the one row save writes for it.

The fields above in one value, and the same row a sweep keeps per slice in record. objective is None rather than nan where there are no values. Asking computes model_digest once, as a save does.

solved_at property
solved_at

When the solver returned, in UTC — None where the solve carried no clock.

What orders a table concatenated from runs solved apart, so that a comparison is not left reading the timestamps of the files.

spec_digest property
spec_digest

Which spec this answered — a digest of the file, not its name.

Two answers carrying one digest answered the same document, so a table of saved cases says whether it is comparing like with like. The data may differ entirely: two scenarios of one spec share this. None where the solve ran off a lowered program, which has no document.

status property
status

Coarse outcome: ok / warning / error / aborted / unknown.

termination_condition property
termination_condition

What the solver said — optimal, infeasible, time_limit and so on.

activity
activity(name)

The left-hand side of constraint name at the solution — (dims…, value).

dual's shape and order, and the other half of a row's story: how far each row's Σ aᵢxᵢ sits from its bound. The solver's own number, not a recomputation. Readable whenever there is a solution — unlike dual it is well-defined on a mixed-integer model. On an == row it equals the right-hand side up to solver tolerance by construction.

RAISES DESCRIPTION
NoSolutionError

The solve left no values to read.

SpecsolveError

This result was closed.

KeyError

No constraint is called name.

close
close()

Release what this result holds early. Optional.

Its frames, which carry both its own values and its hold on the label frames of the build it answered. Frames already read stay valid. Never the model or the solver, which are the Model's to close.

dual
dual(name)

Shadow prices of constraint name — (dims…, value).

primal's shape and order, over constraint rows.

RAISES DESCRIPTION
NoSolutionError

The solve left no values at all.

SpecsolveError

This result was closed, or it left primals but no duals — an integer variable makes them undefined.

KeyError

No constraint is called name.

dual_ray
dual_ray(name)

Constraint name's share of the certificate that this model has no solution — (dims…, value).

The one thing an infeasible solve has to say, and the only reader that answers on one: primal, dual and activity all raise there, because there is no solution behind them. Weight every row by its value here and add them together, and the combined row demands more than the columns can deliver inside their bounds — which is the proof that nothing satisfies all of them at once. That is what a Benders feasibility cut is built from, and it is why a driver no longer needs a second model to ask how far from feasible a subproblem was.

dual's shape and order. The sign is the row's own, one convention across every sink, so a driver never asks who solved — a sink whose solver signs the other way negates what it reads. Where every column is held only by a lower bound of zero, as a dispatch variable is, the bounds deliver nothing and the proof is the simpler Σ weight * right-hand side > 0.

RAISES DESCRIPTION
SpecsolveError

This result was closed; or the solve was not infeasible, so there is nothing to certify; or the sink produced no ray, in which case the message names the solver option that would have.

KeyError

No constraint is called name.

Example

answer.dual_ray('balance') # doctest: +SKIP shape: (4, 2) ┌──────────┬───────┐ │ snapshot ┆ value │ ╞══════════╪═══════╡ │ 0 ┆ 1.0 │ └──────────┴───────┘

evaluate
evaluate(expression)

The value of expression at this solution — (dims…, value).

expression is what one expressions: entry takes: a name the file declares, an expression string, or the mapping carrying cases: with dims: and otherwise:. It may use every name the model declares and only those. The value is aggregated to the expression's own dims, in declaration order, rows in label order over them — primal's shape and order.

A declared name is served by its own reader, compiled on this call and never lowered again, so a spec whose expressions go unread compiles none of them. Anything else lowers the spec as written, which costs what check costs.

RAISES DESCRIPTION
NoSolutionError

The solve left no values to read.

SpecsolveError

This result was closed; the model was built from an already-lowered Program or read back off disk, so there is nothing to lower an undeclared expression against; or a divisor with no value where the expression divides.

LanguageError

A construct outside the language, or a name the spec does not declare.

model_digest
model_digest()

Which model this answered — the document and the data it was attached to.

spec_digest names the document alone, so two scenarios of one spec share that and differ here. Computed on the first ask and kept, which is what keeps a solve that never asks free of it.

primal
primal(name)

The tidy solution of variable name — (dims…, value).

Rows come back in label order, row-major over the variable's coordinate product, so two reads and two runs agree.

RAISES DESCRIPTION
NoSolutionError

The solve left no values to read.

SpecsolveError

This result was closed.

KeyError

No variable is called name.

save
save(directory)

Every kind this solve answered with, one file per name, into directory.

record.parquet holds the Record — how the solve terminated and what it reached, in the columns a sweep keys and folds. A solve that reached no objective writes null there rather than nan, so a directory per case is a table an aggregate reads. Then primal/<name>.parquet for every variable, dual/<name>.parquet for every constraint where the duals are defined, and expression/<name>.parquet for every named expression this data can evaluate — an integer variable leaves the duals out, and an expression that fails on this data is left out, evaluate still saying why. The primals are streamed to disk in primal's order, so the same model and data write the same bytes.

activity/<name>.parquet goes beside them for every constraint, which no kind= names — a sweep folds three kinds and never holds these, so a saved result carries them under a name of their own.

reasons.parquet holds (kind, name, reason) for whatever is deliberately not here, and is absent when everything is: one row per expression that failed, and one with an empty name for the duals, whose absence is never per-constraint. Written because a directory that simply lacks a file cannot tell "there is none, and here is why" from "no such name", which is the one thing dual and evaluate do say.

A solve that left no values writes the record and nothing else. A run that came back infeasible is an answer a set of saved cases needs on disk, rather than a directory that does not exist.

The directory holds this answer and no other. Whatever a previous save left there is removed first, so a re-run cannot leave one model's frames beside another's record. Files that are not part of the layout are left alone.

RETURNS DESCRIPTION
Path

The directory.

RAISES DESCRIPTION
SpecsolveError

This result was closed.

to_dataarray
to_dataarray(name, kind='primal')

One name's values as a labelled xarray.DataArray, to_pandas's arguments.

Dense over the name's dims: a masked coordinate comes back NaN.

to_dataset
to_dataset(*names, kind='primal')

The named values of one kind as one xarray.Dataset; all of that kind by default.

One kind per call: a dual and a variable of the same name would collide, and mean something else per row. Each arrives dense over its own dims, all at once — on a large model name the few you need.

PARAMETER DESCRIPTION
names

What to include; none means every name of kind.

TYPE: str DEFAULT: ()

kind

primal, dual or expression.

TYPE: str DEFAULT: 'primal'

to_pandas
to_pandas(name, kind='primal')

One name's values as a tidy pandas.DataFrame.

PARAMETER DESCRIPTION
name

A variable, a constraint or a named expression, as kind says.

TYPE: str

kind

primal, dual or expression — the reader this stands in for.

TYPE: str DEFAULT: 'primal'

Sweep dataclass

Sweep(key_name, record, metrics, _primals=dict(), _duals=dict(), _expressions=dict(), _no_duals=None, _no_expressions=dict(), _original=None, _hand_built=False, _spill=None, _evaluate=None)

What a fold returned: frames keyed by slice, never a scalar.

Result's readers one dimension wider — same names, same shapes, the slice key prepended. Nothing is combined across slices: each row says which slice computed it. A windowed sweep reads over that key unless a reader asks original_index=True, which gives the dimension the axis sliced and drops the lookahead rows every overlapping window recomputed.

key_name instance-attribute
key_name
keys property
keys
metrics instance-attribute
metrics

One SliceMetrics per slice, keyed and in slice order — diagnostics one dimension wider, its counts and clocks only. loaded says the solver took the model from scratch: under a serial fold the first slice does and the rest are pushed values, so a later True is a slice whose data moved a mask; under an executor every slice builds alone and every one loads. The _seconds columns are this slice's own share, so a slow sweep says which slice, and which phase of it.

record instance-attribute
record

(key, status, termination_condition, objective, has_primal, spec_digest), in slice order — how every slice terminated, whether or not it produced an answer, has_primal saying which of the two it was and spec_digest which document every slice answered. A slice that reached no objective holds null there rather than nan, so the column aggregates over the slices that solved.

dual
dual(name, *, original_index=False)

One constraint's shadow prices across every slice, the key prepended.

primal's shape and arguments. A slice whose model had an integer variable contributes no duals; over the original index each coordinate carries the price of the window that owns it, never a blend of several.

RAISES DESCRIPTION
SpecsolveError

No slice produced duals for name — the message says which of the two it was.

evaluate
evaluate(expression, *, original_index=False)

The value of expression at every slice's solution, the slice key prepended.

evaluate one dimension wider, and primal's shape and arguments. expression is what one expressions: entry takes: a name the file declares, an expression string, or the mapping carrying cases: with dims: and otherwise:.

A declared name was valued at each slice's solution when the fold read it, so it is stitched from what the sweep holds, live or off disk, and never lowered again. Anything else is valued at each slice's own solution with no re-solve: the slice's model is rebuilt from the archive's spec and that slice's cut of the sources, and its saved primal put back against it — so it is available on the sweep load_archive hands back, which carries the spec, sources and axis, and a Sweep a live solve returned says it retains no model. It reads only what an archive can put back: an expression over a parameter the sweep carried is refused, that value being a previous slice's answer rather than stored data.

Over the original index each coordinate carries the value of the window that owns it — the recomputed lookahead rows are dropped, which is what makes summing the stitched frame safe where summing per-window values double-counts.

PARAMETER DESCRIPTION
expression

A declared name, an expression string, or the cases: mapping.

TYPE: str | Mapping[str, object]

original_index

Read over the dimension the axis sliced instead of over the slice key.

TYPE: bool DEFAULT: False

RAISES DESCRIPTION
SpecsolveError

No slice produced a declared expression — an evaluation that failed on every slice carries its own reason — a spilled sweep, which scan reads instead; an undeclared expression on a Sweep with no model behind it, or one that reads a parameter the sweep carried; or original_index on a hand-built axis or a quantity reduced over the sliced dimension.

LanguageError

A construct outside the language, or a name the spec does not declare.

primal
primal(name, *, original_index=False)

One variable's values across every slice, the slice key prepended.

A slice that reached no solution contributes no rows, so this can be shorter than the sweep; record is one row per slice always.

PARAMETER DESCRIPTION
name

A variable the sweep's spec declares.

TYPE: str

original_index

Read over the dimension the axis sliced instead of over the slice key.

TYPE: bool DEFAULT: False

RAISES DESCRIPTION
SpecsolveError

No slice of the sweep produced name, or original_index on a sweep whose axis was hand-built and so named no dimension to read the keys back over.

save
save(directory)

Everything the sweep holds, written as spill_to= would have written it.

The same layout: <kind>/<name>/<position>.parquet for every primal, dual and expression, the slice key a column of each, with record/, metrics/ and the manifest beside them. So the directory is a spilled sweep: scan reads it, and the call that made this sweep, pointed at it with spill_to=, reads it back without solving a slice.

RETURNS DESCRIPTION
Path

The directory.

A sweep whose every slice terminated without values writes each slice's record and no frames, as one such solve does, rather than refusing.

RAISES DESCRIPTION
SpecsolveError

The sweep is spilled — its frames are in a directory already.

scan
scan(name, kind='primal', *, original_index=False)

One name's values across every slice as a polars.LazyFrame, the slice key prepended.

The reader for a sweep solved with spill_to=, whose frames are on disk; on one held in memory it is primal, dual or evaluate made lazy, so the same line reads either.

PARAMETER DESCRIPTION
name

A variable, a constraint or a named expression the spec declares, as kind says.

TYPE: str

kind

primal, dual or expression — the reader this stands in for.

TYPE: str DEFAULT: 'primal'

original_index

Read over the dimension the axis sliced instead of over the slice key.

TYPE: bool DEFAULT: False

RAISES DESCRIPTION
SpecsolveError

No slice produced name, or a kind that names no reader.

to_dataarray
to_dataarray(name, kind='primal', *, original_index=False)

One name's values as a xarray.DataArray, the slice key a dimension; to_pandas's arguments.

The extra dimension is named by the axis — a scenario sweep gives (scenario, …) and a window (<dim>_start, …). A slice that reached no solution has no rows and comes back NaN, the same answer a masked coordinate gets from Result. original_index=True gives the array over the dimension the axis sliced instead, so a rolling horizon's dispatch, or its price, comes back indexed by time.

to_dataset
to_dataset(*names, kind='primal')

The named values of one kind as one xarray.Dataset; all of that kind by default.

One kind per call: a dual and a variable of the same name would collide, and mean something else per row. Name the few you need, or use save, which writes every kind.

No original_index: this and save export what the sweep holds, lookahead rows included.

PARAMETER DESCRIPTION
names

What to include; none means every name of kind some slice produced.

TYPE: str DEFAULT: ()

kind

primal, dual or expression.

TYPE: str DEFAULT: 'primal'

RAISES DESCRIPTION
SpecsolveError

The sweep holds no values of kind at all, or is spilled — its frames are on disk already.

to_pandas
to_pandas(name, kind='primal', *, original_index=False)

One name's values across every slice as a tidy pandas.DataFrame.

The name is resolved before pandas is imported, so a sweep that never held name says so on any install.

PARAMETER DESCRIPTION
name

A variable, a constraint or a named expression, as kind says.

TYPE: str

kind

primal, dual or expression — the reader this stands in for.

TYPE: str DEFAULT: 'primal'

original_index

Read over the dimension the axis sliced instead of over the slice key.

TYPE: bool DEFAULT: False

The rows and frames those hand back: how a solve terminated, what the build and its solves took, and what a slice of a sweep took.

Diagnostics dataclass

Diagnostics(columns, rows, nonzeros, omissions, sparse_parameters, coefficient_range, bound_range, rhs_range, objective_range, solves, loads, seconds)

What a build and its solves did that the answer does not show.

Advisory, all of it: no answer depends on any field. Read them when a loop is slower or smaller than it should be.

bound_range instance-attribute
bound_range

(variable, smallest, largest) — the bound magnitudes each variable block put on its columns, one row per block that declared a finite one. The axis a solver reports and does not repair: HiGHS prints a Bound range beside its Matrix one, equilibrates the matrix automatically, and answers the bounds with Consider scaling the bounds by … — so a model can be clean on coefficient_range and still be the one the solver is complaining about. Zero and infinity are excluded, an unbounded side and a lower: 0 being nothing the solver represents. A large largest is usually a big number standing in for "uncapped", and wants no upper bound at all rather than a rounder one.

coefficient_range instance-attribute
coefficient_range

(constraint, smallest, largest) — the coefficient magnitudes each constraint block put in the matrix, one row per block that kept a term, in build order. A solver's own Matrix range line answers this for the whole model; what it cannot say, and what a caller can act on, is which declaration holds the outlier. largest / smallest over the frame is the conditioning to compare against the solver's. A block whose every row went (the absence rules) has no entry, the same way it has no rows.

columns instance-attribute
columns

The shape the build produced: columns, rows, and matrix entries. The thing to report when a model is bigger than its author expected — a broadcast that multiplied rows shows up here first.

loads instance-attribute
loads
nonzeros instance-attribute
nonzeros
objective_range instance-attribute
objective_range

The same pair for the objective's coefficients, or None where the spec declares no objective and where every term of one cancelled.

omissions instance-attribute
omissions

(constraint, rows_not_built) — every declared row that did not reach the solver (the absence rules), by either route: one emptied of all its terms, and one a propagated absence deleted while its other terms were still live. Empty for a model whose every declared row was built — a recurrence's first coordinate counting as a row it declared and did not get, so a shift against the horizon's edge reports here and is the boundary rather than a fault. Counts rather than coordinates: the label of an unbuilt row does not exist.

rhs_range instance-attribute
rhs_range

(constraint, smallest, largest) — the same for each block's right-hand sides, over the rows that survived. The fourth of the four ranges a solver reports, and the last of them this can answer per declaration rather than per model.

rows instance-attribute
rows
seconds instance-attribute
seconds

Cumulative wall-clock seconds per phase, keyed by the phase's name: attach (the caller's sources onto the plan), build (declarations into the model frames), handoff (the built model into a solver), solve (the solver's own run), write (the built model to a file). A phase that never ran has no key; one that ran again holds the sum — an update's attach and build land on top of the first's, the way solves keeps counting. Clocks rather than a profile: enough to say which phase a slow loop spends its time in, not why.

solves instance-attribute
solves

How many times this model has been solved, and how many of those solves loaded the solver from scratch instead of pushing values onto one that already held it. Read together: loads == 1 is a driver on the fast path — the first solve had nothing to keep — and loads == solves on an iterating driver is the difference between "specsolve is slow" and "this model masks on a parameter that varies", unless the driver asked for keep='nothing', which loads by construction. loads ticks on exactly the solves that report Result.kept of nothing — the same event, counted here and named there.

sparse_parameters instance-attribute
sparse_parameters

(parameter, coordinates, rows, missing) — one row per parameter whose source is short of the coordinates its dims reach, in declaration order, and empty where every one is complete. Sparsity is the ordinary case here — absence is how a model masks — so this reports it rather than judging it: what a missing row means is the absence rules', and whether it was meant is the caller's to say.

A parameter over no dims has one coordinate and attaching already refuses a source that does not carry exactly one row for it, so it is never here.

metrics
metrics()

The sizes, counters and clocks as one value — the row an archive records.

What archive= records beside the answer, and what a caller feeding its own store reads off a model it solved. Which fields reach it and what it means cumulatively are Metrics's to say; a phase this build never entered reads zero there. run is null: the name is the publisher's, and nothing has published this yet.

Record

How a solve terminated, what it reached, and which spec it answered.

One row per solve, and the same columns whoever wrote them: a result writes one, a sweep one per slice keyed by its own key. The only part of an answer the frames themselves cannot carry — a run that left no values writes this and nothing else.

has_primal instance-attribute
has_primal

Whether the solve produced values, which the condition alone does not say: a run stopped at a limit before any incumbent is ok with nothing to read.

model_digest class-attribute instance-attribute
model_digest = None

A digest of the model this answered — the spec and its data, where spec_digest is the document alone. None for an answer written before this column, and for one whose result was never asked for it.

objective instance-attribute
objective

What the solve reached, or None where it reached nothing. Null rather than nan: nan is a number to every aggregate that meets it. Result.objective is a float and reads it back as nan, having no null to return.

run class-attribute instance-attribute
run = None

What the archive holding this answer was called — its file name without a .zip, so runs/nightly-2026-09-10.zip writes nightly-2026-09-10 and a directory called case.v2 keeps both halves of its name. Stamped when the archive is written and null until then.

solve_status property
solve_status

The status this row records — the way back from columns.

The solver's own wording is gone, and status is derived again rather than read off the row.

solved_at class-attribute instance-attribute
solved_at = None

When the solver returned, in UTC. None for a solve that carried no clock — a result built by hand, or read back from a record written before this column.

spec_digest instance-attribute
spec_digest

A digest of the spec this answered, or None where the solve was run off a lowered program and there was no document to digest. Null on disk, never an empty string.

status instance-attribute
status
termination_condition instance-attribute
termination_condition
of classmethod
of(termination_condition, objective, *, has_primal, spec_digest, solved_at, model_digest=None)

The row a solve that terminated this way writes.

status is derived here rather than passed, and an objective is dropped to null here rather than at each writer.

PARAMETER DESCRIPTION
termination_condition

What the solver said.

TYPE: str

objective

What the solve reached. Written only where there are values to read — nan is a number to every aggregate.

TYPE: float

has_primal

Whether there are values, which the condition alone does not say.

TYPE: bool

spec_digest

A digest of the spec answered, or None.

TYPE: str | None

solved_at

When the solver returned, in UTC. None where the solve carried no clock.

TYPE: datetime | None

model_digest

The built model's digest, or None where this answer never held one.

TYPE: str | None DEFAULT: None

Metrics

What a build and its solves took, as the row an archive records beside the answer.

Record's sibling — one says how the solve terminated, this is the measure of what it took — and the same columns whoever writes them, so rows written by runs that never met concatenate into one table.

The scalars of Diagnostics and none of its frames: a coefficient range is a table per declaration, which does not fold into a row beside a count.

Cumulative over the model's life, as every counter it is read off is. solves says how many solves the clocks cover; it reads 1 for the archive specsolve.solve writes, that verb building the model it solves.

attach_seconds instance-attribute
attach_seconds

Wall-clock seconds in each phase a build clocks, in the order they run: the caller's sources onto the plan, the declarations into the model frames, the built model into a solver, the solver's own run, and the built model streamed to an LP or MPS file. A phase that never ran writes zero rather than no column.

So write_seconds reads zero on an archive whose caller never asked for a file, which is most of them: it is write's clock rather than the archive's own. What writing the archive cost is not here and is not anywhere: a caller who wants that number times the call.

build_seconds instance-attribute
build_seconds
columns instance-attribute
columns

The shape the build produced, in the solver's own vocabulary.

handoff_seconds instance-attribute
handoff_seconds
loads instance-attribute
loads
nonzeros instance-attribute
nonzeros
rows instance-attribute
rows
run class-attribute instance-attribute
run = None

What the archive holding this row was called, as Record.run is stamped onto the record beside it: the archive's file name without a .zip. Null until one is written.

solve_seconds instance-attribute
solve_seconds
solves instance-attribute
solves

How many solves the row covers, and how many of those loaded the solver from scratch. Read together with the clocks, which are cumulative over exactly these solves.

write_seconds instance-attribute
write_seconds

SliceMetrics

What one slice of a sweep took — Metrics one dimension in.

Not the same columns, and the fold is what separates them. A slice's clocks are its own share rather than a cumulative total; loaded says whether the solver took this slice from scratch, where a whole model counts its loads; and what a sink added, how many solves ran and what a file write took are facts about a model's life that one slice of a sweep has no share of.

Written per slice by the spill and read back as one table, so a sweep's every slice concatenates the way a directory of archives does.

attach_seconds instance-attribute
attach_seconds

This slice's own seconds per phase, so a slow sweep says which slice and which phase of it. A whole model's write has no per-slice meaning — a sweep writes no file per slice — and there is no column for it.

build_seconds instance-attribute
build_seconds
columns instance-attribute
columns

The shape this slice built, as Metrics reports a whole model's.

handoff_seconds instance-attribute
handoff_seconds
loaded instance-attribute
loaded

Whether the solver took this slice's model from scratch instead of having values pushed onto one it already held. Under a serial fold the first slice does and the rest do not, so a later True is a slice whose data moved a mask; under an executor every slice loads.

nonzeros instance-attribute
nonzeros
rows instance-attribute
rows
solve_seconds instance-attribute
solve_seconds

Carry an answer

SolveArchive dataclass

SolveArchive(spec, sources, answer, source_digests, metrics)

A spec, the data it was solved with, and what one solve of it returned.

sps.solve(archive.spec, archive.sources) asks the question again.

ATTRIBUTE DESCRIPTION
spec

The spec as written.

TYPE: Spec

sources

What was attached, keyed as the file declares it: a table from load_archive, the path to one from scan_archive.

TYPE: Mapping[str, Source]

answer

What came back.

TYPE: Result

source_digests

(run, source, digest), one row per source, so two archives of one spec over different numbers name the input that moved.

TYPE: DataFrame

metrics

What reaching the answer took, as one Metrics.

TYPE: Metrics

answer instance-attribute
answer
metrics instance-attribute
metrics
source_digests instance-attribute
source_digests
sources instance-attribute
sources
spec instance-attribute
spec

SweepArchive dataclass

SweepArchive(spec, sources, axis, carry, answer, source_digests)

A spec, the data a sweep was solved over, the axis that cut it, and what came back.

sps.solve_over(sweep.spec, sweep.sources, sweep.axis, carry=sweep.carry) runs it again.

ATTRIBUTE DESCRIPTION
spec

The spec as written.

TYPE: Spec

sources

What the sweep was given, uncut. A table or a path, as SolveArchive holds them.

TYPE: Mapping[str, Source]

axis

What cut them.

TYPE: EachCoordinate | EachWindow

carry

{parameter: variable} the slices were chained with, empty where they were not.

TYPE: Mapping[str, str]

answer

Every slice's answer, keyed by slice. Held from load_archive, spilled from scan_archive.

TYPE: Sweep

source_digests

As SolveArchive holds it, of the uncut sources.

TYPE: DataFrame

answer instance-attribute
answer
axis instance-attribute
axis
carry instance-attribute
carry
source_digests instance-attribute
source_digests
sources instance-attribute
sources
spec instance-attribute
spec

load_archive

load_archive(path, into=None)

Read an archive back whole: the sources as tables, the answer's frames in memory.

PARAMETER DESCRIPTION
path

The archive, a .zip or the directory one was written to.

TYPE: str | Path

into

Where to unpack a zip, kept afterwards, for a caller who wants the extracted tree as well. Without it a zip unpacks to a scratch directory that is gone when this returns. Refused for a directory archive, which is read where it lies.

TYPE: str | Path | None DEFAULT: None

RETURNS DESCRIPTION
SolveArchive | SweepArchive

A SweepArchive where the archive carries an axis, a

SolveArchive | SweepArchive

SolveArchive where it does not.

RAISES DESCRIPTION
LanguageError

A spec.yaml the language does not accept.

LayoutError

A member outside the layout, an into given for a directory, or an answer whose layout has moved since it was written.

SpecsolveError

An answer that names a different spec than the one beside it.

BadZipFile

A file that is not a zip archive.

load_result

load_result(directory)

Read back an answer Result.save wrote — a solve, off disk.

Every reader answers what it answered in the session that solved: the values, the duals and activities, each named expression, and the reason behind anything the solve could not produce. A Result is frames and a few scalars, so none of it needs the build that made it or the solver that filled it — which is what makes an archived answer comparable with one solved today.

Two things do not come back, both being facts about a session rather than about an answer: kept reads nothing, this result holding no solver, and the solver's verbatim wording behind a refusal is not recorded — the termination condition is. A solve that reached no objective wrote null and reads back as nan, which is what objective has to return, being a float.

PARAMETER DESCRIPTION
directory

Where save wrote it. One that came out of an archive is load_archive's to find.

TYPE: str | Path

RETURNS DESCRIPTION
Result

The result, read whole: the frames are in memory when this returns, so

Result

it owes directory nothing. scan_result is the same answer left

Result

on disk.

RAISES DESCRIPTION
LayoutError

A directory holding no record.parquet, which is what every answer written there carries, or one whose layout has moved since it was written.

load_sweep

load_sweep(directory)

Read back a sweep Sweep.save wrote, or one solve_over(spill_to=) spilled.

The sweep comes back held: every slice's frames are in memory when this returns, so it is the value a sweep solved without spill_to= is — Sweep.primal, Sweep.to_dataset and Sweep.save all answer, and it owes directory nothing afterwards. A sweep larger than memory is scan_sweep instead.

Sweep.record and Sweep.metrics are one row per slice either way, and original_index works on both, the manifest carrying the dimension a window sliced.

PARAMETER DESCRIPTION
directory

Where the sweep was written.

TYPE: str | Path

RETURNS DESCRIPTION
Sweep

The sweep, keyed as it was solved.

RAISES DESCRIPTION
LayoutError

A directory holding no sweep.json, which is what every sweep written there carries, one missing a record every fold writes, or one whose layout has moved since it was written.

scan_archive

scan_archive(path, into=None)

Read an archive back off disk: the sources as paths, each frame read at the call that asks for it.

The members have to outlive the value, so into is required for a zip and kept. The same values and the same errors as load_archive, and LayoutError for a zip with no into.

scan_result

scan_result(directory)

The answer under directory, read as its readers are called rather than now.

load_result's other half, and the same value: every reader answers what that one's does. What differs is when the bytes move — each frame is a polars.scan_parquet of the file it lies in, so an answer far larger than memory is readable a name at a time, and one whose names go unread costs nothing to open.

The files stay where they are, so they have to outlive the result: a name read after the directory is gone raises where the scan is collected.

PARAMETER DESCRIPTION
directory

As load_result takes it.

TYPE: str | Path

RAISES DESCRIPTION
LayoutError

As load_result raises it.

scan_sweep

scan_sweep(directory)

The sweep under directory, its frames left where they lie.

load_sweep's other half, and the value a sweep solved with spill_to= already is: nothing but the record is read, and Sweep.scan reads a name back as a polars.LazyFrame when one is asked for. That is the reader for a sweep too large to hold, and it costs the frame readers: Sweep.primal and its siblings refuse, naming Sweep.scan.

directory has to outlive the sweep, the frames being read off it as they are asked for.

PARAMETER DESCRIPTION
directory

As load_sweep takes it.

TYPE: str | Path

RAISES DESCRIPTION
LayoutError

As load_sweep raises it.

Errors and warnings

Every error is one tree, rooted at SpecsolveError. A spec the language accepts and specsolve cannot build raises SpecsolveError itself, and its message names the rewrite. LanguageError, with SchemaError and DimensionError, is a fault in the spec, and is the language's own: which error you get.

SpecsolveError module-attribute

SpecsolveError = MathSpecError

The root, under the name callers catch it by. An alias and not a subclass: except sps.SpecsolveError has to catch a LanguageError.

LanguageError

The spec is not sayable in the language, or does not obey its rules.

SchemaError

What a load refuses: an unknown key, a bad dtype, a duplicate YAML key, an unparseable or unresolvable expression.

DimensionError

A dim-set rule was violated. Raised at load time, before any data.

The rest are specsolve's:

DataError

Data attached to a valid spec is missing or the wrong shape.

LayoutError

What is on disk is not a layout this package reads.

The target is a directory or archive that save wrote, or did not. The fix is which path was named, or re-solving a model whose layout has moved since it was written.

NoSolutionError

The solve returned no values to read — infeasible, unbounded, errored.

A scenario sweep catches this and records the outcome; a LanguageError instead means the file needs editing.

SpecsolveWarning

Advice from check: the spec loads and solves, and reads wrong.

Raised for a spec that is still part-written, where an expression has not yet reached what it declares.

The spec argument

Every verb takes the spec as a path, a str, a dict or a Spec: what mathspec.to_spec takes, which is never a lowered Program. So a framework that emits declarations never writes a temporary file to run them:

spec = {'dimensions': ..., 'variables': ..., 'constraints': ..., 'objective': ...}

sps.solve(spec, sources)  # a dict runs like a file
kept = to_spec(spec)  # ...or read once and keep the document
sps.solve(kept, sources)  # a Spec is not read again

to_spec(spec).to_yaml()  # the review copy — a dict-built spec still gets a file

Keep the Spec, not the Program. sps.check hands back a lowered Program for reading the plan, and no verb takes one. A Spec handed back to a verb is not read again.

A formulation is written out before a verb reads it. A piecewise: block states rows nothing lowers, so specsolve refuses a spec still carrying one, at every verb, rather than expanding it unasked, and names the way in: to_spec(spec).expand('piecewise') writes each curve out as the variables and constraints it states and keeps every sos: block, for a sink that branches on a set; to_spec(spec).expand() writes the sets out too, as binaries and linking rows, which every sink takes. Which of the two is the caller's to say, because the sinks disagree: HiGHS has no SOS concept and refuses a set, Gurobi and Xpress take one whole.

from mathspec import to_spec

sps.solve(to_spec('piecewise.yaml').expand(), sources)  # curves and sets written out — any sink
sps.solve(to_spec('sos.yaml').expand('piecewise'), sources, 'gurobi')  # the set reaches the solver as a set

A framework emits data, not YAML text, and never merges files. A generated spec must be able to show you a file. Hand-written math still starts as one.

A dict-built spec still gets a file. to_dict() and to_yaml() are the language's, and what they write is its page.

The sources argument

sources maps each declared name to its data, and a dimension's own key supplies its labels. What each value may be, and what attaching refuses, is the data contract; the type is specsolve.lanes.Source, which every verb annotates sources with.

result = sps.solve(
    'dispatch.yaml',
    {'load': 'load.parquet', 'cost': cost_frame, 'p_max': p_max_frame},
)

sources is the whole of the build's input: parameters and dimension indexes in one mapping. solver_options is not a build knob. It is forwarded to the solver verbatim.

Checking a spec

check is the CI verb. It parses, resolves and lowers the spec and attaches nothing, so a spec repository can validate every commit without the data. It returns the program: the spec lowered to the plan a build reads its rows off.

Names that differ only by case

Two declarations of one namespace whose names differ only by case are refused, whichever verb lowers the spec. Every declaration is written to disk as a file named after it, and a case-insensitive filesystem, which a stock macOS or Windows volume is, folds p and P into one file.

variable 'P' and variable 'p' differ only by case, and one answer on disk
cannot hold both: ... Tell them apart by a suffix rather than a capital:
'p_rated' beside 'p'.

The namespaces are the language's own: one flat namespace holding dimensions, relations, parameters, variables and named expressions, and constraints beside it. A constraint may carry a variable's name already, so a constraint P beside a variable p is accepted. The two are written under dual/ and primal/, which nothing folds together.

Checking against a sink

Whether a spec is sayable does not depend on the solver. Where it can land is a separate question, and sink= asks it:

sps.check('spec.yaml')  # sayable?
sps.check('spec.yaml', sink='highs')  # ...and will HiGHS take it?
sps.check('spec.yaml', sink='.lp')  # ...will the LP writer?

sink is a solver name (highs, gurobi) or an output suffix (.lp). It is optional and silent by default. With a sink named, you get back one of:

  • A refusal (SpecsolveError) if the sink has no such concept, or refuses the combination. The message names the construct, the sink, and the sinks that do take it. Only Gurobi and the LP writer take a quadratic row, and HiGHS refuses a quadratic objective beside integrality while taking either alone. HiGHS has no SOS concept, so a set on it is refused and the message names Spec.expand(), which writes the set out as binaries and linking rows every sink takes.

check answers off a declared table, with no data and no installed solver. check(m, sink='gurobi') answers on a machine that has never had gurobipy.

solve and write read the same table, so a refusal comes whether or not you asked. sps.write(m, sources, 'model.mps') on a model carrying a quadratic term is refused by name rather than written with its quadratic rows missing.

What each sink takes

The four quadratic rows, and the two sections HiGHS writes but will not read back, are probed against the shipped solvers by tests/test_sink_capability_probes.py and tests/test_gurobi_capability_probes.py. The rest are read off the APIs.

lp_file mps_file HiGHS direct Gurobi direct Xpress direct
affine rows, COO, integrality text text, MARKER native native native
semi-continuous text not written — no SC bound kSemiContinuous native native
SOS1 / SOS2 text section SOS section no concept — rewritten to binaries addSOS native
indicator text section not written no concept addGenConstrIndicator native
convex quadratic objective text section not written passHessian setMObjective no path here
nonconvex quadratic objective text section not written refused native, at default parameters no path here
quadratic objective and integrality text section not written refused native (MIQP) no path here
quadratic constraint text section, unreadable not written no concept addQConstr no path here
  • HiGHS excludes quadratic twice: by convexity, and by conjunction with integrality.
  • The lp_file column says what can be written, not what reads back. The same HiGHS parser takes the quadratic-objective section and refuses the sos and quadratic-constraint sections.
  • "No path here" describes this package, not Xpress. The Optimizer takes a Hessian; the sink in solvers/xpress.py never hands it one.

Building a model

sps.build returns a Model: the math with your data on it. Build once when one model feeds more than one sink, or is solved more than once:

model = sps.build('spec.yaml', sources)
model.write('model.lp')
result = model.solve()
model.diagnostics()  # what the build and its solves did that the answer does not show
model.row('balance', snapshot=17)  # what one row actually says

Questions about the model are build's, not solve's. How big the model is, what it did not build, what one row says and how its re-solves went are the Model's to answer.

Reading one row

row says what one constraint says at one coordinate, once the data is on it. to_latex renders the spec before any data, and result.dual('balance') gives a row's number without its terms; row is the third question, and the one a wrong model is debugged by.

print(model.row('balance', snapshot=1))
# balance[snapshot=1]: +1 p[1, wind] +50 p[1, gas] +30 p[1, coal] >= 60

The line is linopy's format, as Constraint.print() renders it, with the row's identity on the same line where linopy prints a header.

The same content is a table, for a row too wide to read and for anything that filters or joins:

row = model.row('balance', snapshot=17)
row.terms  # (variable, coordinate, coefficient), one row per term
row.sense  # '=='
row.rhs  # 80.0

A row too wide to spell out is summarised, not truncated:

print(model.row('balance', t=0))
# balance[t=0]: 301 terms — p: 300 (|coef| 0.001…0.3), slack: 1 (|coef| 1000) >= 5

The line says how much of the row each declaration contributes, and whether its coefficients span an order of magnitude. diagnostics().coefficient_range reports that spread per declaration; nothing reports it per row. display_terms sets where a line stops spelling terms out.

row reads the built row.

  • A coefficient is the number the data produced, every digit of it.
  • A term whose variable a where masked out is not there.
  • A term whose coefficient the data made exactly zero is not there either: the build prunes it.
  • A row a where removed raises, and the message names the three things that cause it.

row needs no solve.

The coordinate names every dimension of the declaration. A partial one names a set of rows rather than one. The constraint is positional, so a dimension may be called name and still be named in the coordinate. A label the dimension cannot hold (a string against an integer dimension, a stranger against a declared label set) is refused naming the dimension, not the dtypes.

There is no verb for a column. A variable's bounds are in to_yaml(); its coefficients are the transpose of row, which nothing exposes.

Reading a result

result.status, result.termination_condition, result.objective
result.spec_digest  # a digest of the spec this answered
result.record  # all of it as one Record: the row save writes, and one row of sweep.record
result.is_ok  # rolled-up verdict: not an error, abort or refusal
result.has_primal  # narrower: are there values to read
result.kept  # how much of the session this solve kept: 'nothing', 'solver' or 'progress'

result.primal('p')  # tidy table (dims…, value) in label order — the native shape
result.dual('power_balance')  # shadow prices, same shape, same join
result.activity('power_balance')  # each row's left-hand side at the solution
result.dual_ray('power_balance')  # why an infeasible model has no solution at all
result.evaluate('co2')  # a named expression at the solution, over its own dims
result.evaluate('sum(p * rate)')  # a quantity the file never named, same shape

result.to_pandas('p')  # the same, as a DataFrame
result.to_dataarray('p')  # the same, labelled: .sel / resample / plot
result.to_dataarray('power_balance', 'dual')  # a price, labelled — every bridge takes kind=
result.to_dataset()  # every variable by default; names for a subset
result.to_dataset(kind='dual')  # every dual; one kind per dataset
result.save(directory)  # the whole answer to disk: record.parquet, primal/ dual/ activity/ expression/, reasons.parquet
sps.load_result(directory)  # and back whole, every reader answering what it answered
sps.scan_result(directory)  # the same, read off the directory as you ask for it

primal returns a polars.DataFrame, one row per coordinate: a frame. It is Arrow-backed, so it exports the protocol the loader recognises. to_pandas and to_dataarray are the bridges out; they need pandas and xarray, which specsolve does not install.

Rule
is_ok is not has_primal is_ok rolls up the termination condition. has_primal adds the solver's verdict on whether an incumbent exists, and every reader gates on it. A MIP that hits time_limit before a feasible point is ok with nothing to read
reading with no primal raises NoSolutionError; objective is nan. save is the exception: it writes the record and no frames, an infeasible run being an answer a set of saved cases needs on disk
dual_ray is the one reader an infeasible solve answers a weight per row, dual's shape, certifying that the rows cannot all hold: weight each row by its value and the combination demands more than the columns can deliver inside their bounds. It is what a Benders feasibility cut is built from (decomposition). The sign is the row's own, one convention across the sinks, so a driver never asks who solved. A solve that found an answer has nothing to certify and says so
a certificate is computed only where it was asked for highs always produces one. gurobi needs solver_options={'InfUnbdInfo': 1} and xpress needs solver_options={'presolve': 0}, both set before the solve; without them the model is still refused as infeasible, and dual_ray raises naming the option. A ray is live-only: save does not write one, and no sweep spills one
evaluate takes what an expressions: entry takes a name the file declares, an expression string, or the mapping that carries cases:. A declared name is the value of that named expression at the solution, aggregated to its own dimensions, served by the reader already holding it and compiled at the read, so unread expressions cost nothing. Anything else lowers the spec again, which costs what check costs. It may use every name the spec declares and only those; one it does not is a LanguageError, because a new parameter is a build rather than a read
an undeclared expression names nothing so it is not a kind: save does not write it and a sweep does not spill it. A declared expression is: save writes it under expression/, and it rides every bridge as kind='expression'. To keep a quantity, declare it under expressions: and read it by name
dual raises rather than zero-filling no values at all is NoSolutionError; values but no duals is SpecsolveError. Any integer or binary variable makes duals undefined
an expanded set makes a model mixed-integer an sos: set written out with Spec.expand() is binaries, so an otherwise continuous model solved that way has no duals and says so. gurobi and xpress branch on the set itself and keep them
duals exist only where a solver ran a model written to LP and solved elsewhere never passes back through here. Reduced costs and slacks are not exposed
to_dataset costs what it says each variable arrives dense over its own dimensions. Name a subset, or use save
every bridge takes kind= to_pandas(name, kind), to_dataarray(name, kind) and to_dataset(*names, kind) read primal, dual or expression, primal by default. One kind per call
save writes the whole answer record.parquet says how the solve terminated — status, termination_condition, objective, has_primal, spec_digest, solved_at, run — in the columns a sweep keys per slice, so cases solved apart concatenate. solved_at is when the solver returned, in UTC; run is the archive's own name and is null until one is written, the name being the publisher's rather than the solve's. A solve that reached no objective writes null there rather than nan, so a mean over a set of cases is the mean over the ones that solved. Then primal/<name>.parquet, dual/<name>.parquet, activity/<name>.parquet and expression/<name>.parquet. A dual an integer variable made undefined, and an expression this data cannot evaluate, are left out, and reasons.parquet says why
load_result reads it back whole every reader answers what it answered, and an absence raises the sentence the solve gave. Two session facts do not survive: kept reads nothing, and a refusal carries the termination condition rather than the solver's verbatim wording. The frames are in memory when it returns, so the directory is free afterwards; scan_result is the same answer read as it is asked for, and that one the directory has to outlive (loading or scanning)
an archive checks itself before it is read against a saved answer records which model it answered — the document and the numbers — so an archive whose sources/ were replaced since it was written is refused rather than read. The refusal arrives at the first evaluate of a quantity the file never named, which is where the model is rebuilt and the first point a value could come back. Every reader the save wrote is unaffected, being a frame read off disk

Nothing has to be released. primal and the to_* readers stay valid for as long as the Result does. close() and the context-manager protocol hand a large model back early.

Writing a file instead of solving

sps.write('spec.yaml', sources, 'model.lp')

The suffix picks the writer: .lp or .mps. Anything else is a ValueError listing what can be written, raised before the build.

The two formats describe one model, and name their columns and rows the same way. LP is the one a person diffs; MPS is the one a decade-old toolchain accepts.

Re-solving with new numbers

update puts new data on a model that is already built, so a loop over the same math pays for the YAML, the plan and the build once:

model = sps.build('sub.yaml', sources)
for capacity in search:
    result = model.update({'cap_hat': capacity}).solve()
    price = result.dual('capacity')
it names what changed everything else keeps what build attached. A change is a parameter, or a dimension index under its own key; a coordinate set grows by handing over a longer table
the answer is the reference build's model.update(x) solves what build(spec, sources \| x) solves, always
it never refuses there is no capability to query and no shape of data it rejects. New values can cost the fast path, never the answer
the solver stays loaded where it can new bounds, costs and right-hand sides go onto the model the solver already holds. Whether the next solve also carries on from the work the last one did is keep=. An update that moves a mask (a parameter a where compares against) renumbers labels, so that model is loaded again and keeps nothing
earlier results keep reading a Result owns its values and the label tables of the build it answered. Retaining one keeps those tables alive until it is dropped or closed
an update that raises releases the model the same rule as build
a name the spec does not declare raises DataError an update that named nothing would silently re-solve the numbers already attached

For a sweep, a rolling horizon or a myopic pathway, solve_over is this loop written for you. update is the primitive underneath, for when the next set of numbers depends on the last answer. Where it depends on you, Change a model is the loop written out.

How much of the session a solve keeps

A session holds two things: the solver with the model on it, and the work that solver did. An update keeps the first. keep= says whether it keeps the second. The two can only be dropped in that order.

result = model.update({'load': load}).solve()
result.kept  # 'solver' — reused, and the work it did discarded

again = model.update({'load': more}).solve(keep='progress')
again.kept  # 'progress' — it carried on from where the last solve got to

baseline = model.solve(keep='nothing')  # whatever the session held, gone
baseline.kept  # 'nothing'
What it asks for Ask for it when
keep='nothing' the model handed over again, into a solver that has never seen it; diagnostics().loads ticks with it you are measuring. The held solver is discarded before the load, so cold is structural: no basis, no incumbent, no solver-internal state. A benchmark needs that, and so does comparing two sets of solver_options
keep='solver' (default) the hand-off skipped, and the solver asked to run as though the model were new until you have measured otherwise. Every ordinary update loop wants this and nothing else
keep='progress' that, and the solver left holding what its last run reached the model is hard for its solver's preprocessing and consecutive solves differ by a small step: a rolling horizon, a myopic pathway, a search that inches

keep='progress' can lose by an order of magnitude and win by a factor of two. Over six updates on HiGHS (#815), carrying the solver's work cost 76.6 s against 4.3 s on a dispatch model whose presolve cracks the problem outright, an 18× loss, and 111.2 s against 213.9 s on a storage model whose cyclic recurrence presolve cannot crack, a 1.9× win.

Which one a model wants is measured (timing a loop). The answer does not change either way: across both models above the objectives agreed to 2e-15 relative.

result.kept reports what happened, not what was asked. An update that had to rebuild reports 'nothing', whatever it asked for, and loads ticks on exactly those solves. 'nothing' on every iteration means the session is being rebuilt away.

What progress is made of stays the solver's business. kept says how much was kept, not what it was. No solver option reaches the same thing; on both solvers that ship, an option asking for it did not produce it (#815).

A rebuild carries no progress. A cutting-plane master re-solved after gaining a cut has gained a row, and a basis spans the model it was read from. #382 tracks that case.

Archiving a model

sps.solve('spec.yaml', sources, archive='case.zip')

case = sps.load_archive('case.zip', 'case/')
case.answer.primal('p')  # what came back
sps.solve(case.spec, case.sources)  # the same question, asked again

An archive is the spec, its data and its answer: spec.yaml, sources/<key>.parquet for every key the file declares, sources.parquet digesting those members, answer/ holding what result.save or sweep.save writes plus answer/metrics.parquet, and axis.json for a sweep.

The suffix decides the container, as sps.write's does. .zip packs the members into one file; anything else lays them out in a directory, which is read where it lies:

sps.solve('spec.yaml', sources, archive='case/')  # a directory
sps.load_archive('case/')  # read where it lies — no into=

sps.solve, model.solve and sps.solve_over take archive=, and nothing else writes one. Each writes the spec, the data and the answer it holds at that moment, so the three cannot be paired up wrongly.

The sources go in through the door that reads them, so what build refuses is refused here and nothing is written. A parquet path is copied as its own bytes; a table, a bare label range, a {label: value} map or a single number is written as the tidy parquet table it stands for. Members are stored uncompressed.

The recipes are archiving a solve and reading a directory of runs.

Rule
the spec is held as written spec.yaml is what the file said, so archive.spec reads back as one Spec whatever went in
anything outside the layout is refused a member the layout does not name, or no spec.yaml. A zip is refused before it is unpacked
a saved answer is stamped with its layout format.json beside the frames, 0 while the layout is still moving. Nothing reads an older layout back: the stamp turns a missing column into a sentence naming the way out, which is to solve the model again and save it
spec_digest says whether a comparison compares like with like a digest of the spec, written into every answer's record and checked when an archive is read back: an archive whose answer names another spec is refused. Across the records of cases solved apart, one distinct non-null spec_digest is the claim that every row answered the same document
the sources are digested, one row each archive.source_digests is (run, source, digest) for every member of sources/, held as sources.parquet. Two archives of one document over different numbers agree on spec_digest and differ here, and the rows that differ name the input that moved. The digest is of the parquet bytes the archive holds, so two polars versions can write one table to different digests. Reading an archive does not verify them
the metrics are the solve's, not save's archive.metrics is a Metrics (the attributes), held as answer/metrics.parquet. result.save writes none: the counters cover the model's whole life, and solves says how many solves that is. A sweep's are archive.answer.metrics, a SliceMetrics per slice
every row is stamped with run the archive's own name, on the record, the metrics and the digest table, so a directory of archives reads as one table without parsing paths
a sweep's archive carries its axis as axis.json, with the carry that chained its slices. load_archive returns a SweepArchive where the archive carries one and a SolveArchive where it does not; archived.answer is a Sweep and case.answer a Result
a sliced source is archived whole one copy carrying every slice's rows, the column the axis cuts on included
spill_to= and archive= compose the spill is what the archive packs, so a sweep too large to hold is archived without being held
a hand-built axis is refused a list of (key, sources) is a set of sources per slice. Archive one solve each. Refused before the first slice is solved
whether a model can be sliced stays solve_over's question asked when the sweep is run, not when it is archived
a sweep's answer is held or spilled, as the reader says load_archive reads every slice's frames in, so sweep.primal(name) answers; scan_archive leaves them in the extracted directory for sweep.scan(name). original_index works on both

Loading or scanning

Three saved things read back, and each reads two ways. load_ reads it whole: the frames are in memory when the call returns, so what comes back owes the directory nothing. scan_ leaves them where they lie and reads each at the call that asks for it, so the files have to outlive the value. A load reads every name; a scan reads only the ones asked for.

case = sps.load_archive('case.zip')  # whole, and nowhere to unpack
case = sps.scan_archive('case.zip', 'case/')  # read as asked for, off 'case/'
load_ scan_
a Result's frames in memory a scan_parquet per name
a Sweep held, so primal answers spilled, so scan does and primal refuses
an archive's sources the table each member holds the path to it
an archive's into= optional; a scratch directory without one required for a zip, and kept
the directory afterwards free has to stay

The pairs are load_archive / scan_archive, load_result / scan_result and load_sweep / scan_sweep. Each pair takes the same arguments, hands back the same type, and refuses the same things: a directory holding no answer, and an archive whose answer names another spec. The one difference is the into= a zip needs, which the table above gives.

A loaded value is fixed and a scanned one is not. A load leaves nothing to be read later. A scan re-reads the file at every collect, so a frame rewritten underneath it comes back changed.

Diagnostics

model.diagnostics() reports what a build and its solves did that the answer does not show. Every field is advisory. Nothing about an answer depends on any of them.

Field
columns, rows, nonzeros the shape the build produced; check cannot answer this, having no data
omissions rows a constraint declared but did not build (absence)
sparse_parameters (parameter, coordinates, rows, missing), one row per parameter whose source is short of the coordinates its dimensions reach. Sparsity is how a model masks, so this reports rather than judges: a table that lost a row and a where: that removed one build the same model, and nothing else says which
coefficient_range (constraint, smallest, largest), the coefficient magnitudes each block put in the matrix. largest / smallest over the table is the conditioning to compare against the solver's own
bound_range (variable, smallest, largest), the bound magnitudes each variable block put on its columns, zero and infinity excluded. HiGHS reports this axis (Consider scaling the bounds by …) and does not repair it. A large largest is usually a big number standing in for "uncapped", and wants no upper bound rather than a rounder one
rhs_range (constraint, smallest, largest), the same for each block's right-hand sides, over the rows that survived
objective_range the same pair for the costs, or None where the spec declares no objective
solves, loads how many solves ran, and how many of them loaded the model from scratch. loads == solves means the model masks on a parameter that varies
seconds cumulative wall-clock seconds per phase, keyed by phase name: attach, build, handoff, solve, write. write is model.write(path)'s stream, absent on a model that wrote no file. An archive's own write is no phase of a build and is not clocked

diagnostics() answers after close() too. A sweep's diagnostics are sweep.metrics, one row per slice (sweeps).

metrics() is the scalars as one row, a Metrics. The frames are not in it — a range is a table per declaration, which does not fold into a row beside a count. This is what archive= records and what archive.metrics hands back, and what a caller feeding its own store reads off a model it solved. It is eleven attributes and they are every column of answer/metrics.parquet:

Attribute
columns, rows, nonzeros the shape the build produced
solves how many solves this row covers. 1 for the archive sps.solve writes, that verb building the model it solves
loads how many of those handed the solver the model from scratch
attach_seconds the caller's sources onto the plan
build_seconds the declarations into the model frames
handoff_seconds the built model into a solver
solve_seconds the solver's own run
write_seconds model.write(path)'s stream to an LP or MPS file. Zero on an archive whose caller asked for no file, which is most of them
run the archive's own name, null until one is written. A directory named run=<name> is stamped <name>, so the column and the path agree (reading a directory of runs)

Every clock names its unit, and every one is cumulative over the solves the row covers. A phase that never ran writes zero rather than no column, so rows written by runs that never met concatenate into one table. What writing the archive cost is in no column: time the call.

Choosing a solver

The caller chooses the solver, not the file. solver_name is highs (ships with the package), gurobi (the [gurobi] extra) or xpress (the [xpress] extra). Nothing in the YAML names one. A name outside the three is an error listing them, never a quiet fallback.

Options travel in the chosen solver's own vocabulary, forwarded verbatim. A time limit is three different words:

sps.solve('spec.yaml', sources, solver_options={'time_limit': 60})
sps.solve('spec.yaml', sources, solver_name='gurobi', solver_options={'TimeLimit': 60})
sps.solve('spec.yaml', sources, solver_name='xpress', solver_options={'timelimit': 60})

Gurobi's remote and licensing options travel the same way, so Compute Server, Instant Cloud and WLS need nothing from this package:

options = {'ComputeServer': 'srv:61000', 'ServerPassword': '…'}
sps.solve('spec.yaml', sources, solver_name='gurobi', solver_options=options)

The options are applied when Gurobi's environment is created, which ComputeServer, TokenServer and WLSAccessID require.