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 ¶
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
TYPE:
|
sink
|
A solver name (
TYPE:
|
| 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
|
|
| 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 ¶
Attach sources to spec and build it — the model with your data on it.
| PARAMETER | DESCRIPTION |
|---|---|
spec
|
As
TYPE:
|
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:
|
| RETURNS | DESCRIPTION |
|---|---|
Model
|
The built model. It feeds any number of sinks — |
Model
|
|
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 ¶
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
TYPE:
|
sources
|
As
TYPE:
|
solver_name
|
TYPE:
|
solver_options
|
Forwarded to the solver verbatim, in its own
vocabulary (
TYPE:
|
archive
|
Where to write the spec, its data and this answer, as
TYPE:
|
| 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. |
| RAISES | DESCRIPTION |
|---|---|
SpecsolveError
|
A solver name nothing serves — checked before the build. |
write ¶
Build spec and stream it to a file, in the format out's suffix names.
| PARAMETER | DESCRIPTION |
|---|---|
spec
|
As
TYPE:
|
sources
|
As
TYPE:
|
out
|
Where to write;
TYPE:
|
| 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
|
evaluate ¶
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
TYPE:
|
sources
|
As
TYPE:
|
expression
|
What one
TYPE:
|
| RETURNS | DESCRIPTION |
|---|---|
DataFrame
|
The value, |
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
TYPE:
|
sources
|
As
TYPE:
|
axis
|
TYPE:
|
carry
|
TYPE:
|
key_name
|
What to call the slice column; a class axis names its own, a hand-built list has to be told.
TYPE:
|
executor
|
Any
TYPE:
|
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:
|
solver_options
|
As
TYPE:
|
solver_name
|
As
TYPE:
|
keep
|
As
TYPE:
|
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
TYPE:
|
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
TYPE:
|
| 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
¶
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.
EachWindow
dataclass
¶
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.
slices ¶
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 ¶
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.
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 ¶
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
TYPE:
|
duals
|
The same per constraint, or
TYPE:
|
no_duals
|
Why there are no duals, or
TYPE:
|
row ¶
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
TYPE:
|
coordinate
|
One label per dim of that declaration, all of them — a partial coordinate names a set of rows rather than one.
TYPE:
|
| RETURNS | DESCRIPTION |
|---|---|
ConstraintRow
|
The terms as |
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 ¶
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
|
TYPE:
|
solver_options
|
Forwarded to the solver verbatim, in its own
vocabulary (
TYPE:
|
keep
|
How much of the session this solve may keep:
TYPE:
|
archive
|
Where to write the whole thing — the spec, the data
attached to it now, and this answer — so that
TYPE:
|
| 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 ¶
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
TYPE:
|
| RETURNS | DESCRIPTION |
|---|---|
Model
|
This object, so a driver can chain. |
| RAISES | DESCRIPTION |
|---|---|
DataError
|
A name the spec does not declare. |
write ¶
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
|
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
¶
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.
kept
property
¶
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.
record
property
¶
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
¶
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
¶
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.
termination_condition
property
¶
What the solver said — optimal, infeasible, time_limit and so on.
activity ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 |
LanguageError
|
A construct outside the language, or a name the spec does not declare. |
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 ¶
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 ¶
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 ¶
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 ¶
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:
|
kind
|
TYPE:
|
to_pandas ¶
One name's values as a tidy pandas.DataFrame.
| PARAMETER | DESCRIPTION |
|---|---|
name
|
A variable, a constraint or a named expression, as kind says.
TYPE:
|
kind
|
TYPE:
|
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.
metrics
instance-attribute
¶
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
¶
(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 ¶
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 ¶
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
TYPE:
|
original_index
|
Read over the dimension the axis sliced instead of over the slice key.
TYPE:
|
| RAISES | DESCRIPTION |
|---|---|
SpecsolveError
|
No slice produced a declared expression — an
evaluation that failed on every slice carries its own reason —
a spilled sweep, which |
LanguageError
|
A construct outside the language, or a name the spec does not declare. |
primal ¶
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:
|
original_index
|
Read over the dimension the axis sliced instead of over the slice key.
TYPE:
|
| RAISES | DESCRIPTION |
|---|---|
SpecsolveError
|
No slice of the sweep produced name, or
|
save ¶
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 ¶
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:
|
kind
|
TYPE:
|
original_index
|
Read over the dimension the axis sliced instead of over the slice key.
TYPE:
|
| RAISES | DESCRIPTION |
|---|---|
SpecsolveError
|
No slice produced name, or a kind that names no reader. |
to_dataarray ¶
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 ¶
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:
|
kind
|
TYPE:
|
| RAISES | DESCRIPTION |
|---|---|
SpecsolveError
|
The sweep holds no values of kind at all, or is spilled — its frames are on disk already. |
to_pandas ¶
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:
|
kind
|
TYPE:
|
original_index
|
Read over the dimension the axis sliced instead of over the slice key.
TYPE:
|
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
¶
(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
¶
(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
¶
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.
objective_range
instance-attribute
¶
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
¶
(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
¶
(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.
seconds
instance-attribute
¶
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
¶
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
¶
(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 ¶
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
¶
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
¶
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
¶
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
¶
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
¶
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
¶
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
¶
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.
of
classmethod
¶
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:
|
objective
|
What the solve reached. Written only where there are
values to read —
TYPE:
|
has_primal
|
Whether there are values, which the condition alone does not say.
TYPE:
|
spec_digest
|
A digest of the spec answered, or
TYPE:
|
solved_at
|
When the solver returned, in UTC.
TYPE:
|
model_digest
|
The built model's digest, or
TYPE:
|
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
¶
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.
run
class-attribute
instance-attribute
¶
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.
solves
instance-attribute
¶
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.
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
¶
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.
loaded
instance-attribute
¶
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.
Carry an answer¶
SolveArchive
dataclass
¶
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:
|
sources |
What was attached, keyed as the file declares it: a table
from
TYPE:
|
answer |
What came back.
TYPE:
|
source_digests |
TYPE:
|
metrics |
What reaching the answer took, as one
TYPE:
|
SweepArchive
dataclass
¶
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:
|
sources |
What the sweep was given, uncut. A table or a path, as
TYPE:
|
axis |
What cut them.
TYPE:
|
carry |
TYPE:
|
answer |
Every slice's answer, keyed by slice. Held from
TYPE:
|
source_digests |
As
TYPE:
|
load_archive ¶
Read an archive back whole: the sources as tables, the answer's frames in memory.
| PARAMETER | DESCRIPTION |
|---|---|
path
|
The archive, a
TYPE:
|
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:
|
| RETURNS | DESCRIPTION |
|---|---|
SolveArchive | SweepArchive
|
A |
SolveArchive | SweepArchive
|
|
| RAISES | DESCRIPTION |
|---|---|
LanguageError
|
A |
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 ¶
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
TYPE:
|
| RETURNS | DESCRIPTION |
|---|---|
Result
|
The result, read whole: the frames are in memory when this returns, so |
Result
|
it owes directory nothing. |
Result
|
on disk. |
| RAISES | DESCRIPTION |
|---|---|
LayoutError
|
A directory holding no |
load_sweep ¶
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:
|
| RETURNS | DESCRIPTION |
|---|---|
Sweep
|
The sweep, keyed as it was solved. |
| RAISES | DESCRIPTION |
|---|---|
LayoutError
|
A directory holding no |
scan_archive ¶
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 ¶
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
TYPE:
|
| RAISES | DESCRIPTION |
|---|---|
LayoutError
|
As |
scan_sweep ¶
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
TYPE:
|
| RAISES | DESCRIPTION |
|---|---|
LayoutError
|
As |
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
¶
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 namesSpec.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_filecolumn says what can be written, not what reads back. The same HiGHS parser takes the quadratic-objective section and refuses thesosand quadratic-constraint sections. - "No path here" describes this package, not Xpress. The Optimizer takes
a Hessian; the sink in
solvers/xpress.pynever 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
wheremasked out is not there. - A term whose coefficient the data made exactly zero is not there either: the build prunes it.
- A row a
whereremoved 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¶
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.